@snoai/mem-claude 1.2.9 → 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 +5 -6
- package/dist/cli.js +12 -1
- package/dist/scope.d.ts +2 -0
- package/dist/scope.js +19 -1
- package/dist/worker.js +79 -30
- package/package.json +1 -1
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
|
-
|
|
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 [
|
|
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
|
|
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
|
|
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**
|
|
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 {
|
|
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,
|
|
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 =
|
|
221
|
+
const names = await readSpoolNames();
|
|
188
222
|
for (const name of names) {
|
|
189
|
-
|
|
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 =
|
|
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 =
|
|
234
|
-
if (record
|
|
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:
|
|
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:
|
|
262
|
-
{ role: "assistant", content:
|
|
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
|
-
|
|
271
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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:
|
|
284
|
-
...(
|
|
331
|
+
state: "pending",
|
|
332
|
+
...(retryDelay !== undefined ? { retryAt: now() + retryDelay } : {}),
|
|
285
333
|
};
|
|
286
|
-
|
|
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;
|