Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "openviking-memory",
"version": "0.7.15",
"version": "0.7.16",
"description": "Long-term semantic memory for Claude Code, powered by OpenViking. Auto-recall relevant memories at session start and capture important information during conversations.",
"author": {
"name": "OpenViking",
Expand Down
2 changes: 1 addition & 1 deletion examples/claude-code-memory-plugin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "claude-code-openviking-memory",
"version": "0.7.15",
"version": "0.7.16",
"description": "OpenViking memory plugin for Claude Code — semantic long-term memory",
"type": "module",
"scripts": {
Expand Down
57 changes: 57 additions & 0 deletions examples/claude-code-memory-plugin/scripts/auto-capture.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -512,3 +512,60 @@ test("api-request-blob lines are not captured as conversation turns", () => {
["assistant", "the number of captured turns"],
]);
});

test("a subagent stopping again pushes and commits only its new turns", async () => {
const root = await mkdtemp(join(tmpdir(), "ov-cc-subagent-cursor-"));
const transcriptPath = join(root, "subagent.jsonl");
const batches = [];
let commits = 0;
const lines = [
JSON.stringify({ role: "user", content: "first task for the subagent" }),
JSON.stringify({ role: "assistant", content: "first task done" }),
];
try {
await writeFile(transcriptPath, lines.join("\n"));
await withMockOpenViking(async (req, res) => {
const url = new URL(req.url, "http://127.0.0.1");
if (req.method === "GET" && url.pathname === "/health") {
writeJson(res, { status: "ok", result: { healthy: true } });
} else if (req.method === "POST" && url.pathname.endsWith("/messages/batch")) {
batches.push(await readRequestBody(req));
writeJson(res, { status: "ok", result: { added: batches.at(-1).messages.length } });
} else if (req.method === "POST" && url.pathname.endsWith("/commit")) {
await readRequestBody(req);
commits++;
writeJson(res, { status: "ok", result: {} });
} else {
writeJson(res, { status: "ok", result: {} });
}
}, async (baseUrl) => {
const env = hookEnv(root, baseUrl);
const input = {
session_id: "parent-cursor",
agent_id: "child-cursor",
agent_transcript_path: transcriptPath,
cwd: root,
};
await runHook("subagent-start.mjs", input, env);
await runHook("subagent-stop.mjs", input, env);
// Woken again: the transcript grows and the start hook may run again.
await writeFile(transcriptPath, [
...lines,
JSON.stringify({ role: "user", content: "follow-up from a background task" }),
].join("\n"));
await runHook("subagent-start.mjs", input, env);
await runHook("subagent-stop.mjs", input, env);
// Stopping with nothing new must not commit an empty archive.
await runHook("subagent-stop.mjs", input, env);
});
const texts = (batch) => batch.messages.flatMap(
(message) => message.parts.filter((p) => p.type === "text").map((p) => p.text),
);
assert.equal(batches.length, 2);
assert.deepEqual(texts(batches[0]), ["first task for the subagent", "first task done"]);
assert.deepEqual(texts(batches[1]), ["follow-up from a background task"]);
assert.equal(commits, 2);
} finally {
await rm(root, { recursive: true, force: true });
}
});
10 changes: 9 additions & 1 deletion examples/claude-code-memory-plugin/scripts/subagent-start.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* fire — SubagentStart/Stop are the only two events we see for subagents.
*/

import { writeFile, mkdir } from "node:fs/promises";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { isPluginEnabled, loadConfig } from "./config.mjs";
Expand Down Expand Up @@ -67,6 +67,13 @@ runHookStage({
// own OV session distinct from the parent.
const ovSessionId = deriveOvSessionId(sessionId, `subagent:${subagentId}`);

// A resumed subagent keeps its agent_id; keep the capture cursor so its next
// stop does not resend turns an earlier stop already pushed.
let capturedTurnCount;
try {
capturedTurnCount = JSON.parse(await readFile(stateFile(subagentId), "utf-8")).capturedTurnCount;
} catch { /* no earlier state */ }

try {
await mkdir(STATE_DIR, { recursive: true });
await writeFile(
Expand All @@ -79,6 +86,7 @@ runHookStage({
peerId: effectivePeer.peerId,
peerSource: effectivePeer.source,
startedAt: Date.now(),
capturedTurnCount,
}),
);
} catch (err) {
Expand Down
33 changes: 28 additions & 5 deletions examples/claude-code-memory-plugin/scripts/subagent-stop.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,16 @@
* created in subagent-start.mjs. An immediate commit runs so the subagent's
* context is archived before the parent continues.
*
* A subagent can stop more than once (woken again by a background task
* notification or SendMessage), and each stop hands us the whole transcript.
* The state file keeps a capturedTurnCount cursor, like auto-capture, so later
* stops push and commit only the turns added since the previous one.
*
* Each subagent is written to a distinct OpenViking session derived from the
* parent session id and Claude's subagent id.
*/

import { readFile, unlink } from "node:fs/promises";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { isPluginEnabled, loadConfig } from "./config.mjs";
Expand Down Expand Up @@ -71,6 +76,15 @@ async function loadState(subagentId) {
}
}

async function saveState(subagentId, state) {
try {
await mkdir(STATE_DIR, { recursive: true });
await writeFile(stateFile(subagentId), JSON.stringify(state));
} catch (err) {
logError("state_write", err);
}
}

async function pushTurns(cfg, ovSessionId, turns, { peerId = null, enqueueOnly = false } = {}) {
const fetchJSON = makeFetchJSON(cfg);
let ok = 0;
Expand Down Expand Up @@ -171,38 +185,47 @@ async function main() {

const messages = parseTranscript(transcript);
const turns = extractCaptureTurns(messages, cfg);
const capturedTurnCount = state?.capturedTurnCount || 0;
const newTurns = turns.slice(capturedTurnCount);
log("transcript_parse", {
subagentId,
ovSessionId,
totalTurns: turns.length,
previouslyCaptured: capturedTurnCount,
newTurns: newTurns.length,
});

if (turns.length === 0) {
await unlink(stateFile(subagentId)).catch(() => {});
return;
}

if (newTurns.length === 0) {
log("skip", { reason: "no new turns" });
return;
}

const peerId = peerIdFromSubagent(cfg, subagentId, state, sessionId, cwd);
const fetchJSON = makeFetchJSON(cfg);
const health = await fetchJSON("/health");
let result;
if (health.ok) {
result = await pushTurns(cfg, ovSessionId, turns, { peerId });
result = await pushTurns(cfg, ovSessionId, newTurns, { peerId });
} else if (isRetryableFailure(health)) {
logError("health_check", "server unreachable; enqueuing subagent capture");
result = await pushTurns(cfg, ovSessionId, turns, { peerId, enqueueOnly: true });
result = await pushTurns(cfg, ovSessionId, newTurns, { peerId, enqueueOnly: true });
} else {
logError("health_check", `non-retryable status ${health.status || "unknown"}`);
return;
}
log("push_turns", { ovSessionId, ...result });

if (result.enqueueFailed > 0) {
logError("pending_enqueue", "some turns failed to enqueue; state file retained");
logError("pending_enqueue", "some turns failed to enqueue; state not advanced");
return;
}

await unlink(stateFile(subagentId)).catch(() => {});
await saveState(subagentId, { ...state, ovSessionId, capturedTurnCount: turns.length });
});
}

Expand Down
Loading