@pcircle/memesh 4.2.8 → 4.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.
Files changed (49) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +15 -15
  4. package/dashboard/dist/index.html +7 -7
  5. package/dist/core/auto-tagger.d.ts.map +1 -1
  6. package/dist/core/auto-tagger.js +4 -3
  7. package/dist/core/auto-tagger.js.map +1 -1
  8. package/dist/core/consolidator.d.ts.map +1 -1
  9. package/dist/core/consolidator.js +4 -3
  10. package/dist/core/consolidator.js.map +1 -1
  11. package/dist/core/digest-validator.d.ts.map +1 -1
  12. package/dist/core/digest-validator.js +4 -3
  13. package/dist/core/digest-validator.js.map +1 -1
  14. package/dist/core/dreamer.d.ts.map +1 -1
  15. package/dist/core/dreamer.js +7 -6
  16. package/dist/core/dreamer.js.map +1 -1
  17. package/dist/core/graph.d.ts.map +1 -1
  18. package/dist/core/graph.js +4 -4
  19. package/dist/core/graph.js.map +1 -1
  20. package/dist/core/json-utils.d.ts +2 -0
  21. package/dist/core/json-utils.d.ts.map +1 -0
  22. package/dist/core/json-utils.js +37 -0
  23. package/dist/core/json-utils.js.map +1 -0
  24. package/dist/core/operations.d.ts +4 -0
  25. package/dist/core/operations.d.ts.map +1 -1
  26. package/dist/core/operations.js +6 -0
  27. package/dist/core/operations.js.map +1 -1
  28. package/dist/knowledge-graph.d.ts +1 -0
  29. package/dist/knowledge-graph.d.ts.map +1 -1
  30. package/dist/knowledge-graph.js +18 -12
  31. package/dist/knowledge-graph.js.map +1 -1
  32. package/dist/skills-manifest.json +21 -11
  33. package/dist/transports/cli/cli.d.ts +4 -1
  34. package/dist/transports/cli/cli.d.ts.map +1 -1
  35. package/dist/transports/cli/cli.js +38 -13
  36. package/dist/transports/cli/cli.js.map +1 -1
  37. package/dist/transports/http/server.d.ts.map +1 -1
  38. package/dist/transports/http/server.js +30 -86
  39. package/dist/transports/http/server.js.map +1 -1
  40. package/dist/transports/mcp/handlers.d.ts.map +1 -1
  41. package/dist/transports/mcp/handlers.js +3 -9
  42. package/dist/transports/mcp/handlers.js.map +1 -1
  43. package/package.json +2 -2
  44. package/scripts/hooks/_generated/core-paths.js +77 -0
  45. package/scripts/hooks/_generated/fts-index.js +25 -0
  46. package/scripts/hooks/_shared.js +90 -148
  47. package/scripts/hooks/post-commit.js +25 -46
  48. package/scripts/hooks/pre-compact.js +9 -32
  49. package/scripts/hooks/session-summary.js +36 -13
@@ -1,148 +1,42 @@
1
1
  import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'fs';
2
- import { spawn, execFileSync } from 'child_process';
2
+ import { spawn } from 'child_process';
3
3
  import { createRequire } from 'module';
4
4
  import { homedir } from 'os';
5
- import { basename, dirname, join } from 'path';
5
+ import { dirname, join } from 'path';
6
6
  import { fileURLToPath, pathToFileURL } from 'url';
7
7
 
8
- const require = createRequire(import.meta.url);
9
-
10
8
  // =============================================================================
11
- // Path helpers — MIRROR of src/core/paths.ts
9
+ // Path helpers + FTS primitives GENERATED from src/core (do not hand-mirror)
12
10
  // =============================================================================
13
11
  //
14
- // Hooks cannot import from `dist/` (the F5 security boundary `dist/` may
15
- // be stale or absent at hook execution time), so the path-resolution logic
16
- // is duplicated here. The contract MUST stay in lockstep with
17
- // `src/core/paths.ts`. Any change to the function shapes / precedence
18
- // rules in that file MUST be reflected here too.
12
+ // These were once a 965-line hand-mirror of `src/core`, kept in lockstep by
13
+ // human review until the copies drifted and shipped the P0 FTS bug (a hook
14
+ // wrote an entity+observations but the mirror's reindex step diverged, leaving
15
+ // the memory unrecallable).
19
16
  //
20
- // Unlike the schema duplication (which has a build-time diff guard via
21
- // `scripts/check-schema-drift.mjs`), these helpers are short enough that
22
- // human review at code-review time is sufficient. If they grow, add a
23
- // programmatic guard.
24
-
25
- /**
26
- * Return the user's home directory, honoring HOME env var first.
27
- *
28
- * On POSIX, os.homedir() already consults HOME. On Windows, it ignores
29
- * env vars and reads GetUserProfileDirectoryW directly which makes
30
- * tests unable to redirect home-dir lookups to a tmp dir. Honoring HOME
31
- * first lets tests set HOME=<tmpdir> and have it actually take effect
32
- * across platforms. Production users on Windows almost never set HOME,
33
- * so this falls through to os.homedir() as before.
34
- *
35
- * Mirror of: src/core/paths.ts → homeDir()
36
- *
37
- * @returns {string}
38
- */
39
- function homeDir() {
40
- // Mirror src/core/paths.ts homeDir() same three-step fallback to
41
- // handle HOME="" environments. `os.homedir()` itself reads HOME on
42
- // POSIX, so HOME="" makes it return "". `os.userInfo().homedir`
43
- // reads pw_dir via getpwuid syscall, bypassing env vars entirely.
44
- const home = process.env.HOME;
45
- if (home && home.length > 0) return home;
46
- const fromOs = homedir();
47
- if (fromOs && fromOs.length > 0) return fromOs;
48
- // userInfo is the final defence — re-import here to keep the
49
- // top-of-file `import { homedir } from 'os'` line stable.
50
- return require('os').userInfo().homedir;
51
- }
52
-
53
- /**
54
- * Resolve the memesh data directory.
55
- *
56
- * Precedence: MEMESH_DIR env var > <home>/.memesh.
57
- * Mirror of: src/core/paths.ts → memeshDir()
58
- *
59
- * No-arg to mirror the core helper exactly. Earlier drafts accepted a
60
- * custom `env` parameter for symmetry with `getMemeshDirFromDbPath(env)`,
61
- * but the inner `homeDir()` only ever read `process.env.HOME`, so a
62
- * caller that passed `{HOME: '/tmp/x'}` would be silently ignored — a
63
- * footgun. Tests redirect via `process.env.HOME`; that's the supported
64
- * extension point.
65
- *
66
- * @returns {string}
67
- */
68
- export function memeshDir() {
69
- return process.env.MEMESH_DIR ?? join(homeDir(), '.memesh');
70
- }
71
-
72
- /**
73
- * Resolve the active memesh DB path.
74
- *
75
- * Precedence: MEMESH_DB_PATH env var > <memeshDir>/knowledge-graph.db.
76
- * Mirror of: src/core/paths.ts → getDbPath() — no-arg, see memeshDir().
77
- *
78
- * @returns {string}
79
- */
80
- export function getDbPath() {
81
- return process.env.MEMESH_DB_PATH ?? join(memeshDir(), 'knowledge-graph.db');
82
- }
83
-
84
- /**
85
- * Derive the project name from a working directory.
86
- *
87
- * Hooks historically used `basename(data.cwd || process.cwd())`. Core
88
- * had two variants (`basename(context.cwd)` and `basename(process.cwd())`).
89
- * This helper unifies the contract — explicit cwd wins, falls through to
90
- * process.cwd() — matching the most permissive caller's behaviour.
91
- *
92
- * Mirror of: src/core/paths.ts → getProjectName() + resolveProjectIdentity().
93
- * The layered git resolution MUST stay identical to that file — a divergence
94
- * means hooks (which write project tags) and core (which reads them) would
95
- * disagree on identity, re-creating the split this change fixes.
96
- *
97
- * @param {string|null|undefined} [cwdInput]
98
- * @returns {string}
99
- */
100
- const _projectNameCache = new Map();
101
-
102
- export function getProjectName(cwdInput) {
103
- const cwd = cwdInput && cwdInput.length > 0 ? cwdInput : process.cwd();
104
- const cached = _projectNameCache.get(cwd);
105
- if (cached !== undefined) return cached;
106
- const resolved = _resolveProjectIdentity(cwd);
107
- _projectNameCache.set(cwd, resolved);
108
- return resolved;
109
- }
110
-
111
- // Layered identity: git remote slug > git repo root basename > cwd basename.
112
- // See src/core/paths.ts resolveProjectIdentity for the full rationale. git
113
- // failures at any layer fall through to the next; capture must never break.
114
- function _resolveProjectIdentity(cwd) {
115
- const remote = _tryGit(cwd, ['config', '--get', 'remote.origin.url']);
116
- if (remote) {
117
- const slug = slugFromRemoteUrl(remote);
118
- if (slug) return slug;
119
- }
120
- const root = _tryGit(cwd, ['rev-parse', '--show-toplevel']);
121
- if (root) return basename(root);
122
- return basename(cwd);
123
- }
124
-
125
- function _tryGit(cwd, args) {
126
- try {
127
- const out = execFileSync('git', ['-C', cwd, ...args], {
128
- encoding: 'utf8',
129
- timeout: 2000,
130
- stdio: ['ignore', 'pipe', 'ignore'],
131
- });
132
- const trimmed = out.trim();
133
- return trimmed.length > 0 ? trimmed : null;
134
- } catch {
135
- return null;
136
- }
137
- }
17
+ // `src/core/paths.ts` and `src/storage/fts-index.ts` are runtime-LEAF modules
18
+ // (paths.ts imports only node builtins; fts-index.ts has only a type-only
19
+ // import), so `tsc` emits self-contained JS for them. `scripts/generate-hook-core.mjs`
20
+ // copies that compiled JS to `_generated/` at build time — committed, shipped in
21
+ // the tarball, and version-locked to its own install. So the hook path still
22
+ // survives a missing/stale `dist/` (the F5 constraint) exactly as the hand-mirror
23
+ // did, but the copy is byte-locked to core and CI-gated (`git diff` on rebuild +
24
+ // `tests/hooks/mirror-parity.test.ts`), making drift structurally impossible.
25
+ //
26
+ // Re-exported here so all 7 hooks keep importing these names from `_shared.js`
27
+ // unchanged.
28
+ import {
29
+ memeshDir,
30
+ getDbPath,
31
+ getMemeshDirFromDbPath,
32
+ getProjectName,
33
+ slugFromRemoteUrl,
34
+ } from './_generated/core-paths.js';
35
+ import { removeFromFts, insertFtsRow } from './_generated/fts-index.js';
36
+
37
+ export { memeshDir, getDbPath, getMemeshDirFromDbPath, getProjectName, slugFromRemoteUrl };
138
38
 
139
- /** Mirror of paths.ts slugFromRemoteUrl. */
140
- export function slugFromRemoteUrl(url) {
141
- const cleaned = url.trim().replace(/\.git$/i, '').replace(/[/\\]+$/, '');
142
- if (!cleaned) return null;
143
- const seg = cleaned.split(/[/:\\]/).filter(Boolean).pop();
144
- return seg && seg.length > 0 ? seg : null;
145
- }
39
+ const require = createRequire(import.meta.url);
146
40
 
147
41
  /**
148
42
  * Resolve the package root from a hook file's `import.meta.url`.
@@ -623,25 +517,73 @@ export function openHookDb(env = process.env, opts = {}) {
623
517
  return { db, dbPath };
624
518
  }
625
519
 
626
- const PRIVATE_DIR_MODE = 0o700;
627
- const PRIVATE_FILE_MODE = 0o600;
628
-
629
520
  /**
630
- * Resolve the directory containing the active DB file.
521
+ * Single owner of the hook-side entity write dance: upsert entity, append
522
+ * observations + tags, and — critically — keep the contentless `entities_fts`
523
+ * index in sync so the memory is recallable via the FTS keyword hot path.
524
+ *
525
+ * Why this exists: post-commit.js, pre-compact.js, and session-summary.js each
526
+ * hand-rolled this dance inline. Three copies drifted — session-summary's copy
527
+ * omitted the FTS reindex entirely, so every `session-insight` memory it wrote
528
+ * was invisible to `recall` and pre-edit-recall (no FTS trigger, no self-heal
529
+ * rebuild on open back it up). Centralising the dance here makes the FTS step
530
+ * impossible to forget in a future hook. This mirrors, on the hook side, what
531
+ * `src/storage/fts-index.ts` already does for core (the F5 boundary keeps them
532
+ * as two implementations of the same contract).
631
533
  *
632
- * When MEMESH_DB_PATH is set, returns its parent directory (used for
633
- * sibling files next to the DB). Otherwise returns memeshDir().
634
- * Mirror of: src/core/paths.ts getMemeshDirFromDbPath()
534
+ * The caller MUST open its DB with `openHookDb(env, { fts: true })` so the FTS
535
+ * table is guaranteed present. Embeddings + auto-tagging + signal-scoring are
536
+ * deliberately NOT done here: hooks are cheap always-on capture, and those are
537
+ * the heavier, user-initiated `remember` concerns (core owns them).
635
538
  *
636
- * Renamed from the legacy `getMemeshDir` to match the core helper —
637
- * sibling helper `memeshDir()` returns the GLOBAL data directory, so a
638
- * second function called `getMemeshDir` was confusing. Callers updated
639
- * in lockstep.
539
+ * @param {import('better-sqlite3').Database} db - an open hook DB handle
540
+ * @param {{name: string, type: string, observations?: string[], tags?: string[]}} entity
541
+ * @returns {{ id: number, isNew: boolean } | null} null if the row could not be resolved
640
542
  */
641
- export function getMemeshDirFromDbPath() {
642
- return process.env.MEMESH_DB_PATH ? dirname(process.env.MEMESH_DB_PATH) : memeshDir();
543
+ export function captureEntity(db, { name, type, observations = [], tags = [] }) {
544
+ const insertResult = db
545
+ .prepare('INSERT OR IGNORE INTO entities (name, type) VALUES (?, ?)')
546
+ .run(name, type);
547
+ const isNew = insertResult.changes > 0;
548
+ const row = db.prepare('SELECT id FROM entities WHERE name = ?').get(name);
549
+ if (!row) return null;
550
+ const id = row.id;
551
+
552
+ // Capture the previously-indexed observation text BEFORE inserting new rows,
553
+ // so the contentless-FTS 'delete' below matches what was indexed. Only for
554
+ // existing entities — a brand-new row has no prior FTS entry to remove.
555
+ const prevObsText = isNew
556
+ ? undefined
557
+ : db
558
+ .prepare('SELECT content FROM observations WHERE entity_id = ?')
559
+ .all(id)
560
+ .map((o) => o.content)
561
+ .join(' ');
562
+
563
+ const insertObs = db.prepare('INSERT INTO observations (entity_id, content) VALUES (?, ?)');
564
+ for (const obs of observations) insertObs.run(id, obs);
565
+ const insertTag = db.prepare('INSERT OR IGNORE INTO tags (entity_id, tag) VALUES (?, ?)');
566
+ for (const tag of tags) insertTag.run(id, tag);
567
+
568
+ // Reindex FTS: delete the stale entry (if any) then insert the full,
569
+ // current observation set. Uses the generated copy of src/storage/fts-index.ts
570
+ // so the contentless-FTS5 delete+insert dance can no longer drift from core.
571
+ if (prevObsText !== undefined) {
572
+ removeFromFts(db, id, name, prevObsText);
573
+ }
574
+ const allObsText = db
575
+ .prepare('SELECT content FROM observations WHERE entity_id = ?')
576
+ .all(id)
577
+ .map((o) => o.content)
578
+ .join(' ');
579
+ insertFtsRow(db, id, name, allObsText);
580
+
581
+ return { id, isNew };
643
582
  }
644
583
 
584
+ const PRIVATE_DIR_MODE = 0o700;
585
+ const PRIVATE_FILE_MODE = 0o600;
586
+
645
587
  export function ensurePrivateDir(dirPath) {
646
588
  mkdirSync(dirPath, { recursive: true, mode: PRIVATE_DIR_MODE });
647
589
  try {
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execFileSync } from 'child_process';
4
- import { getProjectName, openHookDb } from './_shared.js';
4
+ import { captureEntity, getProjectName, openHookDb } from './_shared.js';
5
5
 
6
6
  let input = '';
7
7
  process.stdin.setEncoding('utf8');
@@ -77,53 +77,32 @@ process.stdin.on('end', () => {
77
77
  try {
78
78
  const entityName = `commit-${commitHash}`;
79
79
 
80
- // Check if this is a new or existing entity
81
- const insertResult = db.prepare('INSERT OR IGNORE INTO entities (name, type) VALUES (?, ?)').run(entityName, 'commit');
82
- const isNew = insertResult.changes > 0;
83
- const entity = db.prepare('SELECT id FROM entities WHERE name = ?').get(entityName);
84
- if (entity) {
85
- // Capture existing observations for FTS delete (before inserting new one)
86
- const prevObs = isNew
87
- ? []
88
- : db.prepare('SELECT content FROM observations WHERE entity_id = ?').all(entity.id);
89
- const prevObsText = isNew ? undefined : prevObs.map(o => o.content).join(' ');
90
-
91
- // Add observations
92
- db.prepare('INSERT INTO observations (entity_id, content) VALUES (?, ?)').run(entity.id, commitMsg);
93
- db.prepare('INSERT INTO observations (entity_id, content) VALUES (?, ?)').run(entity.id, `Branch: ${branch}`);
94
-
95
- // Add richer diff stats as an observation (backward compatible — failures are silently ignored)
96
- try {
97
- const stat = execFileSync('git', ['show', '--stat', '--format=', commitHash], {
98
- cwd: data.cwd || process.cwd(),
99
- encoding: 'utf8',
100
- timeout: 5000,
101
- }).trim();
102
- if (stat) {
103
- // Last non-empty line is the summary, e.g. "3 files changed, 45 insertions(+), 12 deletions(-)"
104
- const statLines = stat.split('\n').filter(l => l.trim());
105
- const summary = statLines[statLines.length - 1]?.trim() || '';
106
- if (summary) {
107
- db.prepare('INSERT INTO observations (entity_id, content) VALUES (?, ?)').run(entity.id, `Diff stats: ${summary}`);
108
- }
109
- }
110
- } catch {
111
- // git show failed — no diff stats recorded, existing behavior unchanged
112
- }
113
-
114
- // Add project tag
115
- const projectTag = `project:${projectName}`;
116
- db.prepare('INSERT OR IGNORE INTO tags (entity_id, tag) VALUES (?, ?)').run(entity.id, projectTag);
117
-
118
- // Update FTS index — delete old entry first if entity existed
119
- if (prevObsText !== undefined) {
120
- db.prepare("INSERT INTO entities_fts(entities_fts, rowid, name, observations) VALUES('delete', ?, ?, ?)").run(entity.id, entityName, prevObsText);
80
+ // Build the observation set: commit message, branch, and — best-effort —
81
+ // richer diff stats (git failures are silently ignored, unchanged behavior).
82
+ const observations = [commitMsg, `Branch: ${branch}`];
83
+ try {
84
+ const stat = execFileSync('git', ['show', '--stat', '--format=', commitHash], {
85
+ cwd: data.cwd || process.cwd(),
86
+ encoding: 'utf8',
87
+ timeout: 5000,
88
+ }).trim();
89
+ if (stat) {
90
+ // Last non-empty line is the summary, e.g. "3 files changed, 45 insertions(+), 12 deletions(-)"
91
+ const statLines = stat.split('\n').filter(l => l.trim());
92
+ const summary = statLines[statLines.length - 1]?.trim() || '';
93
+ if (summary) observations.push(`Diff stats: ${summary}`);
121
94
  }
122
- // Fetch all observations (including the one just added) for the new FTS entry
123
- const allObs = db.prepare('SELECT content FROM observations WHERE entity_id = ?').all(entity.id);
124
- const allObsText = allObs.map(o => o.content).join(' ');
125
- db.prepare('INSERT INTO entities_fts(rowid, name, observations) VALUES(?, ?, ?)').run(entity.id, entityName, allObsText);
95
+ } catch {
96
+ // git show failed no diff stats recorded, existing behavior unchanged
126
97
  }
98
+
99
+ // Shared write dance — upsert entity + observations + tags AND reindex FTS.
100
+ captureEntity(db, {
101
+ name: entityName,
102
+ type: 'commit',
103
+ observations,
104
+ tags: [`project:${projectName}`],
105
+ });
127
106
  } finally {
128
107
  db.close();
129
108
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { basename } from 'path';
4
4
  import { existsSync, readFileSync } from 'fs';
5
- import { getProjectName, isAutoCaptureEnabled, openHookDb } from './_shared.js';
5
+ import { captureEntity, getProjectName, isAutoCaptureEnabled, openHookDb } from './_shared.js';
6
6
 
7
7
  // Timeout guard: always exit within 10 seconds
8
8
  const TIMEOUT_MS = 10000;
@@ -99,37 +99,14 @@ process.stdin.on('end', () => {
99
99
  if (!handle) return;
100
100
  const { db } = handle;
101
101
  try {
102
- // Upsert entity
103
- const insertResult = db.prepare('INSERT OR IGNORE INTO entities (name, type) VALUES (?, ?)').run(entityName, 'session-summary');
104
- const isNew = insertResult.changes > 0;
105
- const entity = db.prepare('SELECT id FROM entities WHERE name = ?').get(entityName);
106
-
107
- if (entity) {
108
- // Capture existing observations for FTS delete
109
- const prevObs = isNew
110
- ? []
111
- : db.prepare('SELECT content FROM observations WHERE entity_id = ?').all(entity.id);
112
- const prevObsText = isNew ? undefined : prevObs.map(o => o.content).join(' ');
113
-
114
- // Insert each observation line
115
- for (const line of obsLines) {
116
- db.prepare('INSERT INTO observations (entity_id, content) VALUES (?, ?)').run(entity.id, line);
117
- }
118
-
119
- // Add tags
120
- const tags = ['source:auto-capture', 'urgency:pre-compact', `project:${projectName}`];
121
- for (const tag of tags) {
122
- db.prepare('INSERT OR IGNORE INTO tags (entity_id, tag) VALUES (?, ?)').run(entity.id, tag);
123
- }
124
-
125
- // Update FTS
126
- if (prevObsText !== undefined) {
127
- db.prepare("INSERT INTO entities_fts(entities_fts, rowid, name, observations) VALUES('delete', ?, ?, ?)").run(entity.id, entityName, prevObsText);
128
- }
129
- const allObs = db.prepare('SELECT content FROM observations WHERE entity_id = ?').all(entity.id);
130
- const allObsText = allObs.map(o => o.content).join(' ');
131
- db.prepare('INSERT INTO entities_fts(rowid, name, observations) VALUES(?, ?, ?)').run(entity.id, entityName, allObsText);
132
- }
102
+ // Shared write dance — upsert entity + observations + tags AND reindex FTS
103
+ // so the pre-compact memory is recallable via the FTS keyword path.
104
+ captureEntity(db, {
105
+ name: entityName,
106
+ type: 'session-summary',
107
+ observations: obsLines,
108
+ tags: ['source:auto-capture', 'urgency:pre-compact', `project:${projectName}`],
109
+ });
133
110
  } finally {
134
111
  db.close();
135
112
  }
@@ -11,6 +11,7 @@ import { spawn } from 'child_process';
11
11
  import os from 'os';
12
12
  import { pathToFileURL } from 'url';
13
13
  import {
14
+ captureEntity,
14
15
  decideAutoUpdateHook,
15
16
  getMemeshDirFromDbPath,
16
17
  getProjectName,
@@ -233,7 +234,9 @@ process.stdin.on('end', async () => {
233
234
  // Open DB via shared helper — applies SCHEMA_SQL + status migration.
234
235
  // sqlite-vec is loaded separately because only this hook needs it
235
236
  // (for embedding-aware recall-effectiveness tracking).
236
- const handle = openHookDb(process.env);
237
+ // { fts: true } guarantees the entities_fts table exists so captureEntity()
238
+ // can keep it in sync — session-insight memories must be FTS-recallable.
239
+ const handle = openHookDb(process.env, { fts: true });
237
240
  if (!handle) {
238
241
  // Native module unavailable (plugin-marketplace cache install with no
239
242
  // node_modules). Skip session-capture work, but still let the
@@ -291,17 +294,12 @@ process.stdin.on('end', async () => {
291
294
  return [...tags];
292
295
  }
293
296
 
294
- const insertEntity = db.prepare('INSERT OR IGNORE INTO entities (name, type) VALUES (?, ?)');
295
- const selectEntity = db.prepare('SELECT id FROM entities WHERE name = ?');
296
- const insertObs = db.prepare('INSERT INTO observations (entity_id, content) VALUES (?, ?)');
297
- const insertTag = db.prepare('INSERT OR IGNORE INTO tags (entity_id, tag) VALUES (?, ?)');
298
-
297
+ // Delegate the write to the shared captureEntity() so entities land in
298
+ // entities_fts too. This copy used to insert entity + observations + tags
299
+ // only, skipping the FTS reindex the sibling hooks did — which left every
300
+ // session-insight memory unrecallable via the FTS keyword path.
299
301
  function storeMemory(name, type, observations, tags) {
300
- insertEntity.run(name, type);
301
- const row = selectEntity.get(name);
302
- if (!row) return;
303
- for (const obs of observations) insertObs.run(row.id, obs);
304
- for (const tag of tags) insertTag.run(row.id, tag);
302
+ captureEntity(db, { name, type, observations, tags });
305
303
  }
306
304
 
307
305
  // Rule 1: File editing session summary
@@ -426,8 +424,11 @@ process.stdin.on('end', async () => {
426
424
 
427
425
  for (let i = 0; i < entityIds.length; i++) {
428
426
  const name = (entityNames[i] || '').toLowerCase();
429
- // Skip very short names to avoid false positives
430
- if (name.length < 4) continue;
427
+ // Skip names that carry no recall signal: too short, or a
428
+ // machine identifier (auto-capture entities) that can never
429
+ // substring-match prose. Scoring those would be a guaranteed
430
+ // unearned miss — see isMeasurableRecallName.
431
+ if (!isMeasurableRecallName(name)) continue;
431
432
  if (isRecallHit(sessionText, name)) {
432
433
  updateHit.run(entityIds[i]);
433
434
  } else {
@@ -761,6 +762,28 @@ export function isRecallHit(sessionText, name) {
761
762
  return String(sessionText ?? '').toLowerCase().includes(String(name).toLowerCase());
762
763
  }
763
764
 
765
+ /**
766
+ * Whether an injected entity's NAME can serve as a recall-effectiveness signal.
767
+ *
768
+ * Recall-effectiveness decides "was this injected memory used?" by substring-
769
+ * matching the entity NAME in the session transcript (isRecallHit). That only
770
+ * works for names a human might type. Auto-capture entities are named with
771
+ * machine identifiers — `session-<pid>-<ts>-files`, `commit-<hash>`,
772
+ * `pre-compact-<id>` — which never appear verbatim in conversation prose, so
773
+ * they take a `recall_miss` they didn't earn on every injection. Over repeated
774
+ * sessions that drags their Laplace-smoothed impact factor (scoring.ts, 10%
775
+ * weight) down and quietly suppresses auto-captured memories from future recall.
776
+ *
777
+ * We can't measure their usefulness by name, so we don't count them either way —
778
+ * they keep the neutral 0.5 impact. The prefix set is coupled to the auto-capture
779
+ * producers' `<kind>-<id>` naming (post-commit / session-summary / pre-compact);
780
+ * a new auto-capture producer should add its prefix here.
781
+ */
782
+ export function isMeasurableRecallName(name) {
783
+ if (!name || name.length < 4) return false;
784
+ return !/^(session-|commit-|pre-compact-)/i.test(name);
785
+ }
786
+
764
787
  export function maybeTriggerDream(projectName, config, pluginRoot) {
765
788
  dreamTrigTrace('enter', { projectName, hasLlm: Boolean(config?.llm) });
766
789
  if (!projectName || projectName === 'unknown') {