@theglitchking/semantic-memory 1.2.2 → 1.5.0

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 (65) hide show
  1. package/.claude-plugin/marketplace.json +4 -4
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +137 -0
  4. package/ROADMAP.md +5 -2
  5. package/commands/healthcheck.md +13 -11
  6. package/dist/{chunk-UQSASB7Q.js → chunk-2EELGTLX.js} +32 -3
  7. package/dist/chunk-2EELGTLX.js.map +1 -0
  8. package/dist/chunk-5ZKNO6ZD.js +96 -0
  9. package/dist/chunk-5ZKNO6ZD.js.map +1 -0
  10. package/dist/chunk-7M3JZOYH.js +105 -0
  11. package/dist/chunk-7M3JZOYH.js.map +1 -0
  12. package/dist/chunk-A3KYHKB5.js +176 -0
  13. package/dist/chunk-A3KYHKB5.js.map +1 -0
  14. package/dist/{chunk-CYWPDFNI.js → chunk-CSXZ7QUU.js} +4 -203
  15. package/dist/chunk-CSXZ7QUU.js.map +1 -0
  16. package/dist/chunk-FM26NFTR.js +272 -0
  17. package/dist/chunk-FM26NFTR.js.map +1 -0
  18. package/dist/chunk-NTYYS53O.js +91 -0
  19. package/dist/chunk-NTYYS53O.js.map +1 -0
  20. package/dist/{chunk-E2Z4J4MK.js → chunk-OWZWMB4P.js} +70 -2
  21. package/dist/chunk-OWZWMB4P.js.map +1 -0
  22. package/dist/chunk-S4KW7VH4.js +203 -0
  23. package/dist/chunk-S4KW7VH4.js.map +1 -0
  24. package/dist/{chunk-HODDRXJK.js → chunk-V45NJIHI.js} +4 -2
  25. package/dist/chunk-V45NJIHI.js.map +1 -0
  26. package/dist/chunk-WSU4QIPB.js +311 -0
  27. package/dist/chunk-WSU4QIPB.js.map +1 -0
  28. package/dist/cli/index.js +265 -65
  29. package/dist/cli/index.js.map +1 -1
  30. package/dist/core/index.d.ts +39 -2
  31. package/dist/core/index.js +17 -11
  32. package/dist/decay-ELJUKVQG.js +14 -0
  33. package/dist/dossier-2UG724LB.js +39 -0
  34. package/dist/healthcheck-IMB47LZE.js +19 -0
  35. package/dist/indexer-YW4O3VVM.js +8 -0
  36. package/dist/lexicon-5PXQEMUF.js +26 -0
  37. package/dist/lexicon-5PXQEMUF.js.map +1 -0
  38. package/dist/lint-XUQ7JDYX.js +16 -0
  39. package/dist/lint-XUQ7JDYX.js.map +1 -0
  40. package/dist/mcp/server.js +478 -40
  41. package/dist/mcp/server.js.map +1 -1
  42. package/dist/profile-LXUEF7TX.js +20 -0
  43. package/dist/profile-LXUEF7TX.js.map +1 -0
  44. package/dist/{schema-F3RTY6SQ.js → schema-ATFCLC6Y.js} +2 -2
  45. package/dist/schema-ATFCLC6Y.js.map +1 -0
  46. package/dist/{search-27WYAI24.js → search-AHKEFI2B.js} +2 -2
  47. package/dist/telemetry-GB2TEHVE.js +21 -0
  48. package/dist/telemetry-GB2TEHVE.js.map +1 -0
  49. package/hooks/vault-context.js +270 -11
  50. package/package.json +2 -1
  51. package/skills/vault-first/SKILL.md +18 -0
  52. package/dist/chunk-CAN2GWUG.js +0 -88
  53. package/dist/chunk-CAN2GWUG.js.map +0 -1
  54. package/dist/chunk-CYWPDFNI.js.map +0 -1
  55. package/dist/chunk-E2Z4J4MK.js.map +0 -1
  56. package/dist/chunk-HODDRXJK.js.map +0 -1
  57. package/dist/chunk-UQSASB7Q.js.map +0 -1
  58. package/dist/healthcheck-E4GI5RMZ.js +0 -14
  59. package/dist/indexer-KD6UTXZB.js +0 -8
  60. package/dist/lint-GMXQF4RW.js +0 -11
  61. /package/dist/{healthcheck-E4GI5RMZ.js.map → decay-ELJUKVQG.js.map} +0 -0
  62. /package/dist/{indexer-KD6UTXZB.js.map → dossier-2UG724LB.js.map} +0 -0
  63. /package/dist/{lint-GMXQF4RW.js.map → healthcheck-IMB47LZE.js.map} +0 -0
  64. /package/dist/{schema-F3RTY6SQ.js.map → indexer-YW4O3VVM.js.map} +0 -0
  65. /package/dist/{search-27WYAI24.js.map → search-AHKEFI2B.js.map} +0 -0
@@ -0,0 +1,20 @@
1
+ import {
2
+ PROFILE_SECTIONS,
3
+ SPEAKER_PROFILE_PATH,
4
+ initSpeakerProfile,
5
+ readSpeakerProfile,
6
+ speakerProfileTemplate,
7
+ updateProfileSection
8
+ } from "./chunk-7M3JZOYH.js";
9
+ import "./chunk-WSU4QIPB.js";
10
+ import "./chunk-S4KW7VH4.js";
11
+ import "./chunk-PZ5AY32C.js";
12
+ export {
13
+ PROFILE_SECTIONS,
14
+ SPEAKER_PROFILE_PATH,
15
+ initSpeakerProfile,
16
+ readSpeakerProfile,
17
+ speakerProfileTemplate,
18
+ updateProfileSection
19
+ };
20
+ //# sourceMappingURL=profile-LXUEF7TX.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -2,11 +2,11 @@ import {
2
2
  installDefaultSchema,
3
3
  loadSchema,
4
4
  validateNote
5
- } from "./chunk-E2Z4J4MK.js";
5
+ } from "./chunk-OWZWMB4P.js";
6
6
  import "./chunk-PZ5AY32C.js";
7
7
  export {
8
8
  installDefaultSchema,
9
9
  loadSchema,
10
10
  validateNote
11
11
  };
12
- //# sourceMappingURL=schema-F3RTY6SQ.js.map
12
+ //# sourceMappingURL=schema-ATFCLC6Y.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -6,7 +6,7 @@ import {
6
6
  } from "./chunk-25EIQWXL.js";
7
7
  import {
8
8
  Indexer
9
- } from "./chunk-HODDRXJK.js";
9
+ } from "./chunk-V45NJIHI.js";
10
10
  import "./chunk-PZ5AY32C.js";
11
11
 
12
12
  // src/cli/search.ts
@@ -199,4 +199,4 @@ function extractKeywords(query) {
199
199
  export {
200
200
  runSearch
201
201
  };
202
- //# sourceMappingURL=search-27WYAI24.js.map
202
+ //# sourceMappingURL=search-AHKEFI2B.js.map
@@ -0,0 +1,21 @@
1
+ import {
2
+ appendEvent,
3
+ computeSelectionStats,
4
+ readSelectionLog,
5
+ recordSearchForCorrelation,
6
+ selectionLogPath,
7
+ telemetryEnabled,
8
+ wasRecentlySearched
9
+ } from "./chunk-NTYYS53O.js";
10
+ import "./chunk-S4KW7VH4.js";
11
+ import "./chunk-PZ5AY32C.js";
12
+ export {
13
+ appendEvent,
14
+ computeSelectionStats,
15
+ readSelectionLog,
16
+ recordSearchForCorrelation,
17
+ selectionLogPath,
18
+ telemetryEnabled,
19
+ wasRecentlySearched
20
+ };
21
+ //# sourceMappingURL=telemetry-GB2TEHVE.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -11,7 +11,7 @@
11
11
  // Fails open: any error logs to stderr and emits an empty additionalContext.
12
12
 
13
13
  import { spawnSync } from "node:child_process";
14
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
14
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from "node:fs";
15
15
  import { join, resolve, basename, dirname } from "node:path";
16
16
  import { createHash } from "node:crypto";
17
17
  import { fileURLToPath } from "node:url";
@@ -132,13 +132,42 @@ function queryRecentLogViaCli(cliBin, vaultPath, sinceIso, limit = 20) {
132
132
  }
133
133
  }
134
134
 
135
+ // Injection hygiene (v1.4 Phase 2). The <vault-context> block is per-prompt
136
+ // overhead, so it earns its tokens or stays out:
137
+ // - score gate: if the best hit is weak, inject nothing (a "thanks"/"proceed"
138
+ // prompt shouldn't drag six 0.2-score hits into context). Tunable via
139
+ // SEMANTIC_MEMORY_INJECT_MIN_SCORE.
140
+ // - dedup by basename: collapse archive/x.md + archive/pre-restructure/x.md to
141
+ // one slot (keep the highest-scored), so retired-doc twins stop burning slots.
142
+ // - top-3, not top-6.
143
+ // - one compact instructions line — the full cite-or-deflect rule lives once in
144
+ // CLAUDE.md and the vault-first skill, not repeated in full every prompt.
145
+ const INJECT_MIN_SCORE = Number(process.env.SEMANTIC_MEMORY_INJECT_MIN_SCORE) || 0.35;
146
+
147
+ function dedupeByBasename(hits) {
148
+ const seen = new Set();
149
+ const out = [];
150
+ for (const h of hits) {
151
+ const base = String(h.path || "").split("/").pop();
152
+ if (seen.has(base)) continue;
153
+ seen.add(base);
154
+ out.push(h);
155
+ }
156
+ return out;
157
+ }
158
+
135
159
  export function formatContextBlock(source, query, hits) {
136
160
  if (!hits || hits.length === 0) return "";
161
+ // Score gate: skip injection entirely when nothing is strongly relevant.
162
+ const topScore = Math.max(...hits.map((h) => (typeof h.score === "number" ? h.score : 0)));
163
+ if (topScore < INJECT_MIN_SCORE) return "";
164
+
165
+ const shown = dedupeByBasename(hits).slice(0, 3);
137
166
  const lines = [];
138
167
  lines.push(`<vault-context source="${source}" query="${escapeAttr(query)}">`);
139
168
  lines.push(`The vault (semantic-memory) was proactively searched. Top hits:`);
140
169
  lines.push("");
141
- for (const h of hits.slice(0, 6)) {
170
+ for (const h of shown) {
142
171
  const score = typeof h.score === "number" ? h.score.toFixed(2) : "";
143
172
  lines.push(`- \`${h.path}\`${score ? ` (score: ${score})` : ""}`);
144
173
  if (h.snippet) {
@@ -147,11 +176,217 @@ export function formatContextBlock(source, query, hits) {
147
176
  }
148
177
  }
149
178
  lines.push("");
150
- lines.push(`Instructions: This block is injected on every prompt. Apply cite-or-deflect ONLY when the user's prompt is a project prose lookup (how/why/where does X work here, runbook/process question, gotcha or known-issue lookup). For those, read promising hits via \`mcp__semantic-vault__read_note\` and either cite the filenames or say "not in vault" and name the nearest misses. For meta/tool questions, debugging, status checks, directives ("proceed", "merge"), or conversational turns, ignore this block silently. Do NOT narrate "X unrelated" or "not in vault for this" on non-lookup prompts — that is noise, not honest deflection.`);
179
+ lines.push(`Instructions: Apply cite-or-deflect ONLY to project prose lookups (how/why/where does X work here) — cite the hit paths, or say "not in vault". For meta/tool/debug/status/directive/conversational prompts, ignore this block silently. Do NOT narrate "X unrelated" or "not in vault" on those.`);
151
180
  lines.push(`</vault-context>`);
152
181
  return lines.join("\n");
153
182
  }
154
183
 
184
+ // --- Tier-1 lexicon query expansion (v1.4 Phase 6, deterministic) ---
185
+ // Before embedding a user utterance, substitute/augment it with the canonical
186
+ // targets of any learned alias phrases it contains, so "is the flaky thing fixed?"
187
+ // searches as "...the flaky thing... src/core/indexer.ts". Pure string/table
188
+ // lookup — no LLM, no spawn, reads the derived lexicon-cache.json only.
189
+
190
+ function normalizePhrase(s) {
191
+ return String(s || "").toLowerCase().trim().replace(/\s+/g, " ");
192
+ }
193
+
194
+ function readLexiconAliases(projectRoot) {
195
+ try {
196
+ const cachePath = join(projectRoot, ".claude", ".semantic-memory", "lexicon-cache.json");
197
+ if (!existsSync(cachePath)) return [];
198
+ const data = JSON.parse(readFileSync(cachePath, "utf8"));
199
+ return Array.isArray(data?.aliases) ? data.aliases : [];
200
+ } catch {
201
+ return [];
202
+ }
203
+ }
204
+
205
+ export function expandQueryViaLexicon(projectRoot, query) {
206
+ const aliases = readLexiconAliases(projectRoot);
207
+ if (aliases.length === 0) return query;
208
+ const nq = normalizePhrase(query);
209
+ const canonicals = [];
210
+ for (const a of aliases) {
211
+ const phrases = Array.isArray(a.phrases) ? a.phrases : [];
212
+ if (phrases.some((p) => p && nq.includes(normalizePhrase(p)))) {
213
+ if (a.canonical && !canonicals.includes(a.canonical)) canonicals.push(a.canonical);
214
+ }
215
+ }
216
+ return canonicals.length ? `${query} ${canonicals.join(" ")}` : query;
217
+ }
218
+
219
+ // --- Two-hop retrieval (v1.5 Phase 8): utterance → entity → dossier ---
220
+ // When the prompt names a dossier entity (or one of its aliases), inject the
221
+ // dossier head — Purpose + Current state — FIRST, ahead of semantic hits. This is
222
+ // the "resident expert pulls the component's file" move: entity-first, not file-first.
223
+ // Deterministic table lookup against the derived dossier-cache.json — no LLM, no spawn.
224
+
225
+ function readDossierCache(projectRoot) {
226
+ try {
227
+ const cachePath = join(projectRoot, ".claude", ".semantic-memory", "dossier-cache.json");
228
+ if (!existsSync(cachePath)) return [];
229
+ const data = JSON.parse(readFileSync(cachePath, "utf8"));
230
+ return Array.isArray(data?.dossiers) ? data.dossiers : [];
231
+ } catch {
232
+ return [];
233
+ }
234
+ }
235
+
236
+ export function resolveDossierForPrompt(projectRoot, query) {
237
+ const dossiers = readDossierCache(projectRoot);
238
+ if (dossiers.length === 0) return null;
239
+ const nq = normalizePhrase(query);
240
+ let best = null;
241
+ let bestLen = 0;
242
+ for (const d of dossiers) {
243
+ const keys = [d.entity, ...(Array.isArray(d.aliases) ? d.aliases : [])]
244
+ .map(normalizePhrase)
245
+ .filter(Boolean);
246
+ for (const k of keys) {
247
+ if (nq.includes(k) && k.length > bestLen) {
248
+ best = d;
249
+ bestLen = k.length;
250
+ }
251
+ }
252
+ }
253
+ return best;
254
+ }
255
+
256
+ export function formatDossierHead(dossier) {
257
+ if (!dossier || !dossier.path) return "";
258
+ const lines = [];
259
+ lines.push(`<vault-dossier entity="${escapeAttr(dossier.entity || "")}" path="${escapeAttr(dossier.path)}">`);
260
+ lines.push(`This utterance names a tracked entity. Its dossier is the first place to look:`);
261
+ if (dossier.purpose) lines.push(`- purpose: ${String(dossier.purpose).replace(/\s+/g, " ").slice(0, 240)}`);
262
+ if (dossier.current_state) lines.push(`- current state: ${String(dossier.current_state).replace(/\s+/g, " ").slice(0, 240)}`);
263
+ lines.push(`Read \`${dossier.path}\` (its Incident log is the fix history) before answering about ${dossier.entity}.`);
264
+ lines.push(`</vault-dossier>`);
265
+ return lines.join("\n");
266
+ }
267
+
268
+ // --- Session paging (v1.5 Phase 10): memory as the context window's swap space ---
269
+ // SessionStart pages IN a curated digest — latest session digest, active task,
270
+ // dossier current-states, mode — instead of a broad whole-vault semantic sweep.
271
+ // Fewer tokens, more signal. Falls back to search when there's nothing to page in.
272
+
273
+ /** Newest sessions/*.md digest note: its title + first content paragraph. */
274
+ function readLatestSessionDigest(vaultPath) {
275
+ try {
276
+ const dir = join(vaultPath, "sessions");
277
+ if (!existsSync(dir)) return null;
278
+ const files = readdirSync(dir).filter((f) => f.endsWith(".md"));
279
+ if (files.length === 0) return null;
280
+ let newest = null;
281
+ let newestMs = -1;
282
+ for (const f of files) {
283
+ const p = join(dir, f);
284
+ try {
285
+ const ms = statSync(p).mtimeMs;
286
+ if (ms > newestMs) { newestMs = ms; newest = p; }
287
+ } catch { /* skip */ }
288
+ }
289
+ if (!newest) return null;
290
+ const raw = readFileSync(newest, "utf8");
291
+ const body = raw.replace(/^---[\s\S]*?---\s*/, ""); // strip frontmatter
292
+ const titleMatch = body.match(/^#\s+(.+)$/m);
293
+ const title = titleMatch ? titleMatch[1].trim() : basename(newest).replace(/\.md$/, "");
294
+ const para = body
295
+ .replace(/^#.*$/m, "")
296
+ .split(/\n\s*\n/)
297
+ .map((s) => s.trim())
298
+ .find((s) => s && !s.startsWith("#"));
299
+ return {
300
+ path: `sessions/${basename(newest)}`,
301
+ title,
302
+ head: para ? para.replace(/\s+/g, " ").slice(0, 300) : "",
303
+ };
304
+ } catch {
305
+ return null;
306
+ }
307
+ }
308
+
309
+ /** Dossier current-state one-liners from the derived cache (capped). */
310
+ function readDossierStates(projectRoot, cap = 6) {
311
+ const dossiers = readDossierCache(projectRoot);
312
+ return dossiers
313
+ .filter((d) => d && d.current_state)
314
+ .slice(0, cap)
315
+ .map((d) => ({ entity: d.entity, path: d.path, state: String(d.current_state).replace(/\s+/g, " ").slice(0, 160) }));
316
+ }
317
+
318
+ /**
319
+ * Build the SessionStart curated digest block. Returns "" when there's nothing
320
+ * durable to page in (no digest note, no dossier states, no active task) — the
321
+ * caller then falls back to the broad semantic search.
322
+ */
323
+ function buildSessionStartDigest(projectRoot, vaultPath) {
324
+ const digest = readLatestSessionDigest(vaultPath);
325
+ const states = readDossierStates(projectRoot);
326
+ const session = readActiveSession(projectRoot);
327
+ const mode = readMode(projectRoot);
328
+ if (!digest && states.length === 0 && !session) return "";
329
+
330
+ const lines = [`<vault-session-digest mode="${escapeAttr(mode)}">`];
331
+ lines.push(`Paged in from the vault (curated — not a broad search):`);
332
+ if (session) {
333
+ lines.push(`- active task: ${String(session.task || "(unnamed)").slice(0, 160)} (session ${session.id})`);
334
+ }
335
+ if (digest) {
336
+ lines.push(`- last session digest \`${digest.path}\`: ${digest.head || digest.title}`);
337
+ }
338
+ if (states.length > 0) {
339
+ lines.push(`- tracked entities (current state):`);
340
+ for (const s of states) lines.push(` · ${s.entity} — ${s.state} \`${s.path}\``);
341
+ }
342
+ lines.push(`Read the digest or a dossier before re-deriving prior context.`);
343
+ lines.push(`</vault-session-digest>`);
344
+ return lines.join("\n");
345
+ }
346
+
347
+ // --- Speaker profile (v1.5 Phase 11): the model of THIS human ---
348
+ // Read profile/speaker.md and inject a capped, placeholder-stripped head at
349
+ // SessionStart, so every session starts knowing how this person communicates.
350
+
351
+ function readSpeakerProfileBlock(vaultPath, maxLines = 24) {
352
+ try {
353
+ const abs = join(vaultPath, "profile", "speaker.md");
354
+ if (!existsSync(abs)) return "";
355
+ const raw = readFileSync(abs, "utf8");
356
+ const body = raw.replace(/^---[\s\S]*?---\s*/, "").trim();
357
+ const kept = [];
358
+ for (const line of body.split("\n")) {
359
+ if (/^#{1,6}\s/.test(line)) { kept.push(line.replace(/^#+\s*/, "").trim() + ":"); continue; }
360
+ const t = line.trim();
361
+ if (t === "" || /^_.*_$/.test(t)) continue; // drop placeholder prompts
362
+ kept.push(t);
363
+ }
364
+ // Drop trailing section labels with no content under them.
365
+ while (kept.length && kept[kept.length - 1].endsWith(":")) kept.pop();
366
+ if (kept.length === 0) return "";
367
+ const lines = [`<vault-speaker-profile path="profile/speaker.md">`];
368
+ lines.push(`How this human communicates (learned — apply, don't recite):`);
369
+ for (const l of kept.slice(0, maxLines)) lines.push(` ${l}`);
370
+ lines.push(`</vault-speaker-profile>`);
371
+ return lines.join("\n");
372
+ } catch {
373
+ return "";
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Stop-time session-digest instruction: draft a durable digest of this session's
379
+ * decisions/resolutions/task-state as a PROPOSAL (decision Q10 — automatic draft,
380
+ * human-reviewable, graduated via synthesize_promote). Appended to the session-close
381
+ * prompt so a formal session boundary always offers to file its memory.
382
+ */
383
+ function formatSessionDigestPrompt(session) {
384
+ return [
385
+ "",
386
+ `After finishing, page this session's durable state to the vault: call \`mcp__semantic-vault__synthesize_note\` with \`from_session: true\`, \`proposal: true\`, \`proposal_subdir: "sessions"\`, and \`suggested_path: "sessions/${session.id}.md"\` — capturing decisions made, resolutions (symptom→cause→fix), and remaining task state. It files as a reviewable proposal; \`synthesize_promote\` graduates it if worth keeping. This is memory paging, not busywork: next session pages it back in instead of re-deriving.`,
387
+ ].join("\n");
388
+ }
389
+
155
390
  function escapeAttr(s) {
156
391
  return String(s).replace(/["<>&]/g, (c) => ({ '"': "&quot;", "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c]);
157
392
  }
@@ -271,6 +506,9 @@ const CAPTURE_CUES = [
271
506
  // (a/the gotcha, gotcha:, gotchas, gotcha is/was/here/with) so bare filler doesn't misfire.
272
507
  /\b(?:a|the|one|another)\s+gotcha\b|\bgotcha[:s]\b|\bgotcha (?:is|was|here|with)\b|\bworkaround\b|\bhack\b/i,
273
508
  /\bnew convention\b|\bfrom now on\b|\bgoing forward\b/i,
509
+ // Speaker-calibration corrections (v1.5 Phase 11): the user telling you how THEY
510
+ // talk — "no, when I say X I mean Y", "what I mean by X is", "I actually meant".
511
+ /\bwhen i say\b[\s\S]{0,40}\bi mean\b|\bwhat i mean by\b|\bi actually meant\b|\bwhen i said\b[\s\S]{0,40}\bi meant\b/i,
274
512
  ];
275
513
 
276
514
  // Cue detection must scan the user's own prose, NOT quoted machinery. Pasting
@@ -367,16 +605,25 @@ async function handleSessionStart(projectRoot, vaultPath, cliBin) {
367
605
  logMaybeModeChange(cliBin, vaultPath, priorMode, "vault-first");
368
606
  }
369
607
 
370
- const query = sessionSeedQuery(projectRoot);
371
- debug(`sessionstart seed query: "${query}"`);
372
- const hits = runSearchCli(cliBin, ["--notes", vaultPath, "--limit", "6", query], 30_000);
608
+ // Phase 10: prefer a curated digest (last session digest + active task + dossier
609
+ // current-states) over a broad whole-vault semantic sweep. Fewer tokens, more
610
+ // signal. Only fall back to the seeded search when there's nothing to page in.
611
+ const digestBlock = buildSessionStartDigest(projectRoot, vaultPath);
612
+ let searchBlock = "";
613
+ if (!digestBlock) {
614
+ const query = sessionSeedQuery(projectRoot);
615
+ debug(`sessionstart seed query: "${query}"`);
616
+ const hits = runSearchCli(cliBin, ["--notes", vaultPath, "--limit", "6", query], 30_000);
617
+ searchBlock = hits && hits.length > 0 ? formatContextBlock("sessionstart", query, hits) : "";
618
+ } else {
619
+ debug("sessionstart: paging in curated digest (skipping broad search)");
620
+ }
373
621
 
374
622
  // State-delta preload — 14-day window per Phase 4.5 design.
375
623
  const sinceMs = Date.now() - 14 * 24 * 60 * 60 * 1000;
376
624
  const sinceIso = new Date(sinceMs).toISOString();
377
625
  const recentEntries = queryRecentLogViaCli(cliBin, vaultPath, sinceIso, 30);
378
626
 
379
- const searchBlock = hits && hits.length > 0 ? formatContextBlock("sessionstart", query, hits) : "";
380
627
  const stateBlock = formatStateDeltaBlock(sinceIso, recentEntries);
381
628
 
382
629
  // Phase 8 (drift detection): inline fast-tier check. Hard requirements: <100ms total,
@@ -387,7 +634,9 @@ async function handleSessionStart(projectRoot, vaultPath, cliBin) {
387
634
  return "";
388
635
  });
389
636
 
390
- const parts = [driftBlock, stateBlock, searchBlock].filter(Boolean);
637
+ const profileBlock = readSpeakerProfileBlock(vaultPath);
638
+
639
+ const parts = [driftBlock, profileBlock, stateBlock, digestBlock, searchBlock].filter(Boolean);
391
640
  emit("SessionStart", parts.join("\n\n"));
392
641
  }
393
642
 
@@ -572,6 +821,7 @@ async function handleStop(projectRoot) {
572
821
  } else {
573
822
  lines.push(`Before ending: either run \`mcp__semantic-vault__session_run\` with at least one verification command (tests/lint), or call \`mcp__semantic-vault__session_finish\` with \`verified: false\` and a \`reason\` waiving verification (e.g. doc-only edits). session_finish refuses without one of these paths.`);
574
823
  }
824
+ lines.push(formatSessionDigestPrompt(session));
575
825
  lines.push(`</vault-session-close>`);
576
826
  resetCapturePending(projectRoot);
577
827
  emitStop(lines.join("\n"), true);
@@ -627,6 +877,7 @@ async function handleStop(projectRoot) {
627
877
  }
628
878
  lines.push("");
629
879
  lines.push(`Before ending: for each still-unsynthesized item above, either call \`mcp__semantic-vault__synthesize_note\` to file it with provenance, or explicitly acknowledge that it was already captured / not worth capturing.`);
880
+ lines.push(`If a cue is a speaker correction (how the user talks — "when I say X I mean Y"), update the speaker profile via \`mcp__semantic-vault__manage_profile\` (action:update_section) instead of synthesize_note.`);
630
881
  lines.push(`</vault-capture-prompt>`);
631
882
  resetCapturePending(projectRoot);
632
883
  emitStop(lines.join("\n"), true);
@@ -654,18 +905,26 @@ async function handlePrompt(projectRoot, vaultPath, cliBin, input) {
654
905
  emit("UserPromptSubmit", "");
655
906
  return;
656
907
  }
657
- const hits = runSearchCli(cliBin, ["--notes", vaultPath, "--limit", "8", prompt], 30_000);
908
+ // Tier-1 lexicon expansion: search with the alias-expanded query, but keep the
909
+ // original prompt for display in the block.
910
+ const searchQuery = expandQueryViaLexicon(projectRoot, prompt);
911
+ const hits = runSearchCli(cliBin, ["--notes", vaultPath, "--limit", "8", searchQuery], 30_000);
658
912
  state.recent = [fp, ...recent].slice(0, 10);
659
913
  saveFingerprints(projectRoot, state);
660
914
  const cue = detectCaptureCue(prompt);
661
915
  if (cue) {
662
916
  addCapturePending(projectRoot, prompt, cue);
663
917
  }
918
+ // Two-hop: if the utterance names a tracked entity, lead with its dossier head.
919
+ const dossier = resolveDossierForPrompt(projectRoot, prompt);
920
+ const dossierHead = dossier ? formatDossierHead(dossier) : "";
664
921
  if (!hits || hits.length === 0) {
665
- emit("UserPromptSubmit", "");
922
+ // Even with no semantic hits, a resolved dossier is worth injecting.
923
+ emit("UserPromptSubmit", dossierHead);
666
924
  return;
667
925
  }
668
- const block = formatContextBlock("prompt", prompt.slice(0, 120), hits);
926
+ const searchBlock = formatContextBlock("prompt", prompt.slice(0, 120), hits);
927
+ const block = [dossierHead, searchBlock].filter(Boolean).join("\n");
669
928
  emit("UserPromptSubmit", block);
670
929
  }
671
930
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theglitchking/semantic-memory",
3
- "version": "1.2.2",
3
+ "version": "1.5.0",
4
4
  "description": "Unified memory-layer plugin for Claude Code. One MCP server, multiple typed corpora (vault, code, plans, docs, research, project-map), unified cross-corpus knowledge graph, conditional tool registration, in-process drift detection, and human↔code translation. Rebrand of @theglitchking/semantic-sidekick at v1.0 — see CHANGELOG for migration.",
5
5
  "type": "module",
6
6
  "main": "./dist/core/index.js",
@@ -36,6 +36,7 @@
36
36
  "dev": "tsup --watch",
37
37
  "test": "vitest run",
38
38
  "test:watch": "vitest",
39
+ "eval": "vitest run test/retrieval-eval",
39
40
  "lint": "tsc --noEmit",
40
41
  "prepublishOnly": "npm run build",
41
42
  "postinstall": "node scripts/link-skills.js"
@@ -65,6 +65,24 @@ Do not silently pivot to web search, do not guess from training data, and do not
65
65
 
66
66
  If you had to answer from outside the vault (web search, training knowledge, the user telling you something new), offer once to file a note: *"Worth capturing this in the vault as `gotchas/foo.md`?"* — then let the user decide. This is the ingest loop's entry point; don't force it, but don't skip it.
67
67
 
68
+ ### 5. Propose an alias when you resolve the user's phrasing to a concrete artifact (v1.4 lexicon)
69
+
70
+ When the user refers to something in *their own words* — "the flaky thing", "the deploy dance", "that indexer race" — and you resolve it to a concrete artifact (a note path or code symbol), that resolution is worth keeping so the next occurrence routes in one hop. **Propose, don't auto-add** (a wrong alias silently misroutes every future query with that phrase):
71
+
72
+ - Offer once: *"Want me to remember that \"the flaky thing\" → `src/core/indexer.ts`? (manage_lexicon add)"*
73
+ - On confirmation, call `manage_lexicon({action:"add", canonical:"src/core/indexer.ts", phrases:["the flaky thing"]})`. Repeat observations bump `evidence_count` automatically.
74
+ - Only propose when you actually resolved a *vague* reference to a *specific* artifact this turn — not for literal names the user already spelled out.
75
+ - Scan the user's own prose only; never mine phrases from pasted tool output or quoted machinery.
76
+ - If `lint_vault({checks:["alias_conflicts"]})` flags a phrase mapping to two targets, surface it and ask the user which one wins — don't guess.
77
+
78
+ ### 6. Rewrite a terse utterance before deciding the vault has nothing (v1.4, Tier 2)
79
+
80
+ The UserPromptSubmit hook already expands your query through the learned lexicon (Tier 1, deterministic). But when the utterance is terse or indexical — "it's doing the thing again", "did that work?", "is it fixed?" — the injected hits may still be weak because a two-word query has little to match expository note prose. Before concluding "not in vault":
81
+
82
+ - Rewrite the utterance using the current conversation's subject (what "it"/"that"/"the thing" refers to) into a concrete query, then re-search once via `search_hybrid`.
83
+ - Cap it at one rewrite round — don't loop searching for an exact match that doesn't exist (that's the bounded-rounds lesson).
84
+ - If the rewrite resolves the referent to a concrete artifact, that's a §5 alias-capture opportunity — offer it.
85
+
68
86
  ## Boundary conditions — when vault-first does NOT fire
69
87
 
70
88
  - **Code-symbol lookups.** "Where is `parseAuth` defined?" → `Grep`. "Show me the `User` model." → `Glob`. If the answer is a file + line, the vault is the wrong tool.
@@ -1,88 +0,0 @@
1
- import {
2
- loadSchema,
3
- validateNote
4
- } from "./chunk-E2Z4J4MK.js";
5
-
6
- // src/core/lint.ts
7
- import { readFile } from "fs/promises";
8
- import { basename, join } from "path";
9
- import { glob } from "glob";
10
- import matter from "gray-matter";
11
- async function lintVault(notesPath, opts = {}) {
12
- const schema = opts.schemaOverride ?? await loadSchema(notesPath);
13
- const files = await glob(opts.pathGlob ?? "**/*.md", {
14
- cwd: notesPath,
15
- ignore: ["node_modules/**", ".semantic-sidekick-index/**", "vault.schema.yml"]
16
- });
17
- const findings = [];
18
- const knownNames = /* @__PURE__ */ new Set();
19
- const rawByPath = /* @__PURE__ */ new Map();
20
- for (const rel of files) {
21
- try {
22
- const raw = await readFile(join(notesPath, rel), "utf-8");
23
- rawByPath.set(rel, raw);
24
- knownNames.add(basename(rel).replace(/\.md$/, ""));
25
- } catch {
26
- }
27
- }
28
- for (const [rel, raw] of rawByPath) {
29
- findings.push(...validateNote(rel, raw, schema, { todayIso: opts.todayIso }));
30
- findings.push(...findBrokenLinks(rel, raw, knownNames));
31
- }
32
- const byRule = {
33
- schema_violations: findings.filter((f) => f.rule === "schema_violations"),
34
- missing_provenance: findings.filter((f) => f.rule === "missing_provenance"),
35
- stale: findings.filter((f) => f.rule === "stale"),
36
- broken_links: findings.filter((f) => f.rule === "broken_links")
37
- };
38
- const errors = findings.filter((f) => f.severity === "error").length;
39
- const warnings = findings.filter((f) => f.severity === "warn").length;
40
- return {
41
- schemaPath: join(notesPath, "vault.schema.yml"),
42
- findings,
43
- byRule,
44
- counts: { errors, warnings, filesLinted: files.length }
45
- };
46
- }
47
- function findBrokenLinks(relPath, rawFileContent, knownNames) {
48
- const { content } = matter(rawFileContent);
49
- const findings = [];
50
- const stripped = content.replace(/```[\s\S]*?```/g, "").replace(/`[^`]+`/g, "");
51
- const re = /\[\[([^\]\|]+)(?:\|[^\]]+)?\]\]/g;
52
- const seen = /* @__PURE__ */ new Set();
53
- let m;
54
- while ((m = re.exec(stripped)) !== null) {
55
- const target = m[1].trim().split("#")[0].split("/").pop().replace(/\.md$/, "");
56
- if (seen.has(target)) continue;
57
- seen.add(target);
58
- if (!knownNames.has(target)) {
59
- findings.push({
60
- path: relPath,
61
- rule: "broken_links",
62
- severity: "warn",
63
- message: `wikilink [[${target}]] has no matching note`
64
- });
65
- }
66
- }
67
- return findings;
68
- }
69
- function formatLintReport(report, opts = {}) {
70
- const target = opts.rule ? report.byRule[opts.rule] : report.findings;
71
- if (target.length === 0) {
72
- return `clean: 0 findings across ${report.counts.filesLinted} files`;
73
- }
74
- const lines = [
75
- `${target.length} finding${target.length === 1 ? "" : "s"} (errors=${report.counts.errors}, warnings=${report.counts.warnings}, files=${report.counts.filesLinted})`,
76
- ""
77
- ];
78
- for (const f of target) {
79
- lines.push(`[${f.severity}] ${f.rule}: ${f.path} \u2014 ${f.message}`);
80
- }
81
- return lines.join("\n");
82
- }
83
-
84
- export {
85
- lintVault,
86
- formatLintReport
87
- };
88
- //# sourceMappingURL=chunk-CAN2GWUG.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/core/lint.ts"],"sourcesContent":["import { readFile } from \"node:fs/promises\";\nimport { basename, join } from \"node:path\";\nimport { glob } from \"glob\";\nimport matter from \"gray-matter\";\nimport { loadSchema, validateNote, type LintFinding, type VaultSchema } from \"./schema.js\";\n\nexport interface LintReport {\n schemaPath: string;\n findings: LintFinding[];\n byRule: {\n schema_violations: LintFinding[];\n missing_provenance: LintFinding[];\n stale: LintFinding[];\n broken_links: LintFinding[];\n };\n counts: {\n errors: number;\n warnings: number;\n filesLinted: number;\n };\n}\n\nexport async function lintVault(\n notesPath: string,\n opts: { pathGlob?: string; schemaOverride?: VaultSchema; todayIso?: string } = {}\n): Promise<LintReport> {\n const schema = opts.schemaOverride ?? (await loadSchema(notesPath));\n const files = await glob(opts.pathGlob ?? \"**/*.md\", {\n cwd: notesPath,\n ignore: [\"node_modules/**\", \".semantic-sidekick-index/**\", \"vault.schema.yml\"],\n });\n\n const findings: LintFinding[] = [];\n // Build known-note set first, to make broken-link detection O(1) per link.\n const knownNames = new Set<string>();\n const rawByPath = new Map<string, string>();\n for (const rel of files) {\n try {\n const raw = await readFile(join(notesPath, rel), \"utf-8\");\n rawByPath.set(rel, raw);\n // Wikilink names match on basename-without-ext (obsidian-style)\n knownNames.add(basename(rel).replace(/\\.md$/, \"\"));\n } catch {\n /* skip */\n }\n }\n\n for (const [rel, raw] of rawByPath) {\n findings.push(...validateNote(rel, raw, schema, { todayIso: opts.todayIso }));\n findings.push(...findBrokenLinks(rel, raw, knownNames));\n }\n\n const byRule = {\n schema_violations: findings.filter((f) => f.rule === \"schema_violations\"),\n missing_provenance: findings.filter((f) => f.rule === \"missing_provenance\"),\n stale: findings.filter((f) => f.rule === \"stale\"),\n broken_links: findings.filter((f) => f.rule === \"broken_links\"),\n };\n const errors = findings.filter((f) => f.severity === \"error\").length;\n const warnings = findings.filter((f) => f.severity === \"warn\").length;\n\n return {\n schemaPath: join(notesPath, \"vault.schema.yml\"),\n findings,\n byRule,\n counts: { errors, warnings, filesLinted: files.length },\n };\n}\n\nfunction findBrokenLinks(\n relPath: string,\n rawFileContent: string,\n knownNames: Set<string>\n): LintFinding[] {\n const { content } = matter(rawFileContent);\n const findings: LintFinding[] = [];\n // Match [[target]] and [[target|alias]] — skip fenced code and inline code.\n const stripped = content\n .replace(/```[\\s\\S]*?```/g, \"\")\n .replace(/`[^`]+`/g, \"\");\n const re = /\\[\\[([^\\]\\|]+)(?:\\|[^\\]]+)?\\]\\]/g;\n const seen = new Set<string>();\n let m: RegExpExecArray | null;\n while ((m = re.exec(stripped)) !== null) {\n const target = m[1].trim().split(\"#\")[0].split(\"/\").pop()!.replace(/\\.md$/, \"\");\n if (seen.has(target)) continue;\n seen.add(target);\n if (!knownNames.has(target)) {\n findings.push({\n path: relPath,\n rule: \"broken_links\",\n severity: \"warn\",\n message: `wikilink [[${target}]] has no matching note`,\n });\n }\n }\n return findings;\n}\n\nexport function formatLintReport(report: LintReport, opts: { rule?: keyof LintReport[\"byRule\"] } = {}): string {\n const target = opts.rule ? report.byRule[opts.rule] : report.findings;\n if (target.length === 0) {\n return `clean: 0 findings across ${report.counts.filesLinted} files`;\n }\n const lines = [\n `${target.length} finding${target.length === 1 ? \"\" : \"s\"} (errors=${report.counts.errors}, warnings=${report.counts.warnings}, files=${report.counts.filesLinted})`,\n \"\",\n ];\n for (const f of target) {\n lines.push(`[${f.severity}] ${f.rule}: ${f.path} — ${f.message}`);\n }\n return lines.join(\"\\n\");\n}\n"],"mappings":"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","names":[]}