@snoai/mem-claude 1.2.8 → 1.2.10

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/README.md CHANGED
@@ -14,7 +14,7 @@ start of every session.
14
14
  the old one as retired history. Automatic recall only shows what is current.
15
15
  - **Scoped to your work.** Memory belongs to the git repository you are in, plus a global scope
16
16
  you can read everywhere. Other agents that use the same setup (Codex, OpenClaw, Hermes Agent)
17
- share it.
17
+ use the same global scope.
18
18
  - **Yours, and it stays yours.** The store is encrypted from first use with a key made once on
19
19
  your machine. Sno never receives your database or your key. There is no daemon to babysit:
20
20
  the memory service starts when Claude Code needs it.
@@ -24,7 +24,7 @@ start of every session.
24
24
 
25
25
  ## Get started
26
26
 
27
- 1. Follow the [shared memory setup](https://github.com/sno-ai/sno-station/blob/main/docs/memory-setup.md).
27
+ 1. Follow the [memory service setup](https://github.com/sno-ai/sno-station/blob/main/docs/memory-setup.md).
28
28
  It takes a few minutes and runs once for every agent on the machine.
29
29
  2. Install the plugin (Node.js 22.22.3+, 24.15.0+ or 25.9.0+, and `git`):
30
30
 
@@ -40,7 +40,7 @@ start of every session.
40
40
  sno memory recall --harness claude "indentation"
41
41
  ```
42
42
 
43
- `doctor` should report `sidecar: healthy`. To fix a wrong entry:
43
+ `doctor` should report the memory service as healthy (the line reads `sidecar: healthy`). To fix a wrong entry:
44
44
  `sno memory correct --harness claude <id> "correct text"`. Subagents and sessions outside a git
45
45
  repository read and write nothing.
46
46
 
@@ -49,10 +49,9 @@ Daily use and troubleshooting: [usage guide](https://github.com/sno-ai/sno-stati
49
49
 
50
50
  ## Modes
51
51
 
52
- **Local First** (what the shared setup selects) needs no model and no API key: capture is
52
+ **Local First** (what the memory service setup selects) needs no model and no API key: capture is
53
53
  verbatim and everything stays on your machine. **Agent Native** lets Claude Code itself do the
54
- memory work on your existing subscription, and **REM Enhanced** also uses Sno's memory models
55
- for extraction and conflict checks, which needs a Sno key in `settings.json`. Change the mode in
54
+ memory work on your existing subscription, and **REM Enhanced** adds Sno's own memory models to REM, the overnight step where your agents dream: it strengthens their memory and settles what they learned. It runs at most once a day for each part of your memory: usually overnight, or earlier the same day once about 100 new memories have piled up. Sno's models also write down what matters from each conversation when the session ends. It needs a Sno key in `settings.json`. In that mode your memory text and lessons go to your own space in Sno's cloud, which works like your own cloud drive: a Sno program processes it for you alone, and you can delete or keep it as you like. Change the mode in
56
55
  `~/.sno/settings.json`, and keep the existing encryption key.
57
56
 
58
57
  ## Under the hood
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { postToolUse, preToolUse, sessionEnd, sessionStart, stop, userPromptSubm
7
7
  import { installClaude, uninstallClaude } from "./install.js";
8
8
  import { importRepository } from "./import.js";
9
9
  import { MESSAGES } from "./messages.js";
10
- import { workspaceRoot } from "./scope.js";
10
+ import { isTemporaryDirectory, workspaceRoot } from "./scope.js";
11
11
  import { runWorker } from "./worker.js";
12
12
  async function readStdin() {
13
13
  const chunks = [];
@@ -27,6 +27,17 @@ async function main() {
27
27
  // The Cursor IDE runs Claude Code hooks with Cursor-shaped input; Sno's Cursor hooks own Cursor, so these do nothing there.
28
28
  if (input && typeof input === "object" && "cursor_version" in input)
29
29
  return 0;
30
+ // Scripted runs (a temporary directory, `claude -p`) and background-task notices are not conversations; the
31
+ // real-agent release journeys set SNO_CAPTURE_SCRIPTED=1 to be recorded anyway. Measured 2026-10-13: 616 of 785 queued turns.
32
+ if (input && process.env["SNO_CAPTURE_SCRIPTED"] !== "1" && typeof input === "object") {
33
+ const { cwd, prompt } = input;
34
+ if (typeof cwd === "string" && isTemporaryDirectory(cwd))
35
+ return 0;
36
+ if (process.env["CLAUDE_CODE_ENTRYPOINT"]?.startsWith("sdk-"))
37
+ return 0;
38
+ if (typeof prompt === "string" && prompt.trimStart().startsWith("<task-notification>"))
39
+ return 0;
40
+ }
30
41
  if (command === "session-start") {
31
42
  process.stdout.write(`${await sessionStart(input)}\n`);
32
43
  return 0;
package/dist/scope.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { ScopeCtx } from "@snoai/memory/client";
2
+ /** A session under the system temporary directory is a throwaway run (a script's or test's agent call); it is never memory. */
3
+ export declare function isTemporaryDirectory(dir: string): boolean;
2
4
  /** One workspace is the git repository root when `cwd` is inside one, else `cwd` itself. */
3
5
  export declare function workspaceRoot(cwd: string): Promise<string>;
4
6
  export declare function hookScope(project: string, sessionId: string): ScopeCtx;
package/dist/scope.js CHANGED
@@ -1,8 +1,26 @@
1
1
  import { execFile } from "node:child_process";
2
- import { resolve } from "node:path";
2
+ import { realpathSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { resolve, sep } from "node:path";
3
5
  import { promisify } from "node:util";
4
6
  import { CODING_SKIN_MANUAL_SESSION_ID } from "@snoai/memory/coding-skin";
5
7
  const execFileAsync = promisify(execFile);
8
+ /** A session under the system temporary directory is a throwaway run (a script's or test's agent call); it is never memory. */
9
+ export function isTemporaryDirectory(dir) {
10
+ const real = (path) => {
11
+ try {
12
+ return realpathSync(path);
13
+ }
14
+ catch {
15
+ return resolve(path);
16
+ }
17
+ };
18
+ const target = real(dir);
19
+ return [tmpdir(), "/tmp", "/var/tmp"].some((root) => {
20
+ const base = real(root);
21
+ return target === base || target.startsWith(base.endsWith(sep) ? base : `${base}${sep}`);
22
+ });
23
+ }
6
24
  /** One workspace is the git repository root when `cwd` is inside one, else `cwd` itself. */
7
25
  export async function workspaceRoot(cwd) {
8
26
  try {
package/dist/worker.js CHANGED
@@ -3,9 +3,9 @@ import { spawn, spawnSync } from "node:child_process";
3
3
  import { closeSync, mkdirSync, openSync } from "node:fs";
4
4
  import { createServer } from "node:http";
5
5
  import { userInfo } from "node:os";
6
- import { readFile, readdir, unlink } from "node:fs/promises";
6
+ import { readFile, readdir, rm } from "node:fs/promises";
7
7
  import { join } from "node:path";
8
- import { CODING_SKIN_CHILD_DEADLINE_MS, CODING_SKIN_CHILD_MAX_BUFFER_BYTES, CODING_SKIN_IMPORT_INTERVAL_MS, CODING_SKIN_MAX_ATTEMPTS, CODING_SKIN_RETRY_DELAYS_MS, CODING_SKIN_WORKER_LIFETIME_MS, HOST_MODEL_CALLBACK_HOST, HOST_MODEL_CALLBACK_PATH, HOST_MODEL_DEADLINE_HEADER, } from "@snoai/memory/coding-skin";
8
+ import { CODING_SKIN_CHILD_DEADLINE_MS, CODING_SKIN_CHILD_MAX_BUFFER_BYTES, CODING_SKIN_IMPORT_INTERVAL_MS, CODING_SKIN_MAX_ATTEMPTS, CODING_SKIN_MAX_PENDING_CAPTURES, CODING_SKIN_RETRY_DELAYS_MS, CODING_SKIN_WORKER_LIFETIME_MS, HOST_MODEL_CALLBACK_HOST, HOST_MODEL_CALLBACK_PATH, HOST_MODEL_DEADLINE_HEADER, } from "@snoai/memory/coding-skin";
9
9
  import { z } from "zod";
10
10
  import { MODEL_ID, SKIN_ID } from "./constants.js";
11
11
  import { acquirePidFileLock, writeJsonAtomic } from "./files.js";
@@ -183,18 +183,44 @@ async function updateImportReceipt(reference, field) {
183
183
  console.log(JSON.stringify({ event: "import-receipt-failed", path: reference.path, file: reference.file, field, error: String(error) }));
184
184
  }
185
185
  }
186
+ async function readSpool(path) {
187
+ const contents = await readFile(path, "utf8").catch(error => {
188
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
189
+ return undefined;
190
+ throw error;
191
+ });
192
+ if (contents === undefined)
193
+ return undefined;
194
+ let record;
195
+ try {
196
+ record = spoolSchema.parse(JSON.parse(contents));
197
+ }
198
+ catch (error) {
199
+ await rm(path, { force: true });
200
+ console.log(JSON.stringify({ event: "spool-discarded", path, reason: "invalid-record", error: String(error) }));
201
+ return undefined;
202
+ }
203
+ if (record.attempts >= CODING_SKIN_MAX_ATTEMPTS) {
204
+ await rm(path, { force: true });
205
+ console.log(JSON.stringify({ event: "spool-discarded", path, turnId: record.turnId, reason: "attempts-exhausted" }));
206
+ return undefined;
207
+ }
208
+ return record;
209
+ }
210
+ async function readSpoolNames() {
211
+ const names = (await readdir(spoolDirectory()).catch(() => [])).filter(name => name.endsWith(".json")).sort();
212
+ const removed = names.splice(0, Math.max(0, names.length - CODING_SKIN_MAX_PENDING_CAPTURES));
213
+ for (const name of removed)
214
+ await rm(join(spoolDirectory(), name), { force: true });
215
+ if (removed.length > 0)
216
+ console.log(JSON.stringify({ event: "spool-pruned", removed: removed.length,
217
+ remaining: names.length, reason: "capacity" }));
218
+ return names;
219
+ }
186
220
  export async function hasActionableSpool() {
187
- const names = (await readdir(spoolDirectory()).catch(() => [])).filter(name => name.endsWith(".json"));
221
+ const names = await readSpoolNames();
188
222
  for (const name of names) {
189
- const contents = await readFile(join(spoolDirectory(), name), "utf8").catch(error => {
190
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
191
- return undefined;
192
- throw error;
193
- });
194
- if (!contents)
195
- continue;
196
- const record = spoolSchema.parse(JSON.parse(contents));
197
- if (record.attempts < CODING_SKIN_MAX_ATTEMPTS)
223
+ if (await readSpool(join(spoolDirectory(), name)))
198
224
  return true;
199
225
  }
200
226
  return false;
@@ -218,6 +244,7 @@ export async function runWorker(dependencies = productionDependencies()) {
218
244
  mkdirSync(childCwd, { recursive: true, mode: 0o700 });
219
245
  const callback = await callbackServer(credential, () => currentCwd ?? childCwd, () => currentImport, () => { lastCallbackAt = now(); }, (prompt, cwd, requesterMs) => dependencies.runChild(prompt, cwd, Math.min(CODING_SKIN_CHILD_DEADLINE_MS, CODING_SKIN_WORKER_LIFETIME_MS - (now() - workerStartedAt), requesterMs ?? Infinity)));
220
246
  server = callback.server;
247
+ await readSpoolNames();
221
248
  const client = await dependencies.connect();
222
249
  const principal = client.principal || userInfo().username;
223
250
  await client.init({ principal, project: "global", session: "claude-worker", host: { sessionId: "claude-worker" } }, {
@@ -225,14 +252,25 @@ export async function runWorker(dependencies = productionDependencies()) {
225
252
  });
226
253
  console.log(JSON.stringify({ event: "worker-registered" }));
227
254
  while (true) {
228
- const spoolNames = (await readdir(spoolDirectory()).catch(() => [])).filter(name => name.endsWith(".json")).sort();
255
+ const spoolNames = await readSpoolNames();
229
256
  console.log(JSON.stringify({ event: "spool-scan", count: spoolNames.length }));
230
257
  let actionable = 0;
231
- for (const name of spoolNames) {
258
+ for (const name of spoolNames.slice(0, 1)) {
232
259
  const path = join(spoolDirectory(), name);
233
- let record = spoolSchema.parse(JSON.parse(await readFile(path, "utf8")));
234
- if (record.attempts >= CODING_SKIN_MAX_ATTEMPTS)
260
+ let record = await readSpool(path);
261
+ if (!record)
235
262
  continue;
263
+ const batch = [{ path, record }];
264
+ if (record.kind !== "import")
265
+ for (const pendingName of spoolNames.slice(1)) {
266
+ const pendingPath = join(spoolDirectory(), pendingName);
267
+ const pending = await readSpool(pendingPath);
268
+ if (pending && pending.sessionId === record.sessionId && pending.project === record.project
269
+ && pending.childCwd === record.childCwd && pending.attempts === record.attempts
270
+ && pending.retryAt === record.retryAt && pending.kind !== "import")
271
+ batch.push({ path: pendingPath, record: pending });
272
+ }
273
+ batch.sort((left, right) => left.record.at - right.record.at);
236
274
  actionable += 1;
237
275
  drainedAt = undefined;
238
276
  while (record.attempts < CODING_SKIN_MAX_ATTEMPTS) {
@@ -255,41 +293,52 @@ export async function runWorker(dependencies = productionDependencies()) {
255
293
  currentCwd = childCwd;
256
294
  currentImport = record.kind === "import";
257
295
  try {
258
- const messages = record.kind === "import"
259
- ? [{ role: "user", content: record.user, at: record.at }]
296
+ const messages = batch.flatMap(({ record: turn }) => turn.kind === "import"
297
+ ? [{ role: "user", content: turn.user, at: turn.at }]
260
298
  : [
261
- { role: "user", content: record.user, at: record.at },
262
- { role: "assistant", content: record.assistant ?? "", at: record.at },
263
- ];
299
+ { role: "user", content: turn.user, at: turn.at },
300
+ { role: "assistant", content: turn.assistant ?? "", at: turn.at },
301
+ ]);
264
302
  const result = await withinDeadline(client.capture({ turnId: record.turnId, rewindEpoch: 0, messages }, { principal, project: record.project, session: record.sessionId, host: { sessionId: record.sessionId, workspace: record.project } }), CODING_SKIN_WORKER_LIFETIME_MS - (now() - workerStartedAt));
265
303
  if (result.degraded)
266
304
  throw new Error(result.reason);
267
305
  if (!result.committed && !result.accepted && !result.skipped && !result.partial)
268
306
  throw new Error("capture not committed");
269
307
  await updateImportReceipt(record.importReceipt, result.committed || result.accepted || result.partial ? "committed" : "skipped");
270
- console.log(JSON.stringify({ event: result.partial ? "capture-partial" : result.committed ? "capture-committed" : result.accepted ? "capture-accepted" : "capture-skipped", turnId: record.turnId, committed: result.committed }));
271
- await unlink(path);
308
+ for (const entry of batch) {
309
+ console.log(JSON.stringify({ event: result.partial ? "capture-partial" : result.committed ? "capture-committed" : result.accepted ? "capture-accepted" : "capture-skipped", turnId: entry.record.turnId, committed: result.committed }));
310
+ await rm(entry.path, { force: true });
311
+ }
272
312
  break;
273
313
  }
274
314
  catch (error) {
275
315
  await updateImportReceipt(record.importReceipt, "failures");
276
316
  const attempts = record.attempts + 1;
277
- console.log(JSON.stringify({ event: "capture-failed", turnId: record.turnId, attempt: attempts,
278
- final: attempts >= CODING_SKIN_MAX_ATTEMPTS, error: error instanceof Error ? error.message : String(error) }));
317
+ for (const entry of batch)
318
+ console.log(JSON.stringify({ event: "capture-failed", turnId: entry.record.turnId, attempt: attempts,
319
+ final: attempts >= CODING_SKIN_MAX_ATTEMPTS, error: error instanceof Error ? error.message : String(error) }));
320
+ // A batch shares its tries, so only its first turn is given up; the rest keep their own counts on disk.
321
+ if (attempts >= CODING_SKIN_MAX_ATTEMPTS) {
322
+ const [first] = batch;
323
+ if (first)
324
+ await rm(first.path, { force: true });
325
+ break;
326
+ }
279
327
  const retryDelay = CODING_SKIN_RETRY_DELAYS_MS[attempts - 1];
280
328
  record = {
281
329
  ...record,
282
330
  attempts,
283
- state: attempts >= CODING_SKIN_MAX_ATTEMPTS ? "failed" : "pending",
284
- ...(attempts < CODING_SKIN_MAX_ATTEMPTS && retryDelay !== undefined ? { retryAt: now() + retryDelay } : {}),
331
+ state: "pending",
332
+ ...(retryDelay !== undefined ? { retryAt: now() + retryDelay } : {}),
285
333
  };
286
- await writeJsonAtomic(path, record);
334
+ for (const entry of batch) {
335
+ entry.record = { ...entry.record, attempts, state: "pending", retryAt: record.retryAt };
336
+ await writeJsonAtomic(entry.path, entry.record);
337
+ }
287
338
  if (now() - workerStartedAt >= CODING_SKIN_WORKER_LIFETIME_MS) {
288
339
  handoff = true;
289
340
  return "drained";
290
341
  }
291
- if (record.attempts >= CODING_SKIN_MAX_ATTEMPTS)
292
- break;
293
342
  }
294
343
  finally {
295
344
  currentCwd = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snoai/mem-claude",
3
- "version": "1.2.8",
3
+ "version": "1.2.10",
4
4
  "license": "Apache-2.0",
5
5
  "publishConfig": { "access": "public" },
6
6
  "repository": {