@snoai/mem-claude 1.2.4 → 1.2.6

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
@@ -29,8 +29,7 @@ start of every session.
29
29
  2. Install the plugin (Node.js 22.22.3+, 24.15.0+ or 25.9.0+, and `git`):
30
30
 
31
31
  ```bash
32
- sh -c 'sno_installer_body=$(curl -fsSL https://sno.ai/install) && printf "%s\n" "$sno_installer_body" | sh'
33
- sno setup --harness claude
32
+ sh -c 'sno_installer_body=$(curl -fsSL https://sno.ai/install) && printf "%s\n" "$sno_installer_body" | sh' && ~/.local/bin/sno setup --harness claude
34
33
  ```
35
34
 
36
35
  3. Open a new Claude Code session inside a git repository, then try it:
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { isAbsolute, join } from "node:path";
4
4
  import { doctor } from "./doctor.js";
5
5
  import { correctCommand, getCommand, recallCommand, rememberCommand } from "./explicit.js";
6
6
  import { postToolUse, preToolUse, sessionEnd, sessionStart, stop, userPromptSubmit } from "./hooks.js";
7
- import { installClaude } from "./install.js";
7
+ import { installClaude, uninstallClaude } from "./install.js";
8
8
  import { importRepository } from "./import.js";
9
9
  import { MESSAGES } from "./messages.js";
10
10
  import { workspaceRoot } from "./scope.js";
@@ -20,30 +20,35 @@ function option(args, name) {
20
20
  const index = args.indexOf(name);
21
21
  return index >= 0 ? args[index + 1] : undefined;
22
22
  }
23
+ const HOOK_COMMANDS = ["session-start", "user-prompt-submit", "stop", "session-end", "pre-tool-use", "post-tool-use"];
23
24
  async function main() {
24
25
  const [command, ...args] = process.argv.slice(2);
26
+ const input = HOOK_COMMANDS.includes(command ?? "") ? await readStdin() : undefined;
27
+ // The Cursor IDE runs Claude Code hooks with Cursor-shaped input; Sno's Cursor hooks own Cursor, so these do nothing there.
28
+ if (input && typeof input === "object" && "cursor_version" in input)
29
+ return 0;
25
30
  if (command === "session-start") {
26
- process.stdout.write(`${await sessionStart(await readStdin())}\n`);
31
+ process.stdout.write(`${await sessionStart(input)}\n`);
27
32
  return 0;
28
33
  }
29
34
  if (command === "user-prompt-submit") {
30
- process.stdout.write(`${await userPromptSubmit(await readStdin())}\n`);
35
+ process.stdout.write(`${await userPromptSubmit(input)}\n`);
31
36
  return 0;
32
37
  }
33
38
  if (command === "stop") {
34
- await stop(await readStdin());
39
+ await stop(input);
35
40
  return 0;
36
41
  }
37
42
  if (command === "session-end") {
38
- await sessionEnd(await readStdin());
43
+ await sessionEnd(input);
39
44
  return 0;
40
45
  }
41
46
  if (command === "pre-tool-use") {
42
- await preToolUse(await readStdin());
47
+ await preToolUse(input);
43
48
  return 0;
44
49
  }
45
50
  if (command === "post-tool-use") {
46
- await postToolUse(await readStdin());
51
+ await postToolUse(input);
47
52
  return 0;
48
53
  }
49
54
  if (command === "worker") {
@@ -65,6 +70,13 @@ async function main() {
65
70
  });
66
71
  return 0;
67
72
  }
73
+ if (command === "uninstall") {
74
+ const configDir = option(args, "--config-dir");
75
+ if (!configDir || !isAbsolute(configDir))
76
+ throw new Error("--config-dir must be absolute");
77
+ await uninstallClaude({ configDir, writeOutput: line => process.stdout.write(`${line}\n`) });
78
+ return 0;
79
+ }
68
80
  if (command === "import") {
69
81
  const repo = option(args, "--repo");
70
82
  if (args.includes("--user") || !repo || !isAbsolute(repo))
package/dist/files.js CHANGED
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
3
  import { chmod, mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
4
4
  import { dirname } from "node:path";
5
- import locking from "fs-ext";
5
+ import { tryLock, unlock } from "fs-native-extensions";
6
6
  export async function writeJsonAtomic(path, value) {
7
7
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
8
8
  const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
@@ -10,6 +10,15 @@ export async function writeJsonAtomic(path, value) {
10
10
  await chmod(temporary, 0o600);
11
11
  await rename(temporary, path);
12
12
  }
13
+ function alive(pid) {
14
+ try {
15
+ process.kill(pid, 0);
16
+ return true;
17
+ }
18
+ catch (error) {
19
+ return error instanceof Error && "code" in error && error.code === "EPERM";
20
+ }
21
+ }
13
22
  export async function acquirePidFileLock(path, staleAfterMs) {
14
23
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
15
24
  const reclaimPath = `${path}.reclaim`;
@@ -18,7 +27,7 @@ export async function acquirePidFileLock(path, staleAfterMs) {
18
27
  const closeGuard = async () => {
19
28
  try {
20
29
  if (held)
21
- locking.flockSync(guard.fd, "un");
30
+ unlock(guard.fd);
22
31
  }
23
32
  finally {
24
33
  held = false;
@@ -28,16 +37,10 @@ export async function acquirePidFileLock(path, staleAfterMs) {
28
37
  try {
29
38
  if (!(await guard.stat()).isFile())
30
39
  throw new Error("Lock guard is not a regular file");
31
- try {
32
- locking.flockSync(guard.fd, "exnb");
33
- held = true;
34
- }
35
- catch (error) {
36
- if (error instanceof Error && "code" in error && (error.code === "EAGAIN" || error.code === "EWOULDBLOCK")) {
37
- await closeGuard();
38
- return undefined;
39
- }
40
- throw error;
40
+ held = tryLock(guard.fd);
41
+ if (!held) {
42
+ await closeGuard();
43
+ return undefined;
41
44
  }
42
45
  const [contents, lockStat] = await Promise.all([
43
46
  readFile(path, "utf8").catch(() => undefined),
@@ -47,6 +50,14 @@ export async function acquirePidFileLock(path, staleAfterMs) {
47
50
  await closeGuard();
48
51
  return undefined;
49
52
  }
53
+ // A holder from before an upgrade took its guard with flock, which this lock cannot see on Linux; its fresh pid
54
+ // file is the only sign it still runs.
55
+ const holder = Number(contents?.split(" ")[0]);
56
+ if (Number.isInteger(holder) && holder > 0 && holder !== process.pid && lockStat
57
+ && Date.now() - lockStat.mtimeMs <= staleAfterMs && alive(holder)) {
58
+ await closeGuard();
59
+ return undefined;
60
+ }
50
61
  await unlink(path).catch(error => {
51
62
  if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
52
63
  throw error;
package/dist/install.d.ts CHANGED
@@ -21,5 +21,9 @@ export interface InstallOptions {
21
21
  export declare function isOwnedHookCommand(command: string, subcommand: string): boolean;
22
22
  export declare function isOwnedPermissionRule(rule: string): boolean;
23
23
  export declare function readSettings(configDir: string): Promise<Settings | undefined>;
24
+ export declare function uninstallClaude(options: {
25
+ configDir: string;
26
+ writeOutput: (line: string) => void;
27
+ }): Promise<void>;
24
28
  export declare function installClaude(options: InstallOptions): Promise<void>;
25
29
  export {};
package/dist/install.js CHANGED
@@ -1,6 +1,6 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { basename, dirname, isAbsolute, join } from "node:path";
3
- import { CODING_SKIN_HOOKS, codingSkinHookCommand, isCodingSkinHookCommand, shellQuote, } from "@snoai/memory/coding-skin";
3
+ import { CODING_SKIN_HOOKS, codingSkinHookCommand, isCodingSkinHookCommand, snoProgramPath, shellQuote, } from "@snoai/memory/coding-skin";
4
4
  import { z } from "zod";
5
5
  import { APP_NAME } from "./constants.js";
6
6
  import { MESSAGES } from "./messages.js";
@@ -35,7 +35,7 @@ export function isOwnedPermissionRule(rule) {
35
35
  return false;
36
36
  const body = rule.slice(5, -3);
37
37
  if (body.endsWith(" memory"))
38
- return basename(body.slice(0, -" memory".length)) === "sno";
38
+ return basename(snoProgramPath(body.slice(0, -" memory".length))) === "sno";
39
39
  return isAbsolute(body) && isOwnedProgram(body);
40
40
  }
41
41
  export async function readSettings(configDir) {
@@ -95,9 +95,41 @@ function updateSettings(settings, programPath) {
95
95
  if (index < 0)
96
96
  allow.push(rule);
97
97
  else
98
- allow[index] = rule;
98
+ allow.splice(index, allow.length - index, rule, ...allow.slice(index + 1).filter(current => !isOwnedPermissionRule(current)));
99
99
  settings.permissions.allow = allow;
100
100
  }
101
+ // Removes the hooks, rule and skill installClaude wrote; every other setting stays as it was.
102
+ export async function uninstallClaude(options) {
103
+ const settings = await readSettings(options.configDir);
104
+ if (!settings)
105
+ throw new Error(MESSAGES.settingsUnparsable);
106
+ const before = JSON.stringify(settings);
107
+ for (const event of Object.keys(CODING_SKIN_HOOKS)) {
108
+ const groups = settings.hooks?.[event];
109
+ if (!settings.hooks || !groups)
110
+ continue;
111
+ const kept = groups.map(group => ({ ...group, hooks: group.hooks.filter(hook => !hook.command || !isOwnedHookCommand(hook.command, CODING_SKIN_HOOKS[event].subcommand)) }))
112
+ .filter(group => group.hooks.length > 0);
113
+ if (kept.length)
114
+ settings.hooks[event] = kept;
115
+ else
116
+ delete settings.hooks[event];
117
+ }
118
+ if (settings.hooks && Object.keys(settings.hooks).length === 0)
119
+ delete settings.hooks;
120
+ if (settings.permissions?.allow) {
121
+ settings.permissions.allow = settings.permissions.allow.filter(rule => !isOwnedPermissionRule(rule));
122
+ if (settings.permissions.allow.length === 0)
123
+ delete settings.permissions.allow;
124
+ if (Object.keys(settings.permissions).length === 0)
125
+ delete settings.permissions;
126
+ }
127
+ if (JSON.stringify(settings) !== before) {
128
+ await writeFile(join(options.configDir, "settings.json"), `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
129
+ }
130
+ await rm(join(options.configDir, "skills", APP_NAME), { recursive: true, force: true });
131
+ options.writeOutput(MESSAGES.uninstallComplete);
132
+ }
101
133
  export async function installClaude(options) {
102
134
  const settings = await readSettings(options.configDir);
103
135
  const writes = [{
@@ -105,7 +137,7 @@ export async function installClaude(options) {
105
137
  content: await readFile(new URL(`../skills/${APP_NAME}/SKILL.md`, import.meta.url), "utf8"),
106
138
  }];
107
139
  if (settings) {
108
- updateSettings(settings, options.programPath);
140
+ updateSettings(settings, snoProgramPath(options.programPath));
109
141
  writes.unshift({
110
142
  path: join(options.configDir, "settings.json"),
111
143
  content: `${JSON.stringify(settings, null, 2)}\n`,
@@ -2,6 +2,7 @@ export declare const MESSAGES: {
2
2
  readonly usage: "Run through sno: sno memory <recall|get|remember|correct|import|doctor> --harness claude, or sno memory hook <event> --harness claude";
3
3
  readonly dryRunPrefix: "would write";
4
4
  readonly installComplete: "Sno memory for Claude Code installed";
5
+ readonly uninstallComplete: "Sno memory for Claude Code removed";
5
6
  readonly settingsUnparsable: "settings.json parse error; settings preserved";
6
7
  readonly importCaptureDisabled: "Automatic capture is off in settings.json (capture.ambient); imported notes will be skipped.";
7
8
  readonly doctorUnavailable: "doctor: unavailable";
package/dist/messages.js CHANGED
@@ -2,6 +2,7 @@ export const MESSAGES = {
2
2
  usage: "Run through sno: sno memory <recall|get|remember|correct|import|doctor> --harness claude, or sno memory hook <event> --harness claude",
3
3
  dryRunPrefix: "would write",
4
4
  installComplete: "Sno memory for Claude Code installed",
5
+ uninstallComplete: "Sno memory for Claude Code removed",
5
6
  settingsUnparsable: "settings.json parse error; settings preserved",
6
7
  importCaptureDisabled: "Automatic capture is off in settings.json (capture.ambient); imported notes will be skipped.",
7
8
  doctorUnavailable: "doctor: unavailable",
package/dist/worker.js CHANGED
@@ -5,7 +5,7 @@ import { createServer } from "node:http";
5
5
  import { userInfo } from "node:os";
6
6
  import { readFile, readdir, unlink } 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, } 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_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";
@@ -133,6 +133,14 @@ async function callbackServer(credential, activeCwd, isImport, onCall, runChild)
133
133
  return;
134
134
  }
135
135
  onCall();
136
+ // Calls run one at a time and a child blocks the event loop, so a requester may have stopped waiting before
137
+ // this call's turn: skip it instead of spending a model run on an answer nobody reads.
138
+ const deadline = Number(request.headers[HOST_MODEL_DEADLINE_HEADER]);
139
+ if (Number.isFinite(deadline) && deadline <= Date.now()) {
140
+ console.log(JSON.stringify({ event: "model-callback-expired", model: requestBody.model }));
141
+ response.writeHead(504).end(JSON.stringify({ error: { kind: "cancelled", reason: "deadline" } }));
142
+ return;
143
+ }
136
144
  if (isImport() && lastImportChildAt > 0) {
137
145
  const remaining = CODING_SKIN_IMPORT_INTERVAL_MS - (Date.now() - lastImportChildAt);
138
146
  if (remaining > 0)
@@ -142,7 +150,7 @@ async function callbackServer(credential, activeCwd, isImport, onCall, runChild)
142
150
  lastImportChildAt = Date.now();
143
151
  console.log(JSON.stringify({ event: "import-child-start", at: lastImportChildAt }));
144
152
  }
145
- const result = await runChild(requestBody.prompt, cwd);
153
+ const result = await runChild(requestBody.prompt, cwd, Number.isFinite(deadline) ? deadline - Date.now() : undefined);
146
154
  const status = result.kind === "ok" ? 200 : result.kind === "cancelled" ? 504 : 503;
147
155
  console.log(JSON.stringify({ event: "model-callback", model: requestBody.model, status }));
148
156
  response.setHeader("content-type", "application/json");
@@ -208,7 +216,7 @@ export async function runWorker(dependencies = productionDependencies()) {
208
216
  let handoff = false;
209
217
  try {
210
218
  mkdirSync(childCwd, { recursive: true, mode: 0o700 });
211
- const callback = await callbackServer(credential, () => currentCwd ?? childCwd, () => currentImport, () => { lastCallbackAt = now(); }, (prompt, cwd) => dependencies.runChild(prompt, cwd, Math.min(CODING_SKIN_CHILD_DEADLINE_MS, CODING_SKIN_WORKER_LIFETIME_MS - (now() - workerStartedAt))));
219
+ 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)));
212
220
  server = callback.server;
213
221
  const client = await dependencies.connect();
214
222
  const principal = client.principal || userInfo().username;
@@ -263,9 +271,11 @@ export async function runWorker(dependencies = productionDependencies()) {
263
271
  await unlink(path);
264
272
  break;
265
273
  }
266
- catch {
274
+ catch (error) {
267
275
  await updateImportReceipt(record.importReceipt, "failures");
268
276
  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) }));
269
279
  const retryDelay = CODING_SKIN_RETRY_DELAYS_MS[attempts - 1];
270
280
  record = {
271
281
  ...record,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snoai/mem-claude",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
4
4
  "license": "Apache-2.0",
5
5
  "publishConfig": { "access": "public" },
6
6
  "repository": {
@@ -26,11 +26,10 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@snoai/memory": "^1.2.0",
29
- "fs-ext": "^2.1.1",
29
+ "fs-native-extensions": "^1.5.1",
30
30
  "zod": "^4.5.4"
31
31
  },
32
32
  "devDependencies": {
33
- "@types/fs-ext": "^2.0.3",
34
33
  "@types/node": "^22.19.17",
35
34
  "typescript": "^7.0.2"
36
35
  }