@snoai/mem-claude 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hooks.js +21 -1
- package/dist/session-activity.d.ts +11 -0
- package/dist/session-activity.js +91 -0
- package/dist/session-state.d.ts +1 -0
- package/dist/session-state.js +3 -0
- package/package.json +3 -3
package/dist/hooks.js
CHANGED
|
@@ -8,6 +8,7 @@ import { importReceiptExists, importRepository } from "./import.js";
|
|
|
8
8
|
import { connectMemory, isDegradedConnection } from "./memory-client.js";
|
|
9
9
|
import { withinDeadline, } from "./recall.js";
|
|
10
10
|
import { hookScope, workspaceRoot } from "./scope.js";
|
|
11
|
+
import { reportSessionActivity } from "./session-activity.js";
|
|
11
12
|
import { readSkillRuns } from "./skill-runs.js";
|
|
12
13
|
import { startWorkerDetached } from "./worker.js";
|
|
13
14
|
import { appendSpool, readLastSession, readSession, recordDegraded, recordInjection, recordInvocation, recordLookup, recordSkip, writeLastSession, updateSession, writeSession, } from "./session-state.js";
|
|
@@ -21,7 +22,7 @@ const promptSchema = baseHookSchema.extend({ prompt_id: z.string().min(1), promp
|
|
|
21
22
|
const sessionEndSchema = baseHookSchema.extend({ transcript_path: z.string().optional(), reason: z.string().optional() });
|
|
22
23
|
const toolSchema = baseHookSchema.extend({ tool_use_id: z.string().min(1), tool_name: z.string().min(1), tool_input: z.unknown() });
|
|
23
24
|
const postToolSchema = toolSchema.extend({ tool_response: z.unknown() });
|
|
24
|
-
const stopSchema = baseHookSchema.extend({ prompt_id: z.string().min(1), last_assistant_message: z.string() });
|
|
25
|
+
const stopSchema = baseHookSchema.extend({ prompt_id: z.string().min(1), last_assistant_message: z.string(), transcript_path: z.string().optional() });
|
|
25
26
|
function emptyEnvelope(event) {
|
|
26
27
|
return JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: "" } });
|
|
27
28
|
}
|
|
@@ -188,6 +189,23 @@ export async function userPromptSubmit(raw) {
|
|
|
188
189
|
return emptyEnvelope("UserPromptSubmit");
|
|
189
190
|
}
|
|
190
191
|
}
|
|
192
|
+
/** Sends one session.activity row for the records since the last send; a failure is logged and never stops the hook. */
|
|
193
|
+
async function sendSessionActivity(input, hook) {
|
|
194
|
+
try {
|
|
195
|
+
const state = await readSession(input.session_id);
|
|
196
|
+
await reportSessionActivity(state.activity, input, hook, async (activity) => {
|
|
197
|
+
state.activity = activity;
|
|
198
|
+
await writeSession(state);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
console.error(JSON.stringify({
|
|
203
|
+
event: "session-activity",
|
|
204
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
205
|
+
impact: `no session.activity row for this ${hook}; the next send covers the same records`,
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
191
209
|
export async function stop(raw) {
|
|
192
210
|
const started = performance.now();
|
|
193
211
|
let sessionId;
|
|
@@ -198,6 +216,7 @@ export async function stop(raw) {
|
|
|
198
216
|
await persistSkip(input.session_id, "Stop", "subagent", started);
|
|
199
217
|
return;
|
|
200
218
|
}
|
|
219
|
+
await sendSessionActivity(input, "Stop");
|
|
201
220
|
if (!readCaptureSettings().ambient)
|
|
202
221
|
return;
|
|
203
222
|
sessionId = input.session_id;
|
|
@@ -334,6 +353,7 @@ export async function sessionEnd(raw) {
|
|
|
334
353
|
await persistSkip(input.session_id, "SessionEnd", "subagent", started);
|
|
335
354
|
return;
|
|
336
355
|
}
|
|
356
|
+
await sendSessionActivity(input, "SessionEnd");
|
|
337
357
|
const state = await readSession(input.session_id);
|
|
338
358
|
try {
|
|
339
359
|
const rows = await readSkillRuns(input);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ActivityCursor, type ActivityRecord } from "@snoai/memory/coding-skin";
|
|
2
|
+
export declare function claudeRecord(line: string): ActivityRecord | undefined;
|
|
3
|
+
/**
|
|
4
|
+
* Appends one session.activity row for the transcript records after the cursor, and returns the advanced cursor. Stop leaves the run open; SessionEnd closes it.
|
|
5
|
+
* `persist` saves the advanced cursor before the row is appended, so a failed save repeats the window instead of counting it twice.
|
|
6
|
+
*/
|
|
7
|
+
export declare function reportSessionActivity(cursor: ActivityCursor | undefined, input: {
|
|
8
|
+
session_id: string;
|
|
9
|
+
cwd: string;
|
|
10
|
+
transcript_path?: string | undefined;
|
|
11
|
+
}, hook: "Stop" | "SessionEnd", persist?: (cursor: ActivityCursor) => Promise<void>): Promise<ActivityCursor>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CODING_SKIN_HOOKS, appendObserveLedgerRows, EMPTY_ACTIVITY_CURSOR, foldActivity, isAgentText, readNewLines, } from "@snoai/memory/coding-skin";
|
|
4
|
+
import { detectProjectId, getSnoProfileDir } from "@snoai/observability";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { workspaceRoot } from "./scope.js";
|
|
7
|
+
/** A quarter of the hook's timeout, so reading, the ledger write and the rest of the step stay within half of it; a backlog the budget cannot finish is read by the next send. */
|
|
8
|
+
const readBudgetMs = (hook) => CODING_SKIN_HOOKS[hook].timeout * 1000 / 4;
|
|
9
|
+
const entrySchema = z.object({
|
|
10
|
+
type: z.string(),
|
|
11
|
+
timestamp: z.string().optional(),
|
|
12
|
+
isMeta: z.boolean().optional(),
|
|
13
|
+
isSidechain: z.boolean().optional(),
|
|
14
|
+
isCompactSummary: z.boolean().optional(),
|
|
15
|
+
entrypoint: z.string().optional(),
|
|
16
|
+
origin: z.object({ kind: z.string() }).optional(),
|
|
17
|
+
message: z.object({ content: z.union([z.string(), z.array(z.unknown())]).optional() }).optional(),
|
|
18
|
+
});
|
|
19
|
+
const toolResultSchema = z.object({ type: z.literal("tool_result") });
|
|
20
|
+
const textBlockSchema = z.object({ type: z.literal("text"), text: z.string() });
|
|
21
|
+
/**
|
|
22
|
+
* Who sent a user entry, from Claude Code 2.1.x transcripts on the owner's machine. Heuristics:
|
|
23
|
+
* an entry the person typed has origin.kind "human" (older entries have no origin and count as
|
|
24
|
+
* typed), but Reach rings and seat starts are typed into the terminal too and are told apart by
|
|
25
|
+
* their text; a session whose entrypoint is "sdk-*" was started by a program, so every prompt in
|
|
26
|
+
* it comes from another agent; a "<teammate-message" (wrapped as "Another Claude session sent a message:") is another
|
|
27
|
+
* Claude session's message. Skill text (isMeta), the compaction or resume summary, local command
|
|
28
|
+
* output, "!" shell commands and their output, the interrupt marker and
|
|
29
|
+
* background-task notices other than a heartbeat tick change nothing.
|
|
30
|
+
*/
|
|
31
|
+
function incomingOf(entry) {
|
|
32
|
+
if (entry.type !== "user" || entry.isSidechain || entry.isMeta || entry.isCompactSummary)
|
|
33
|
+
return undefined;
|
|
34
|
+
const content = entry.message?.content;
|
|
35
|
+
const blocks = Array.isArray(content) ? content : [];
|
|
36
|
+
if (blocks.some(block => toolResultSchema.safeParse(block).success))
|
|
37
|
+
return undefined;
|
|
38
|
+
const text = (typeof content === "string" ? content : blocks.flatMap(block => {
|
|
39
|
+
const parsed = textBlockSchema.safeParse(block);
|
|
40
|
+
return parsed.success ? [parsed.data.text] : [];
|
|
41
|
+
}).join("\n")).trimStart();
|
|
42
|
+
if (text.startsWith("<task-notification>"))
|
|
43
|
+
return isAgentText(text) ? "agent" : undefined;
|
|
44
|
+
if (text.startsWith("<teammate-message") || text.startsWith("Another Claude session sent a message:"))
|
|
45
|
+
return "agent";
|
|
46
|
+
if (["<local-command-", "<bash-", "[Request interrupted by user"].some(prefix => text.startsWith(prefix)))
|
|
47
|
+
return undefined;
|
|
48
|
+
if (isAgentText(text) || entry.entrypoint?.startsWith("sdk"))
|
|
49
|
+
return "agent";
|
|
50
|
+
if (entry.origin !== undefined && entry.origin.kind !== "human")
|
|
51
|
+
return undefined;
|
|
52
|
+
return "human";
|
|
53
|
+
}
|
|
54
|
+
export function claudeRecord(line) {
|
|
55
|
+
const entry = entrySchema.parse(JSON.parse(line));
|
|
56
|
+
if (entry.timestamp === undefined)
|
|
57
|
+
return undefined;
|
|
58
|
+
const ts = Date.parse(entry.timestamp);
|
|
59
|
+
if (!Number.isFinite(ts))
|
|
60
|
+
throw new Error("invalid-transcript-timestamp");
|
|
61
|
+
const incoming = incomingOf(entry);
|
|
62
|
+
return incoming === undefined ? { ts } : { ts, incoming };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Appends one session.activity row for the transcript records after the cursor, and returns the advanced cursor. Stop leaves the run open; SessionEnd closes it.
|
|
66
|
+
* `persist` saves the advanced cursor before the row is appended, so a failed save repeats the window instead of counting it twice.
|
|
67
|
+
*/
|
|
68
|
+
export async function reportSessionActivity(cursor, input, hook, persist = async () => { }) {
|
|
69
|
+
const from = cursor ?? EMPTY_ACTIVITY_CURSOR;
|
|
70
|
+
const transcriptPath = input.transcript_path ?? join(process.env["CLAUDE_CONFIG_DIR"] ?? join(homedir(), ".claude"), "projects", input.cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${input.session_id}.jsonl`);
|
|
71
|
+
const records = [];
|
|
72
|
+
const read = await readNewLines(transcriptPath, from.offset, readBudgetMs(hook), line => {
|
|
73
|
+
const record = claudeRecord(line);
|
|
74
|
+
if (record)
|
|
75
|
+
records.push(record);
|
|
76
|
+
});
|
|
77
|
+
// A backlog left unread means the session is not over as far as this send can tell.
|
|
78
|
+
const folded = foldActivity({ ...from, offset: read.offset }, records, hook === "SessionEnd" && read.done);
|
|
79
|
+
await persist(folded.cursor);
|
|
80
|
+
if (folded.payload) {
|
|
81
|
+
const projectId = input.cwd ? detectProjectId(await workspaceRoot(input.cwd)) : undefined;
|
|
82
|
+
appendObserveLedgerRows(getSnoProfileDir(), [{
|
|
83
|
+
ts_ms: folded.payload.window_end_ms,
|
|
84
|
+
...(projectId === undefined ? {} : { project_id: projectId }),
|
|
85
|
+
event_type: "session.activity",
|
|
86
|
+
lane: "memory",
|
|
87
|
+
payload: { harness: "claude-code", ...folded.payload },
|
|
88
|
+
}]);
|
|
89
|
+
}
|
|
90
|
+
return folded.cursor;
|
|
91
|
+
}
|
package/dist/session-state.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export declare function writeLastSession(project: string, sessionId: string, tra
|
|
|
8
8
|
declare const sessionStateSchema: z.ZodObject<{
|
|
9
9
|
sessionId: z.ZodString;
|
|
10
10
|
skillRunsReported: z.ZodDefault<z.ZodNumber>;
|
|
11
|
+
activity: z.ZodOptional<z.ZodType<import("@snoai/memory/coding-skin").ActivityCursor, unknown, z.core.$ZodTypeInternals<import("@snoai/memory/coding-skin").ActivityCursor, unknown>>>;
|
|
11
12
|
prompts: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
12
13
|
prompt: z.ZodString;
|
|
13
14
|
at: z.ZodNumber;
|
package/dist/session-state.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { mkdir, readFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
import { activityCursorSchema } from "@snoai/memory/coding-skin";
|
|
5
6
|
import { z } from "zod";
|
|
6
7
|
import { acquirePidFileLock, writeJsonAtomic } from "./files.js";
|
|
7
8
|
import { appStateRoot, sessionPath, spoolDirectory } from "./paths.js";
|
|
@@ -35,6 +36,8 @@ const receiptEventSchema = z.object({
|
|
|
35
36
|
const sessionStateSchema = z.object({
|
|
36
37
|
sessionId: z.string().min(1),
|
|
37
38
|
skillRunsReported: z.number().int().nonnegative().default(0),
|
|
39
|
+
/** Where the previous session.activity send stopped in the transcript. */
|
|
40
|
+
activity: activityCursorSchema.optional(),
|
|
38
41
|
prompts: z.record(z.string(), z.object({ prompt: z.string(), at: z.number() })).default({}),
|
|
39
42
|
receipt: z.record(z.string(), receiptEventSchema).default({}),
|
|
40
43
|
/** PreToolUse arrival time by tool_use_id, consumed by PostToolUse to measure the call. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@snoai/mem-claude",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"publishConfig": { "access": "public" },
|
|
6
6
|
"repository": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"directory": "apps/mem-claude"
|
|
10
10
|
},
|
|
11
11
|
"type": "module",
|
|
12
|
-
"description": "
|
|
12
|
+
"description": "Give Claude Code a memory. It remembers what you worked on and brings it back next session.",
|
|
13
13
|
"files": [
|
|
14
14
|
"dist",
|
|
15
15
|
"skills",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"lint:fix": "biome check --write src/"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@snoai/memory": "1.
|
|
28
|
+
"@snoai/memory": "^1.2.0",
|
|
29
29
|
"fs-ext": "^2.1.1",
|
|
30
30
|
"zod": "^4.5.4"
|
|
31
31
|
},
|