@snoai/mem-claude 1.1.0 → 1.2.1

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 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
+ }
@@ -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;
@@ -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.1.0",
3
+ "version": "1.2.1",
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": "Thin Claude CLI client for Sno Station Mem.",
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.1.0",
28
+ "@snoai/memory": "^1.2.0",
29
29
  "fs-ext": "^2.1.1",
30
30
  "zod": "^4.5.4"
31
31
  },