linksee-memory 0.17.0 → 0.17.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.
@@ -14,8 +14,14 @@
14
14
  // "hooks": [ { "type": "command", "command": "node",
15
15
  // "args": ["${CLAUDE_PROJECT_DIR}/dist/bin/guard-hook.js"], "timeout": 8 } ] } ] } }
16
16
  import { pathToFileURL } from 'node:url';
17
- import { openDb, runMigrations } from '../db/migrate.js';
18
- import { gateAction, buildBootDigest } from '../lib/guard.js';
17
+ import { NATIVE_HINT, isNativeLoadError, hookLog } from '../lib/native-hint.js';
18
+ /** Fail open (exit 0). Native-load failures say so once on stderr; anything else goes to hook.log only. */
19
+ function bail(where, e) {
20
+ if (isNativeLoadError(e))
21
+ process.stderr.write(`${NATIVE_HINT}\n`);
22
+ hookLog(`guard: ${where}: ${String(e?.message ?? e).split('\n')[0]}`);
23
+ process.exit(0);
24
+ }
19
25
  function readStdin() {
20
26
  return new Promise((resolve) => {
21
27
  const chunks = [];
@@ -37,14 +43,23 @@ async function main() {
37
43
  }
38
44
  if (!ev || typeof ev !== 'object')
39
45
  process.exit(0);
46
+ let openDb, runMigrations;
47
+ let gateAction, buildBootDigest;
48
+ try {
49
+ ({ openDb, runMigrations } = await import('../db/migrate.js'));
50
+ ({ gateAction, buildBootDigest } = await import('../lib/guard.js'));
51
+ }
52
+ catch (e) {
53
+ bail('import', e);
54
+ }
40
55
  let db;
41
56
  try {
42
57
  db = openDb();
43
58
  db.pragma('busy_timeout = 2000'); // MCP server may hold a write lock; wait briefly, else fail-open
44
59
  runMigrations(db); // ensures injection_log exists even when run standalone
45
60
  }
46
- catch {
47
- process.exit(0); // can't open DB → never block
61
+ catch (e) {
62
+ bail('open', e); // can't open DB → never block
48
63
  }
49
64
  try {
50
65
  if (ev.hook_event_name === 'PreToolUse') {
package/dist/bin/setup.js CHANGED
@@ -36,11 +36,14 @@
36
36
  // detect_terms / violation_signal, so cross-repo noise is bounded by what you declared.
37
37
  import { spawnSync } from 'node:child_process';
38
38
  import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'node:fs';
39
- import { join, dirname } from 'node:path';
39
+ import { join, dirname, sep } from 'node:path';
40
40
  import { homedir } from 'node:os';
41
41
  import { fileURLToPath } from 'node:url';
42
42
  import { createInterface } from 'node:readline';
43
- import { guardFullyWired, wireGuard, syncWiredFor } from '../lib/guard-wiring.js';
43
+ import Database from 'better-sqlite3';
44
+ import { guardFullyWired, wireGuard, syncWiredFor, rewireLegacyHooks, unmanagedLinkseeHooks } from '../lib/guard-wiring.js';
45
+ import { resolveHookRuntime, formatBytes } from '../lib/hook-runtime.js';
46
+ import { readSettingsFile, stripBom, writeSettingsFile } from '../lib/settings-file.js';
44
47
  const args = process.argv.slice(2);
45
48
  const dryRun = args.includes('--dry-run');
46
49
  const autoYes = args.includes('--yes') || args.includes('-y');
@@ -49,20 +52,25 @@ const noGuard = args.includes('--no-guard');
49
52
  const projectGuard = args.includes('--project-guard');
50
53
  const showHelp = args.includes('--help') || args.includes('-h');
51
54
  if (showHelp) {
52
- console.log(`linksee-memory-setup — One-command setup for Linksee Memory
53
-
54
- Usage:
55
- npx linksee-memory setup Interactive setup
56
- npx linksee-memory setup --yes Accept all defaults, no prompts
57
- npx linksee-memory setup --dry-run Show what would happen
58
-
59
- What it does:
60
- 1. Registers linksee-memory MCP server with Claude Code
61
- 2. Installs SKILL.md (teaches the agent when to recall/remember)
62
- 3. Configures Stop hook (auto-captures every session)
63
- 4. Offers to wire the re-injection guard into THIS project's .claude/settings.json
64
-
65
- After setup, just chat with Claude Code normally.
55
+ console.log(`linksee-memory-setup — One-command setup for Linksee Memory
56
+
57
+ Usage:
58
+ npx linksee-memory setup Interactive setup
59
+ npx linksee-memory setup --yes Accept all defaults, no prompts
60
+ npx linksee-memory setup --dry-run Show what would happen
61
+
62
+ What it does:
63
+ 1. Registers linksee-memory MCP server with Claude Code
64
+ 2. Installs SKILL.md (teaches the agent when to recall/remember)
65
+ 3. Configures Stop hook (auto-captures every session)
66
+ 4. Wires the re-injection guard into ~/.claude/settings.json (every repo)
67
+ --project-guard: this repo only · --no-guard: skip
68
+
69
+ Writes ~/.claude/settings.json (one settings.json.bak-<timestamp> kept before changing it),
70
+ ~/.claude/skills/linksee-memory/SKILL.md, and — when run through npx — a pinned copy under
71
+ ~/.linksee-memory/runtime/<version>/ that the hooks call with node directly.
72
+
73
+ After setup, just chat with Claude Code normally.
66
74
  Add "Use Linksee" to any prompt to trigger memory recall.`);
67
75
  process.exit(0);
68
76
  }
@@ -76,12 +84,21 @@ const __filename = fileURLToPath(import.meta.url);
76
84
  const SKILL_SRC = join(dirname(__filename), '..', 'skill', 'SKILL.md');
77
85
  const SERVER_NAME = 'linksee';
78
86
  const MCP_COMMAND = `claude mcp add -s user ${SERVER_NAME} -- npx -y linksee-memory`;
79
- // Subcommand form (npx -y linksee-memory <sub>) so the hooks resolve for a cold user —
80
- // npx can resolve the package name, but not sibling bin names like linksee-memory-sync.
81
- const HOOK_COMMAND = 'npx -y linksee-memory sync';
82
- // Re-injection guard: command + merge rules live in lib/guard-wiring.ts (shared with the tests).
83
- // It points at the globally-installed bin rather than a dist path, so it ships without a build
84
- // step, and the shell form is resolved at run time so it survives npx-cache eviction.
87
+ // Hook commands (0.17.1, L-1-106): `"<node>" "<package>/dist/bin/<bin>.js"` — node called directly,
88
+ // not `npx -y linksee-memory <sub>` (6–9 s per call vs the 8 s PreToolUse timeout). When setup runs
89
+ // from npx's temporary cache, lib/hook-runtime.ts installs this version under
90
+ // <memory dir>/runtime/<version> (npm install, once) and the hooks point there; if that fails it
91
+ // keeps the npx form and says so.
92
+ // Merge / replace rules live in lib/guard-wiring.ts (shared with the tests).
93
+ const PACKAGE_ROOT = join(dirname(__filename), '..', '..');
94
+ const PACKAGE_VERSION = (() => {
95
+ try {
96
+ return String(JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')).version);
97
+ }
98
+ catch {
99
+ return 'unknown';
100
+ }
101
+ })();
85
102
  const PROJECT_DIR = process.cwd();
86
103
  const PROJECT_CLAUDE_DIR = join(PROJECT_DIR, '.claude');
87
104
  const PROJECT_SETTINGS_PATH = join(PROJECT_CLAUDE_DIR, 'settings.json');
@@ -107,7 +124,7 @@ try {
107
124
  if (!existsSync(confFile))
108
125
  continue;
109
126
  try {
110
- const conf = JSON.parse(readFileSync(confFile, 'utf8'));
127
+ const conf = JSON.parse(stripBom(readFileSync(confFile, 'utf8')));
111
128
  if (conf?.mcpServers?.[SERVER_NAME]) {
112
129
  mcpAlreadyRegistered = true;
113
130
  break;
@@ -198,40 +215,85 @@ else {
198
215
  console.log('');
199
216
  // ── Step 3: Configure Stop hook ──────────────────────────
200
217
  console.log(`${BOLD}[3/4]${RESET} Configuring auto-capture hook...`);
201
- let settings = {};
202
- if (existsSync(SETTINGS_PATH)) {
218
+ const runtime = resolveHookRuntime({
219
+ packageRoot: PACKAGE_ROOT,
220
+ nodePath: process.execPath,
221
+ memoryDir: TELEMETRY_DIR,
222
+ version: PACKAGE_VERSION,
223
+ dryRun,
224
+ // For testing an unpublished build: what to npm-install instead of linksee-memory@<version>.
225
+ spec: process.env.LINKSEE_RUNTIME_SPEC || undefined,
226
+ });
227
+ const HOOK_COMMANDS = runtime.commands;
228
+ const HOOK_COMMAND = HOOK_COMMANDS.sync;
229
+ if (runtime.mode === 'npx') {
230
+ console.log(` ${SKIP} Hooks use npx (slower, ~6–9 s per call): ${runtime.note ?? ''}`);
231
+ }
232
+ else {
233
+ console.log(` ${DIM}Hooks call node directly: ${runtime.root}${runtime.note ? ` (${runtime.note})` : ''}${RESET}`);
234
+ }
235
+ // Hooks that run our scripts but were wired by hand (wrappers, unquoted paths, exec form) are
236
+ // never rewritten; list them at the end so the user can update them if they want to.
237
+ function handWiredNotice(path) {
238
+ const r = readSettingsFile(path);
239
+ if (!r.ok)
240
+ return;
241
+ for (const c of unmanagedLinkseeHooks(r.settings))
242
+ handWired.add(`${c} (${path})`);
243
+ }
244
+ const handWired = new Set();
245
+ /** writeSettingsFile that reports instead of throwing (a failed write leaves the file as it was). */
246
+ function safeWrite(path, settings) {
203
247
  try {
204
- settings = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'));
248
+ return writeSettingsFile(path, settings);
205
249
  }
206
- catch {
207
- console.log(` ${FAIL} Could not parse ${SETTINGS_PATH}`);
208
- settings = {};
250
+ catch (e) {
251
+ console.log(` ${FAIL} Could not write ${path} (${e?.message ?? e}) — left as it was`);
252
+ return null;
209
253
  }
210
254
  }
211
- // Check if hook already exists
212
- const stopHooks = settings?.hooks?.Stop ?? [];
213
- // Recognise every shape the sync hook has been wired in (npx subcommand, global bin, dist
214
- // path, exec form) — a narrower probe appended a second copy, see lib/guard-wiring.ts.
215
- const alreadyHooked = syncWiredFor(settings, 'Stop');
216
- if (alreadyHooked) {
217
- console.log(` ${SKIP} Stop hook already configured`);
218
- }
219
- else if (dryRun) {
220
- console.log(` ${DIM}[dry-run] Would add Stop hook to ${SETTINGS_PATH}${RESET}`);
255
+ // Same protection as the guard step below (lib/settings-file.ts): BOM stripped, an unparseable
256
+ // file is left untouched, one .bak before the first write, temp file + rename.
257
+ const stopRead = readSettingsFile(SETTINGS_PATH);
258
+ if (!stopRead.ok) {
259
+ console.log(` ${FAIL} Could not parse ${SETTINGS_PATH} (${stopRead.reason}) — left untouched`);
260
+ console.log(` ${DIM}Fix the file, or add the Stop hook by hand: ${HOOK_COMMAND}${RESET}`);
221
261
  }
222
262
  else {
223
- // Add hook
224
- if (!settings.hooks)
225
- settings.hooks = {};
226
- if (!settings.hooks.Stop)
227
- settings.hooks.Stop = [];
228
- settings.hooks.Stop.push({
229
- matcher: '',
230
- hooks: [{ type: 'command', command: HOOK_COMMAND }],
231
- });
232
- mkdirSync(CLAUDE_DIR, { recursive: true });
233
- writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
234
- console.log(` ${CHECK} Stop hook added → ${SETTINGS_PATH}`);
263
+ const settings = stopRead.settings;
264
+ // Swap the Stop hook forms setup wrote (the exact 0.17.0 npx string, or our marked direct form).
265
+ // Only the sync kind here: the guard hooks are step 4's, after its --no-guard check.
266
+ const replaced = rewireLegacyHooks(settings, HOOK_COMMANDS, ['sync']).replaced;
267
+ // Recognise every shape the sync hook has been wired in (npx subcommand, global bin, dist
268
+ // path, exec form) — a narrower probe appended a second copy, see lib/guard-wiring.ts.
269
+ const alreadyHooked = syncWiredFor(settings, 'Stop');
270
+ if (alreadyHooked && replaced.length === 0) {
271
+ console.log(` ${SKIP} Stop hook already configured`);
272
+ }
273
+ else if (dryRun) {
274
+ console.log(` ${DIM}[dry-run] Would ${alreadyHooked ? 'switch the npx hooks to direct node calls in' : 'add Stop hook to'} ${SETTINGS_PATH}${RESET}`);
275
+ }
276
+ else {
277
+ if (!alreadyHooked) {
278
+ if (!settings.hooks)
279
+ settings.hooks = {};
280
+ if (!settings.hooks.Stop)
281
+ settings.hooks.Stop = [];
282
+ settings.hooks.Stop.push({
283
+ matcher: '',
284
+ hooks: [{ type: 'command', command: HOOK_COMMAND }],
285
+ });
286
+ }
287
+ const w = safeWrite(SETTINGS_PATH, settings);
288
+ if (w) {
289
+ if (replaced.length)
290
+ console.log(` ${CHECK} Repointed ${replaced.length} hook(s) to ${HOOK_COMMANDS.sync.startsWith('npx') ? 'npx' : 'a direct node call'} → ${SETTINGS_PATH}`);
291
+ if (!alreadyHooked)
292
+ console.log(` ${CHECK} Stop hook added → ${SETTINGS_PATH}`);
293
+ if (w.backup)
294
+ console.log(` ${DIM}Previous file kept at ${w.backup}${RESET}`);
295
+ }
296
+ }
235
297
  }
236
298
  console.log('');
237
299
  // ── Step 4: Wire the re-injection guard (user-global by default) ─────────
@@ -259,24 +321,29 @@ async function configureGuard() {
259
321
  console.log(` ${SKIP} Skipped (--no-guard). Enable later: npx -y linksee-memory setup`);
260
322
  return false;
261
323
  }
262
- let settings = {};
263
- if (existsSync(targetPath)) {
264
- try {
265
- // Strip a leading BOM (U+FEFF) — Windows editors (Notepad) emit UTF-8+BOM, which JSON.parse rejects.
266
- const raw = readFileSync(targetPath, 'utf8');
267
- settings = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
268
- }
269
- catch {
270
- // Never clobber a file we can't parse — the user may have hand-authored it.
271
- console.log(` ${FAIL} Could not parse ${targetPath} — left untouched`);
272
- console.log(` ${DIM}Add the guard block by hand (see README → Re-injection Guard).${RESET}`);
273
- return false;
274
- }
324
+ // BOM stripped; never clobber a file we can't parse — the user may have hand-authored it.
325
+ const read = readSettingsFile(targetPath);
326
+ if (!read.ok) {
327
+ console.log(` ${FAIL} Could not parse ${targetPath} (${read.reason}) — left untouched`);
328
+ console.log(` ${DIM}Add the guard block by hand (see README → Re-injection Guard).${RESET}`);
329
+ return false;
275
330
  }
276
- if (guardFullyWired(settings)) {
331
+ const settings = read.settings;
332
+ // Known npx forms → direct node call (exact matches only; see lib/guard-wiring.ts).
333
+ const { replaced } = rewireLegacyHooks(settings, HOOK_COMMANDS, ['guard']);
334
+ if (guardFullyWired(settings) && replaced.length === 0) {
277
335
  console.log(` ${SKIP} Guard already wired in ${targetPath}`);
278
336
  return true;
279
337
  }
338
+ if (guardFullyWired(settings) && !dryRun) {
339
+ const w = safeWrite(targetPath, settings);
340
+ if (!w)
341
+ return false;
342
+ console.log(` ${CHECK} Repointed ${replaced.length} guard hook(s) → ${targetPath}`);
343
+ if (w.backup)
344
+ console.log(` ${DIM}Previous file kept at ${w.backup}${RESET}`);
345
+ return true;
346
+ }
280
347
  if (dryRun) {
281
348
  console.log(` ${DIM}[dry-run] Would merge SessionStart + PreToolUse guard hooks into ${targetPath}${RESET}`);
282
349
  return false;
@@ -294,10 +361,13 @@ async function configureGuard() {
294
361
  }
295
362
  }
296
363
  // Merge, don't replace (see lib/guard-wiring.ts for the rules it holds to).
297
- wireGuard(settings);
298
- mkdirSync(targetDir, { recursive: true });
299
- writeFileSync(targetPath, JSON.stringify(settings, null, 2), 'utf8');
364
+ wireGuard(settings, HOOK_COMMANDS.guard);
365
+ const w = safeWrite(targetPath, settings);
366
+ if (!w)
367
+ return false;
300
368
  console.log(` ${CHECK} Guard wired → ${targetPath} ${DIM}(${scopeLabel})${RESET}`);
369
+ if (w.backup)
370
+ console.log(` ${DIM}Previous file kept at ${w.backup}${RESET}`);
301
371
  if (!projectGuard) {
302
372
  console.log(` ${DIM}Active in every repo. Scope an anchor with \`affects\` globs to limit where it fires;`);
303
373
  console.log(` --no-guard to skip, --project-guard for this repo only.${RESET}`);
@@ -307,6 +377,25 @@ async function configureGuard() {
307
377
  const guardConfigured = await configureGuard();
308
378
  console.log('');
309
379
  // ── Telemetry consent (opt-in, anonymous, off unless you agree) ──────────
380
+ /** True when there is no memory yet (or it cannot be read) — used only to pick the first-try text. */
381
+ function memoryIsEmpty() {
382
+ const dbPath = join(TELEMETRY_DIR, 'memory.db');
383
+ if (!existsSync(dbPath))
384
+ return true;
385
+ try {
386
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true });
387
+ try {
388
+ const row = db.prepare('SELECT COUNT(*) AS c FROM memories').get();
389
+ return !row || row.c === 0;
390
+ }
391
+ finally {
392
+ db.close();
393
+ }
394
+ }
395
+ catch {
396
+ return true;
397
+ }
398
+ }
310
399
  async function configureTelemetryConsent() {
311
400
  console.log(`${BOLD}Anonymous usage stats${RESET} ${DIM}(optional)${RESET}`);
312
401
  const env = (process.env.LINKSEE_TELEMETRY || '').toLowerCase().trim();
@@ -355,19 +444,45 @@ console.log('');
355
444
  console.log(`${BOLD}Setup complete!${RESET}`);
356
445
  console.log('');
357
446
  console.log('How it works:');
358
- console.log(` ${DIM}• Every session is auto-captured (decisions, caveats, learnings)${RESET}`);
447
+ console.log(` ${DIM}• Every Claude Code session is auto-captured (decisions, caveats, learnings)${RESET}`);
359
448
  console.log(` ${DIM}• Agent auto-recalls past context when starting a task${RESET}`);
360
449
  console.log(` ${DIM}• Memory is local-first (your memory never leaves your machine)${RESET}`);
361
- console.log(` ${DIM}• Works across Claude Code, Cursor, Windsurf, Codex, Gemini (cross-agent)${RESET}`);
450
+ console.log(` ${DIM}• The memory is shared over MCP by 5 clients (Claude Code, Cursor, Windsurf, Codex, Gemini CLI);${RESET}`);
451
+ console.log(` ${DIM} auto-capture and the guard run in Claude Code only. Codex: codex mcp add linksee -- npx -y linksee-memory${RESET}`);
362
452
  if (guardConfigured) {
363
- console.log(` ${DIM}• Re-injection guard re-surfaces this project's accepted decisions before edits${RESET}`);
453
+ console.log(` ${DIM}• Re-injection guard re-surfaces your accepted decisions before edits (${projectGuard ? 'this project' : 'every repo'})${RESET}`);
364
454
  }
365
455
  console.log('');
366
- console.log('Test by asking:');
367
- console.log(` ${BOLD}"How did we solve this before?"${RESET}`);
368
- console.log(` ${BOLD}"Same error again"${RESET}`);
369
- console.log(` ${BOLD}"Remember: I prefer TypeScript over JavaScript"${RESET}`);
370
- console.log(` ${BOLD}「前にこの問題どう解決したっけ」${RESET}`);
456
+ // 0.17.1 never deletes pinned runtimes (Codex fourth review of #15: any automatic delete risked
457
+ // removing one a hook still uses). Say what they cost and how to remove old ones by hand.
458
+ if (runtime.mode === 'pinned' && runtime.runtimeBase && !dryRun) {
459
+ console.log(` ${DIM}• Hooks run from ${runtime.runtimeBase}${sep}<version> (${runtime.bytes !== undefined ? formatBytes(runtime.bytes) : 'about 35 MB'} each). Older versions are not removed automatically;${RESET}`);
460
+ console.log(` ${DIM} 0.17.2 adds \`linksee-memory runtime prune\`. Until then you can delete old ones by hand: ${runtime.runtimeBase}${RESET}`);
461
+ console.log('');
462
+ }
463
+ handWiredNotice(SETTINGS_PATH);
464
+ if (projectGuard)
465
+ handWiredNotice(PROJECT_SETTINGS_PATH);
466
+ if (handWired.size) {
467
+ console.log('Left as you wired them (not written by setup, so not changed):');
468
+ for (const c of handWired)
469
+ console.log(` ${DIM}• ${c}${RESET}`);
470
+ console.log(` ${DIM}setup writes "<node>" "<package>/dist/bin/<script>.js" --linksee-managed; switch to that form if you want setup to manage them.${RESET}`);
471
+ console.log('');
472
+ }
473
+ if (memoryIsEmpty()) {
474
+ // A fresh install has nothing to recall, so "how did we solve this before?" always comes back
475
+ // empty — the first thing a new user tries would fail. Offer a round trip that cannot.
476
+ console.log('Try it (your memory is empty, so start by giving it something):');
477
+ console.log(` 1. Say: ${BOLD}"Remember: in this repo we decided to use X, because Y."${RESET}`);
478
+ console.log(` 2. Open a ${BOLD}new${RESET} session and ask: ${BOLD}"What did we decide about X?"${RESET}`);
479
+ }
480
+ else {
481
+ console.log('Test by asking:');
482
+ console.log(` ${BOLD}"How did we solve this before?"${RESET}`);
483
+ console.log(` ${BOLD}"Same error again"${RESET}`);
484
+ console.log(` ${BOLD}「前にこの問題どう解決したっけ」${RESET}`);
485
+ }
371
486
  console.log('');
372
487
  console.log(`Or add ${BOLD}"Use Linksee"${RESET} to any prompt to trigger memory recall.`);
373
488
  console.log('');
@@ -7,34 +7,34 @@
7
7
  // npx -y linksee-memory share-draft --service freee --task "..." --symptom "..." \
8
8
  // --tried "..." --result "..." --kind execution [--conditions "..."] [--runtime claude] [--json]
9
9
  import { buildShareDraft, ALLOWED_KEYS, KINDS, RUNTIMES, NOTICE, NOTICE_EN, DRAFT_PAGE, URL_MAX } from '../lib/share-draft.js';
10
- const HELP = `linksee-memory share-draft — make a link to a reviewed draft of a fix, for other agents
11
-
12
- Linksee sends nothing. This prints a URL to ${DRAFT_PAGE}
13
- The page opens with the fields filled; you read them there, and only your click sends.
14
- If you never run this (or the MCP tool share_draft), nothing happens.
15
-
16
- Usage:
17
- npx -y linksee-memory share-draft --service <id> --task <text> --symptom <text> \\
18
- --tried <text> --result <text> --kind <${KINDS.join('|')}> \\
19
- [--conditions <text>] [--runtime <${RUNTIMES.join('|')}>] [--json]
20
-
21
- Fields (nothing else is accepted):
22
- --service KanseiLINK service_id, lowercase: ^[a-z0-9_.-]{1,160}$ (required)
23
- --task what you were trying to do, ≤300 chars (required)
24
- --symptom the KIND of error, no values, ≤600 chars (required)
25
- --tried steps you tried, ≤2,000 chars (required)
26
- --result how it was solved / that it was not, ≤1,000 chars (required)
27
- --conditions tool + version, kind of environment, ≤600 chars
28
- --kind execution | documentation | hypothesis (required)
29
- --runtime claude | codex | gpt | gemini | other
30
-
31
- Stops (no link) when a field looks like a token / API key / JWT / Bearer value, an e-mail,
32
- a tenant URL (acme.slack.com …), an IP address, a local path (C:\\, /Users/, /home/), or a
33
- long hex / base64 run (32+), and says which field. The whole URL must be ≤${URL_MAX} chars
34
- (one Japanese character takes 9) — it asks you to shorten; it never cuts.
35
-
36
- ${NOTICE[0]}
37
- ${NOTICE[1]}
10
+ const HELP = `linksee-memory share-draft — make a link to a reviewed draft of a fix, for other agents
11
+
12
+ Linksee sends nothing. This prints a URL to ${DRAFT_PAGE}
13
+ The page opens with the fields filled; you read them there, and only your click sends.
14
+ If you never run this (or the MCP tool share_draft), nothing happens.
15
+
16
+ Usage:
17
+ npx -y linksee-memory share-draft --service <id> --task <text> --symptom <text> \\
18
+ --tried <text> --result <text> --kind <${KINDS.join('|')}> \\
19
+ [--conditions <text>] [--runtime <${RUNTIMES.join('|')}>] [--json]
20
+
21
+ Fields (nothing else is accepted):
22
+ --service KanseiLINK service_id, lowercase: ^[a-z0-9_.-]{1,160}$ (required)
23
+ --task what you were trying to do, ≤300 chars (required)
24
+ --symptom the KIND of error, no values, ≤600 chars (required)
25
+ --tried steps you tried, ≤2,000 chars (required)
26
+ --result how it was solved / that it was not, ≤1,000 chars (required)
27
+ --conditions tool + version, kind of environment, ≤600 chars
28
+ --kind execution | documentation | hypothesis (required)
29
+ --runtime claude | codex | gpt | gemini | other
30
+
31
+ Stops (no link) when a field looks like a token / API key / JWT / Bearer value, an e-mail,
32
+ a tenant URL (acme.slack.com …), an IP address, a local path (C:\\, /Users/, /home/), or a
33
+ long hex / base64 run (32+), and says which field. The whole URL must be ≤${URL_MAX} chars
34
+ (one Japanese character takes 9) — it asks you to shorten; it never cuts.
35
+
36
+ ${NOTICE[0]}
37
+ ${NOTICE[1]}
38
38
  `;
39
39
  function parse(argv) {
40
40
  const fields = {};
@@ -15,7 +15,9 @@ import { join, dirname } from 'node:path';
15
15
  import { homedir } from 'node:os';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { isTelemetryEnabled, buildPayload, sendTelemetry } from '../lib/telemetry.js';
18
- import Database from 'better-sqlite3';
18
+ import { NATIVE_HINT, isNativeLoadError } from '../lib/native-hint.js';
19
+ // better-sqlite3 is imported dynamically below, so a broken native addon goes through the same
20
+ // one-line hint as everything else instead of failing at module load (Codex third review of #15).
19
21
  const LOG_DIR = process.env.LINKSEE_MEMORY_DIR ?? join(homedir(), '.linksee-memory');
20
22
  const LOG_FILE = join(LOG_DIR, 'hook.log');
21
23
  const LOG_MAX_BYTES = 1024 * 1024; // 1 MB → rotate
@@ -86,6 +88,10 @@ async function main() {
86
88
  }
87
89
  else if (r.status !== 0) {
88
90
  log(`importer exited ${r.status} (session=${sessionId}, ${elapsed}ms): ${(r.stderr || '').slice(0, 500)}`);
91
+ // The native addon loads at `new Database()` inside the importer; after a Node major upgrade
92
+ // that is where it fails. Say so once — still exit 0, the Stop hook never blocks.
93
+ if (isNativeLoadError(r.stderr || ''))
94
+ process.stderr.write(`${NATIVE_HINT}\n`);
89
95
  process.exit(0);
90
96
  }
91
97
  const out = (r.stdout || '').trim().split('\n').slice(-1)[0] || '';
@@ -114,6 +120,7 @@ async function main() {
114
120
  const dbDir = process.env.LINKSEE_MEMORY_DIR ?? join(homedir(), '.linksee-memory');
115
121
  const dbPath = join(dbDir, 'memory.db');
116
122
  if (existsSync(dbPath)) {
123
+ const { default: Database } = await import('better-sqlite3');
117
124
  const db = new Database(dbPath, { readonly: true });
118
125
  try {
119
126
  const payload = buildPayload(db, sessionId, { mcpServersInUse: mcpServers });
@@ -132,6 +139,8 @@ async function main() {
132
139
  }
133
140
  catch (e) {
134
141
  log(`telemetry: error (non-fatal): ${e?.message ?? e}`);
142
+ if (isNativeLoadError(e))
143
+ process.stderr.write(`${NATIVE_HINT}\n`);
135
144
  }
136
145
  }
137
146
  process.exit(0);
@@ -1,4 +1,34 @@
1
1
  import Database from 'better-sqlite3';
2
2
  export declare function getDbPath(): string;
3
3
  export declare function openDb(): Database.Database;
4
+ /** Oldest schema this version migrates in place. Older files are refused before any write. */
5
+ export declare const MIN_SUPPORTED_SCHEMA = 6;
6
+ /** Base class: openDb() refused this file without writing to it. The message says what to do. */
7
+ export declare class DbRefusedError extends Error {
8
+ }
9
+ /**
10
+ * Thrown for a schema older than MIN_SUPPORTED_SCHEMA (a DB last opened by a 2026-05-or-earlier
11
+ * version). Raised by openDb() from a read-only connection, before any writable connection exists,
12
+ * and again by runMigrations() as a second line.
13
+ *
14
+ * No old version is recommended as a stepping stone: 0.8.0–0.17.0 stop on `no such table:
15
+ * drift_anchors` after partly altering the file, and 0.7.2 has no subcommands — `npx
16
+ * linksee-memory@0.7.2 <anything>` starts its MCP server, whose start-up consolidate can delete
17
+ * original rows (Codex review of PR #15). A migration command for these files is 0.17.2 work.
18
+ */
19
+ export declare class LegacySchemaError extends DbRefusedError {
20
+ readonly schemaVersion: number;
21
+ constructor(schemaVersion: number, dbPath: string);
22
+ }
23
+ /** The read-only check could not run (busy, locked, unreadable): refused rather than guessed. */
24
+ export declare class PrecheckError extends DbRefusedError {
25
+ readonly reason: string;
26
+ constructor(dbPath: string, reason: string);
27
+ }
28
+ /** A rollback journal left by an interrupted write: it cannot be read without being rolled back. */
29
+ export declare class HotJournalError extends DbRefusedError {
30
+ constructor(dbPath: string);
31
+ }
32
+ /** Read meta.schema_version without writing (0 when the table or row is absent). */
33
+ export declare function readSchemaVersion(db: Database.Database): number;
4
34
  export declare function runMigrations(db: Database.Database): void;