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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,8 @@ greplica transcript bundle --platform codex|claude|copilot|opencode --file <path
- `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.
- `greplica doctor` - verifies installation and diagnoses configuration failures. Not a required preflight before every command.
- `greplica doctor` - verifies installation and diagnoses configuration failures. Not a required preflight before every command. Prints the resolved Greplica home path and source even when the repo is not detected.
- Failed background memory updates retain capped debug copies under `$GREPLICA_HOME/logs/runs/` and append to `$GREPLICA_HOME/logs/hook-worker.jsonl`. Those paths can include agent/session output — treat them as sensitive and prune or delete when finished debugging.
- `greplica install` prepares repo state, local storage, and agent integration; normal repo commands require install first. Local and managed mode are selected independently per repository.
- `greplica login` authenticates managed mode through GitHub and stores the Greplica JWT separately in `~/.greplica/credentials.json`.

Expand Down
7 changes: 6 additions & 1 deletion apps/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type EmbeddingConfig,
type GreplicaConfig,
} from "../../libs/config/greplica-config.js";
import { resolveGreplicaHome } from "../../libs/config/greplica-home.js";
import { createGraphMemoryProvider } from "../../libs/knowledge-graph/provider-factory.js";
import type { GraphMemoryProvider } from "../../libs/knowledge-graph/provider.js";
import { createEmbedder } from "../../libs/knowledge-graph/graph-context/embedder.js";
Expand Down Expand Up @@ -666,6 +667,11 @@ function parseHookPlatform(value: string | undefined): InstallPlatform {
}

async function runDoctor(args: string[], getContext: CommandContextProvider): Promise<void> {
console.log("Greplica doctor");
const greplicaHomeInfo = resolveGreplicaHome();
console.log(`Greplica home: ${resolve(greplicaHomeInfo.path)}`);
console.log(`Greplica home source: ${greplicaHomeInfo.source}`);

let context: CommandContext;
try {
context = getContext();
Expand All @@ -677,7 +683,6 @@ async function runDoctor(args: string[], getContext: CommandContextProvider): Pr
}

let ready = true;
console.log("Greplica doctor");
console.log(`Repo: ${context.repo.repo_name}`);
console.log(`Repo root: ${context.repo.repo_root ?? ""}`);
console.log(`Remote: ${context.repo.remote_url ?? "none"}`);
Expand Down
22 changes: 20 additions & 2 deletions apps/cli/repo-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,20 @@ function defaultBranch(repoRoot: string): string {
const remoteHead = gitOptional(repoRoot, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
if (remoteHead?.startsWith("origin/")) return remoteHead.slice("origin/".length);

return "main";
// Network call — keep a short timeout so install/doctor/graph cannot hang on a bad remote.
const lsRemoteSymref = gitOptional(repoRoot, ["ls-remote", "--symref", "origin", "HEAD"], {
timeoutMs: 5_000,
});
const fromLsRemote = parseLsRemoteSymrefHead(lsRemoteSymref);
if (fromLsRemote !== undefined) return fromLsRemote;

return "unknown";
}

function parseLsRemoteSymrefHead(output: string | undefined): string | undefined {
if (output === undefined) return undefined;
const match = output.match(/^ref:\s+refs\/heads\/([^\s\t]+)/m);
return match?.[1];
}

function repoName(remoteUrl: string, repoRoot: string): string {
Expand All @@ -48,12 +61,17 @@ function repoName(remoteUrl: string, repoRoot: string): string {
return lastPart ?? basename(repoRoot);
}

function gitOptional(cwd: string, args: string[]): string | undefined {
function gitOptional(
cwd: string,
args: string[],
options: { timeoutMs?: number } = {},
): string | undefined {
try {
const output = execFileSync("git", args, {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
...(options.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
}).trim();
return output.length > 0 ? output : undefined;
} catch {
Expand Down
19 changes: 18 additions & 1 deletion libs/config/greplica-home.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
import { homedir } from "node:os";
import { join } from "node:path";

export type GreplicaHomeSource = "GREPLICA_HOME" | "ENGINEERING_CONTEXT_HOME" | "default";

export interface ResolvedGreplicaHome {
path: string;
source: GreplicaHomeSource;
}

export function resolveGreplicaHome(env: NodeJS.ProcessEnv = process.env): ResolvedGreplicaHome {
if (env.GREPLICA_HOME !== undefined && env.GREPLICA_HOME.length > 0) {
return { path: env.GREPLICA_HOME, source: "GREPLICA_HOME" };
}
if (env.ENGINEERING_CONTEXT_HOME !== undefined && env.ENGINEERING_CONTEXT_HOME.length > 0) {
return { path: env.ENGINEERING_CONTEXT_HOME, source: "ENGINEERING_CONTEXT_HOME" };
}
return { path: join(homedir(), ".greplica"), source: "default" };
}

export function greplicaHome(): string {
return process.env.GREPLICA_HOME ?? process.env.ENGINEERING_CONTEXT_HOME ?? join(homedir(), ".greplica");
return resolveGreplicaHome().path;
}
154 changes: 143 additions & 11 deletions libs/hooks/worker.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,41 @@
import { spawn } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import {
appendFileSync,
cpSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ClaimedMemoryUpdateAttempt } from "./types.js";
import { LocalAgentRuntimeStore } from "./runtime-store.js";
import { WorkerLease } from "../utils/worker-lease.js";
import { ensureGreplicaConfig } from "../config/greplica-config.js";
import { resolveGreplicaHome } from "../config/greplica-home.js";
import { canScheduleMemoryUpdates, type RepoInstallation } from "../install/repo-installation-store.js";
import { platformInstaller } from "../install/platforms/index.js";
import { openDatabase } from "../storage/sqlite/db.js";

const hookWorkerLockName = "hook-memory-update-worker";
const hookWorkerHeartbeatMs = 60 * 1000;
const retainedHookRunLimit = 20;
const hookWorkerLogLineLimit = 500;

export function hookWorkerChildEnv(
baseEnv: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
// Always resolve from the live parent process.env, then pin GREPLICA_HOME into
// the child env so a stripped/partial baseEnv cannot fall back to ~/.greplica.
const { path } = resolveGreplicaHome(process.env);
return {
...baseEnv,
GREPLICA_HOME: path,
};
}

export function startHookWorker(): void {
const script = process.argv[1];
Expand All @@ -21,11 +45,16 @@ export function startHookWorker(): void {
const child = spawn(process.execPath, [script, "hook", "worker"], {
detached: true,
stdio: "ignore",
env: process.env,
env: hookWorkerChildEnv(),
});
child.unref();
} catch {
// Hooks must stay best-effort and fast. A later hook can try again.
} catch (error: unknown) {
appendHookWorkerFailureLog({
platform: null,
session_id: null,
phase: "spawn_worker",
error: errorMessage(error),
});
}
}

Expand Down Expand Up @@ -62,7 +91,7 @@ export async function runHookWorker(): Promise<void> {
}
}

async function maybeUpdateWorkingMemory(attempt: ClaimedMemoryUpdateAttempt): Promise<void> {
export async function maybeUpdateWorkingMemory(attempt: ClaimedMemoryUpdateAttempt): Promise<void> {
const cwd = attempt.session.cwd;
const transcriptPath = attempt.session.transcript_path;
if (cwd === null || transcriptPath === null) return;
Expand All @@ -83,31 +112,53 @@ async function maybeUpdateWorkingMemory(attempt: ClaimedMemoryUpdateAttempt): Pr
join(tmpdir(), `greplica-hook-${safePathSegment(attempt.session.platform)}-${safePathSegment(attempt.session.session_id)}-`),
);
const proposalPath = join(runDir, "working-memory.proposal.json");
let succeeded = false;
let retained = false;

try {
await runner.runWorkingMemoryUpdate({
cwd,
env: {
...process.env,
...hookWorkerChildEnv(),
GREPLICA_HOOK_DISABLE: "1",
},
prompt: updateWorkingMemoryPrompt(transcriptMarkdown, attempt, sessionRef, proposalPath),
transcriptPath: join(runDir, "agent-events.jsonl"),
finalMessagePath: join(runDir, "final-message.md"),
});
} catch {
// Failed background updates should not affect foreground hook sessions.
succeeded = true;
} catch (error: unknown) {
appendHookWorkerFailureLog({
platform: attempt.session.platform,
session_id: attempt.session.session_id,
phase: "working_memory_update",
error: errorMessage(error),
});
retained = retainFailedHookRun(runDir, attempt);
} finally {
rmSync(runDir, { recursive: true, force: true });
if (succeeded) {
rmSync(runDir, { recursive: true, force: true });
} else if (retained) {
// Agent close handlers may still write into runDir after spawn failure rejects.
// Wait briefly (keeping the process alive), then remove the OS temp copy now that
// a copy exists under $GREPLICA_HOME/logs/runs.
await new Promise((resolve) => setTimeout(resolve, 1_000));
try {
rmSync(runDir, { recursive: true, force: true });
} catch {
// Best-effort; leave for inspection if still locked.
}
}
}
}

function updateWorkingMemoryPrompt(
export function updateWorkingMemoryPrompt(
transcriptMarkdown: string,
attempt: ClaimedMemoryUpdateAttempt,
sessionRef: string,
proposalPath: string,
): string {
const escapedTranscript = escapeFilteredSessionTranscript(transcriptMarkdown.trim());
return `Run the greplica-update-working-memory skill for a completed coding-agent session. If your runtime supports slash-command skills, invoke /greplica-update-working-memory for this task.

Use the filtered session transcript below as the session context. It has been projected to Markdown with session metadata and human/agent text messages only.
Expand All @@ -128,11 +179,92 @@ Session:
- due_reason: ${attempt.reason}

<filtered_session_transcript>
${transcriptMarkdown.trim()}
${escapedTranscript}
</filtered_session_transcript>
`;
}

export function appendHookWorkerFailureLog(entry: {
platform: string | null;
session_id: string | null;
phase: string;
error: string;
}): void {
try {
const home = resolveGreplicaHome().path;
const logsDir = join(home, "logs");
mkdirSync(logsDir, { recursive: true });
const logPath = join(logsDir, "hook-worker.jsonl");
const line = JSON.stringify({
timestamp: new Date().toISOString(),
platform: entry.platform,
session_id: entry.session_id,
phase: entry.phase,
error: entry.error,
});
appendFileSync(logPath, `${line}\n`, "utf8");
pruneHookWorkerFailureLog(logPath);
} catch {
// Logging is best-effort; never throw from the failure path.
}
}

function pruneHookWorkerFailureLog(logPath: string): void {
try {
const lines = readFileSync(logPath, "utf8").split("\n");
// Keep trailing empty line after the last JSON object when present.
const nonempty = lines.filter((line) => line.length > 0);
if (nonempty.length <= hookWorkerLogLineLimit) return;
const kept = nonempty.slice(-hookWorkerLogLineLimit);
writeFileSync(logPath, `${kept.join("\n")}\n`, "utf8");
} catch {
// Best-effort rotation.
}
}

function retainFailedHookRun(runDir: string, attempt: ClaimedMemoryUpdateAttempt): boolean {
try {
const home = resolveGreplicaHome().path;
const runsDir = join(home, "logs", "runs");
mkdirSync(runsDir, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const retainedName = `${stamp}-${safePathSegment(attempt.session.platform)}-${safePathSegment(attempt.session.session_id)}`;
const destination = join(runsDir, retainedName);
// May contain agent transcript/event output — retained briefly for debugging under
// $GREPLICA_HOME/logs/runs (capped by pruneRetainedHookRuns). Treat as sensitive.
cpSync(runDir, destination, { recursive: true });
pruneRetainedHookRuns(runsDir);
return true;
} catch {
// If retention fails, leave runDir in place (skip cleanup) for inspection.
return false;
}
}

function pruneRetainedHookRuns(runsDir: string): void {
const entries = readdirSync(runsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
const excess = entries.length - retainedHookRunLimit;
if (excess <= 0) return;
for (const name of entries.slice(0, excess)) {
rmSync(join(runsDir, name), { recursive: true, force: true });
}
}

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

/** Neutralize literal closing tags so pasted transcript content cannot break the evidence fence. */
function escapeFilteredSessionTranscript(transcriptMarkdown: string): string {
return transcriptMarkdown.replaceAll(
"</filtered_session_transcript>",
"</\u200Bfiltered_session_transcript>",
);
}

function safePathSegment(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "unknown";
}
Loading