Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ greplica graph context "<query>" [--debug]
greplica graph audit anchors
greplica graph view [--out <file>] [--no-open]
greplica graph export <dir>
greplica git ingest [--max-commits <n>] [--max-age <days>] [--prs] [--github-token <token>] [--dry-run]
greplica git watch [--daemon] [--once] [--interval <seconds>] [--anchor-threshold <days>]
greplica proposal validate <proposal.json>
greplica proposal apply <proposal.json>
greplica session mark-memory-current --session-ref <ref>
Expand All @@ -136,6 +138,8 @@ greplica transcript bundle --platform codex|claude|copilot|opencode --file <path
- `greplica graph context "<query>"` - returns Markdown for agent use. Add `--debug` for the full retrieval payload with ranking signals.
- `greplica graph read` - prints the current graph view: all components, flows, claims, sources, and edges in scope.
- `greplica graph view` to visualise the current memory in a local HTML, opens in your default browser. Use `--out` to choose where the file is written; by default it goes to a temp path.
- `greplica git ingest` deterministically bootstraps memory from conventional commits, changed modules, and repeated co-change patterns. Add `--prs` to enrich commit provenance from GitHub or `--dry-run` to inspect the proposal.
- `greplica git watch` checks changed code anchors after new commits and marks stale claims for review. Use `--daemon` for continuous polling or `--once` for automation.
- `greplica transcript bundle` - converts one or more Codex, Claude Code, GitHub Copilot CLI, or OpenCode transcripts into a sanitized Markdown bundle for `greplica-fast-session-bootstrap`.
- `greplica embeddings prewarm` - downloads and initializes the local embedding model ahead of the first query when local embeddings are configured.
- `greplica session mark-memory-current` - marks a tracked agent session as already reflected in working memory.
Expand Down
238 changes: 238 additions & 0 deletions apps/cli/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { setTimeout as sleep } from "node:timers/promises";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { isatty } from "node:tty";
import { basename, dirname, join, resolve } from "node:path";
Expand All @@ -17,6 +18,7 @@ import { graphContextConfigFromGreplicaConfig } from "../../libs/knowledge-graph
import { createEmbedder } from "../../libs/knowledge-graph/graph-context/embedder.js";
import { renderGraphContextMarkdown } from "../../libs/knowledge-graph/graph-context/render.js";
import { buildGraphFolderExport } from "../../libs/knowledge-graph/folder-export.js";
import type { MemoryCommitProposal } from "../../libs/knowledge-graph/proposal.js";
import { buildTranscriptBundle } from "../../libs/session-transcript/bundle.js";
import { installGreplica, platformDisplayName } from "../../libs/install/install.js";
import { allPlatformInstallers, platformInstaller } from "../../libs/install/platforms/index.js";
Expand All @@ -29,6 +31,8 @@ import { runHookWorker, shouldRunAutoMemoryUpdates, startHookWorker } from "../.
import { withLocalModelLock } from "../../libs/knowledge-graph/graph-context/local-model-lock.js";
import { openDatabase } from "../../libs/storage/sqlite/db.js";
import { SqliteRepository as SqliteKnowledgeGraphRepository } from "../../libs/storage/sqlite/repository.js";
import { attachPullRequestMetadata, collectGitHistory, generateGitHistoryProposal } from "../../libs/git-memory/history.js";
import { runGitWatchCheck } from "../../libs/git-memory/watch.js";
import { detectRepoContext } from "./repo-context.js";

interface CommandContext {
Expand Down Expand Up @@ -122,6 +126,20 @@ const cliCommands = [
handler: withCommandContext(runGraphViewCommand),
showInTopLevelHelp: true,
},
{
key: "gitIngest",
path: ["git", "ingest"],
usage: "git ingest [--max-commits <n>] [--max-age <days>] [--prs] [--github-token <token>] [--dry-run]",
handler: runGitIngestCommand,
showInTopLevelHelp: true,
},
{
key: "gitWatch",
path: ["git", "watch"],
usage: "git watch [--daemon] [--once] [--interval <seconds>] [--anchor-threshold <days>]",
handler: runGitWatchCommand,
showInTopLevelHelp: true,
},
{
key: "proposalValidate",
path: ["proposal", "validate"],
Expand Down Expand Up @@ -312,6 +330,72 @@ function runGraphViewCommand(args: string[], getContext: CommandContextProvider)
}
}

async function runGitIngestCommand(args: string[]): Promise<void> {
const options = parseGitIngestArgs(args);
const context = createGitMemoryContext();
try {
context.service.requireRepo(context.repo);
const repoRoot = context.repo.repo_root ?? process.cwd();
let commits = collectGitHistory(repoRoot, {
maxCommits: options.maxCommits,
maxAgeDays: options.maxAgeDays,
});
if (options.prs) {
commits = await attachPullRequestMetadata(
commits,
context.repo.remote_url,
options.githubToken ?? process.env.GITHUB_TOKEN,
);
}
const plan = generateGitHistoryProposal(repoRoot, commits, context.service.readGraph(context.repo));
printGitIngestStats(plan.stats);
if (options.dryRun) {
console.log("");
console.log(JSON.stringify(plan.proposal, null, 2));
return;
}
if (!proposalHasCreates(plan.proposal)) {
console.log("No new git-derived memory to apply.");
return;
}
const result = await context.service.applyProposal(context.repo, plan.proposal);
console.log(`Applied git history proposal as memory commit ${result.memory_commit_id}.`);
} finally {
context.service.close();
}
}

async function runGitWatchCommand(args: string[]): Promise<void> {
const options = parseGitWatchArgs(args);
if (options.daemon) {
startGitWatchDaemon(options);
console.log("Started Greplica git watch daemon.");
return;
}

const context = createGitMemoryContext();
try {
do {
const result = await runGitWatchCheck(
context.repo.repo_root ?? process.cwd(),
context.repo,
context.service,
context.repository,
{ anchorThresholdDays: options.anchorThresholdDays },
);
console.log(
result.skipped
? `Git watch: ${result.head.slice(0, 12)} unchanged; no audit due.`
: `Git watch: audited ${result.audited_claims} claim(s), marked ${result.marked_claims} for review.`,
);
if (options.once) return;
await sleep(options.intervalSeconds * 1000);
} while (true);
} finally {
context.service.close();
}
}

async function runProposalValidateCommand(args: string[], getContext: CommandContextProvider): Promise<void> {
const file = requireFile(args[0], usage("proposalValidate"));
const { repo, service } = getContext();
Expand Down Expand Up @@ -359,6 +443,7 @@ function printAnchorAudit(result: ClaimAnchorAuditResult): void {
printAuditSection("Missing anchors", result.missing_anchors, (issue) => issue.claim_id);
printAuditSection("Invalid files", result.missing_files, (issue) => `${issue.claim_id} -> ${formatAuditAnchor(issue.anchor)}`);
printAuditSection("Missing symbols", result.missing_symbols, (issue) => `${issue.claim_id} -> ${formatAuditAnchor(issue.anchor)}`);
printAuditSection("Drifted anchors", result.drifted, (issue) => `${issue.claim_id} -> ${formatAuditAnchor(issue.anchor)}`);
printAuditSection("Ambiguous symbols", result.ambiguous_symbols, (issue) => `${issue.claim_id} -> ${formatAuditAnchor(issue.anchor)}`);
printAuditSection("Unsupported languages", result.unsupported_languages, (issue) => `${issue.claim_id} -> ${formatAuditAnchor(issue.anchor)}`);
}
Expand All @@ -367,6 +452,7 @@ function anchorAuditIssueCount(result: ClaimAnchorAuditResult): number {
return result.missing_anchors.length +
result.missing_files.length +
result.missing_symbols.length +
result.drifted.length +
result.ambiguous_symbols.length +
result.unsupported_languages.length;
}
Expand Down Expand Up @@ -410,6 +496,18 @@ function withCommandContext(handler: CommandContextHandler): CliCommand["handler
};
}

function createGitMemoryContext(): CommandContext & {
repository: SqliteKnowledgeGraphRepository;
} {
const repo = detectRepoContext();
const env = loadRepoEnv(repo.repo_root ?? process.cwd());
const config = ensureGreplicaConfig();
const db = openDatabase();
const repository = new SqliteKnowledgeGraphRepository(db);
const service = new KnowledgeGraphService(repository, graphContextConfigFromGreplicaConfig(config));
return { repo, env, config, service, repository };
}

function runHookIngest(args: string[]): void {
if (process.env.GREPLICA_HOOK_DISABLE === "1") return;

Expand Down Expand Up @@ -736,6 +834,96 @@ interface TranscriptBundleOptions {
outputPath: string;
}

interface GitIngestOptions {
maxCommits: number;
maxAgeDays?: number;
prs: boolean;
githubToken?: string;
dryRun: boolean;
}

interface GitWatchCliOptions {
daemon: boolean;
once: boolean;
intervalSeconds: number;
anchorThresholdDays: number;
}

function parseGitIngestArgs(args: string[]): GitIngestOptions {
const options: GitIngestOptions = { maxCommits: 200, prs: false, dryRun: false };
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--prs") {
options.prs = true;
continue;
}
if (arg === "--dry-run") {
options.dryRun = true;
continue;
}
if (arg === "--max-commits" || arg === "--max-age" || arg === "--github-token") {
const value = requireFlagValue(args, index, arg, usage("gitIngest"));
if (arg === "--max-commits") options.maxCommits = positiveInteger(value, arg, usage("gitIngest"));
if (arg === "--max-age") options.maxAgeDays = positiveInteger(value, arg, usage("gitIngest"));
if (arg === "--github-token") options.githubToken = value;
index += 1;
continue;
}
if (arg.startsWith("--max-commits=")) {
options.maxCommits = positiveInteger(arg.slice("--max-commits=".length), "--max-commits", usage("gitIngest"));
continue;
}
if (arg.startsWith("--max-age=")) {
options.maxAgeDays = positiveInteger(arg.slice("--max-age=".length), "--max-age", usage("gitIngest"));
continue;
}
if (arg.startsWith("--github-token=")) {
options.githubToken = requireInlineFlagValue(arg.slice("--github-token=".length), "--github-token", usage("gitIngest"));
continue;
}
throw new Error(usage("gitIngest"));
}
return options;
}

function parseGitWatchArgs(args: string[]): GitWatchCliOptions {
const options: GitWatchCliOptions = {
daemon: false,
once: false,
intervalSeconds: 30,
anchorThresholdDays: 7,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--daemon") {
options.daemon = true;
continue;
}
if (arg === "--once") {
options.once = true;
continue;
}
if (arg === "--interval" || arg === "--anchor-threshold") {
const value = requireFlagValue(args, index, arg, usage("gitWatch"));
if (arg === "--interval") options.intervalSeconds = positiveInteger(value, arg, usage("gitWatch"));
if (arg === "--anchor-threshold") options.anchorThresholdDays = positiveInteger(value, arg, usage("gitWatch"));
index += 1;
continue;
}
if (arg.startsWith("--interval=")) {
options.intervalSeconds = positiveInteger(arg.slice("--interval=".length), "--interval", usage("gitWatch"));
continue;
}
if (arg.startsWith("--anchor-threshold=")) {
options.anchorThresholdDays = positiveInteger(arg.slice("--anchor-threshold=".length), "--anchor-threshold", usage("gitWatch"));
continue;
}
throw new Error(usage("gitWatch"));
}
if (options.daemon && options.once) throw new Error(`--daemon and --once cannot be combined.\n${usage("gitWatch")}`);
return options;
}

function parseTranscriptBundleArgs(args: string[]): TranscriptBundleOptions {
let platform: InstallPlatform | undefined;
let outputPath: string | undefined;
Expand Down Expand Up @@ -792,6 +980,17 @@ function requireFlagValue(args: string[], index: number, flag: string, usageText
return value;
}

function requireInlineFlagValue(value: string, flag: string, usageText: string): string {
if (value.trim().length === 0) throw new Error(`Missing value for ${flag}.\n${usageText}`);
return value;
}

function positiveInteger(value: string, flag: string, usageText: string): number {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${flag} must be a positive integer.\n${usageText}`);
return parsed;
}

function parseInstallPlatform(value: string): InstallPlatform {
if ((installPlatforms as readonly string[]).includes(value)) return value as InstallPlatform;
throw new Error(`Invalid --platform ${value}.\n${usage("install")}`);
Expand Down Expand Up @@ -847,6 +1046,45 @@ function printInstallResult(result: Awaited<ReturnType<typeof installGreplica>>)
for (const note of result.notes) console.log(`- ${note}`);
}

function printGitIngestStats(stats: {
commits_analyzed: number;
claims: number;
components: number;
flows: number;
sources: number;
edges: number;
}): void {
console.log(`Commits analyzed: ${stats.commits_analyzed}`);
console.log(`Claims to create: ${stats.claims}`);
console.log(`Components to create: ${stats.components}`);
console.log(`Flows to create: ${stats.flows}`);
console.log(`Sources to create: ${stats.sources}`);
console.log(`Edges to create: ${stats.edges}`);
}

function proposalHasCreates(proposal: MemoryCommitProposal): boolean {
return Object.values(proposal.creates).some((items) => Array.isArray(items) && items.length > 0);
}

function startGitWatchDaemon(options: GitWatchCliOptions): void {
const script = process.argv[1];
if (script === undefined) throw new Error("Cannot start git watch daemon without a CLI script path.");
const child = spawn(
process.execPath,
[
script,
"git",
"watch",
"--interval",
String(options.intervalSeconds),
"--anchor-threshold",
String(options.anchorThresholdDays),
],
{ detached: true, stdio: "ignore", env: process.env },
);
child.unref();
}

function cliName(): string {
return basename(process.argv[1] ?? "greplica");
}
Expand Down
Loading