sessionmem 1.3.0 → 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 (68) hide show
  1. package/.github/sessionmem-logo.svg +57 -0
  2. package/CHANGELOG.md +128 -0
  3. package/CONTRIBUTING.md +130 -0
  4. package/README.md +292 -108
  5. package/SECURITY.md +33 -0
  6. package/dist/adapters/diagnostics.js +109 -0
  7. package/dist/adapters/generic.js +6 -2
  8. package/dist/adapters/global/antigravity.js +4 -1
  9. package/dist/adapters/global/claudeCode.js +19 -4
  10. package/dist/adapters/global/codex.js +7 -6
  11. package/dist/adapters/global/qcoder.js +4 -1
  12. package/dist/adapters/ide/cline.js +3 -0
  13. package/dist/adapters/ide/cursor.js +3 -0
  14. package/dist/adapters/ide/installer.js +17 -5
  15. package/dist/adapters/ide/windsurf.js +3 -0
  16. package/dist/adapters/installedHosts.js +51 -0
  17. package/dist/cli/commands/dedupe.js +1 -1
  18. package/dist/cli/commands/doctor.js +146 -0
  19. package/dist/cli/commands/entities.js +27 -0
  20. package/dist/cli/commands/history.js +23 -0
  21. package/dist/cli/commands/import.js +15 -44
  22. package/dist/cli/commands/ingestHook.js +4 -78
  23. package/dist/cli/commands/install.js +37 -59
  24. package/dist/cli/commands/list.js +3 -0
  25. package/dist/cli/commands/run.js +10 -12
  26. package/dist/cli/commands/scan.js +16 -0
  27. package/dist/cli/commands/search.js +9 -4
  28. package/dist/cli/commands/team.js +7 -3
  29. package/dist/cli/commands/ui.js +67 -0
  30. package/dist/cli/commands/uninstall.js +2 -1
  31. package/dist/cli/commands/watch.js +48 -0
  32. package/dist/cli/context.js +8 -6
  33. package/dist/cli/index.js +48 -7
  34. package/dist/cli/projectId.js +1 -2
  35. package/dist/cli/transcriptSweep.js +182 -0
  36. package/dist/core/api/contracts.js +1 -0
  37. package/dist/core/api/memoryCoreService.js +8 -0
  38. package/dist/core/api/sessionLifecycleService.js +13 -1
  39. package/dist/core/capture/toolEvent.js +82 -0
  40. package/dist/core/config/policyConfig.js +5 -1
  41. package/dist/core/extract/factClaims.js +97 -0
  42. package/dist/core/extract/knowledge.js +127 -0
  43. package/dist/core/injection/tokenBudget.js +10 -2
  44. package/dist/core/retrieve/retrieveMemories.js +20 -4
  45. package/dist/core/schema/migrations/012_transcript_checkpoints.sql +4 -0
  46. package/dist/core/schema/migrations/013_fts_porter.sql +32 -0
  47. package/dist/core/schema/migrations/014_memory_entities.sql +19 -0
  48. package/dist/core/schema/migrations/015_memory_supersessions.sql +41 -0
  49. package/dist/core/schema/runMigrations.js +55 -5
  50. package/dist/core/storage/db.js +31 -9
  51. package/dist/core/storage/knowledgeRepo.js +67 -0
  52. package/dist/core/storage/memoryRepo.js +45 -12
  53. package/dist/core/storage/memorySearchRepo.js +30 -20
  54. package/dist/core/storage/supersessionRepo.js +101 -0
  55. package/dist/core/summarize/cloudSummarizer.js +3 -8
  56. package/dist/core/summarize/summaryShape.js +1 -1
  57. package/dist/ui/assets.js +181 -0
  58. package/dist/ui/server.js +147 -0
  59. package/docs/REGISTRY_LISTING.md +88 -0
  60. package/docs/architecture.md +77 -0
  61. package/docs/benchmark.md +122 -0
  62. package/docs/cloud-summarization.md +57 -0
  63. package/docs/migration.md +44 -0
  64. package/docs/privacy-and-retention.md +139 -0
  65. package/docs/team-mode.md +117 -0
  66. package/docs/troubleshooting.md +126 -0
  67. package/docs/typescript-upgrade.md +48 -0
  68. package/package.json +25 -13
@@ -27,6 +27,7 @@ function getSearchStatements(db) {
27
27
  embedding, embedding_dim, embedding_version
28
28
  FROM memories
29
29
  WHERE project_id = @projectId
30
+ AND (@includeHistory = 1 OR id NOT IN (SELECT memory_id FROM superseded_memories))
30
31
  AND (expires_at IS NULL OR expires_at > @now)
31
32
  AND (
32
33
  importance >= 8
@@ -38,13 +39,15 @@ function getSearchStatements(db) {
38
39
  m.id, m.project_id, m.session_id, m.source_adapter, m.kind, m.content,
39
40
  m.normalized_content, m.importance, m.author, m.origin_project_id,
40
41
  m.access_count, m.created_at, m.updated_at,
41
- m.embedding, m.embedding_dim, m.embedding_version
42
+ m.embedding, m.embedding_dim, m.embedding_version,
43
+ -bm25(memories_fts) AS lexical_score
42
44
  FROM memories_fts
43
45
  JOIN memories m ON m.rowid = memories_fts.rowid
44
46
  WHERE memories_fts MATCH @match
45
47
  AND m.project_id = @projectId
48
+ AND (@includeHistory = 1 OR m.id NOT IN (SELECT memory_id FROM superseded_memories))
46
49
  AND (m.expires_at IS NULL OR m.expires_at > @now)
47
- ORDER BY rank
50
+ ORDER BY rank, m.id
48
51
  LIMIT @limit
49
52
  `),
50
53
  };
@@ -83,34 +86,40 @@ function mapRows(rows) {
83
86
  const versionMatch = row.embedding_version === EMBEDDING_VERSION;
84
87
  return {
85
88
  ...row,
89
+ lexical_score: row.lexical_score ?? 0,
86
90
  embedding: versionMatch ? parsed : null,
87
91
  };
88
92
  });
89
93
  }
90
- export function searchMemoryCandidates(db, projectId, now = new Date()) {
94
+ export function searchMemoryCandidates(db, projectId, now = new Date(), includeHistory = false) {
91
95
  const rows = getSearchStatements(db).searchCandidates.all({
92
96
  projectId,
97
+ includeHistory: Number(includeHistory),
93
98
  now: toStorageTimestamp(now),
94
99
  recencyCutoff: toStorageTimestamp(new Date(now.getTime() - FALLBACK_RECENCY_DAYS * MS_PER_DAY)),
95
100
  });
96
101
  return mapRows(rows);
97
102
  }
98
- /**
99
- * Sanitize query text for FTS5 MATCH syntax.
100
- * Wraps each non-empty token in double quotes so special characters
101
- * (colons, hyphens, parentheses, etc.) are treated as literals.
102
- * Tokens are joined with implicit AND.
103
- */
103
+ const QUERY_STOP_WORDS = new Set(("a an and are as at be been being but by can could did do does doing for from " +
104
+ "had has have he her hers herself him himself his how i if in into is it its itself " +
105
+ "me my myself of on or our ours ourselves she should so than that the their theirs " +
106
+ "them themselves then there these they this those to too us was we were what when " +
107
+ "where which who whom why will with would you your yours yourself yourselves " +
108
+ "s t don doesn didn isn wasn weren won wouldn couldn shouldn").split(" "));
109
+ /** Match any meaningful query term; question grammar must not exclude evidence. */
104
110
  function sanitizeFtsQuery(queryText) {
105
- return queryText
106
- .split(/\s+/)
107
- .filter((token) => token.length > 0)
108
- .map((token) => `"${token.replace(/"/g, '""')}"`)
109
- .join(" ");
111
+ const tokens = [...new Set(queryText.normalize("NFKC").toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [])];
112
+ const terms = tokens.filter((token) => !QUERY_STOP_WORDS.has(token));
113
+ // Preserve literal searches consisting entirely of stop words. Tokens contain
114
+ // only letters/numbers, so FTS operators and punctuation cannot become syntax.
115
+ return (terms.length ? terms : tokens)
116
+ .slice(0, 64)
117
+ .map((token) => `"${token}"`)
118
+ .join(" OR ");
110
119
  }
111
120
  /**
112
- * Pre-filter candidates using FTS5 full-text search before cosine similarity.
113
- * Returns up to FTS_CANDIDATE_LIMIT candidates.
121
+ * Retrieve BM25-ranked lexical candidates, including stored summaries.
122
+ * Returns up to FTS_CANDIDATE_LIMIT matches with deterministic ID tie breaks.
114
123
  *
115
124
  * FTS keyword overlap can be sparse, so the fallback recency/importance scan is
116
125
  * UNIONed with (never substituted for) the FTS hits:
@@ -124,28 +133,29 @@ function sanitizeFtsQuery(queryText) {
124
133
  * (<8) — exactly the rows the fallback's filter excludes — returning zero
125
134
  * candidates for a query that matched only such rows.
126
135
  */
127
- export function searchMemoryCandidatesFTS(db, projectId, queryText, now = new Date()) {
136
+ export function searchMemoryCandidatesFTS(db, projectId, queryText, now = new Date(), includeHistory = false) {
128
137
  const sanitized = sanitizeFtsQuery(queryText);
129
138
  if (!sanitized) {
130
- return searchMemoryCandidates(db, projectId, now);
139
+ return searchMemoryCandidates(db, projectId, now, includeHistory);
131
140
  }
132
141
  let rows;
133
142
  try {
134
143
  rows = getSearchStatements(db).searchCandidatesFTS.all({
135
144
  match: sanitized,
136
145
  projectId,
146
+ includeHistory: Number(includeHistory),
137
147
  now: toStorageTimestamp(now),
138
148
  limit: FTS_CANDIDATE_LIMIT,
139
149
  });
140
150
  }
141
151
  catch {
142
152
  // FTS5 MATCH can throw on malformed queries — fall back to full scan
143
- return searchMemoryCandidates(db, projectId, now);
153
+ return searchMemoryCandidates(db, projectId, now, includeHistory);
144
154
  }
145
155
  if (rows.length >= FTS_FALLBACK_THRESHOLD) {
146
156
  return dedupById(mapRows(rows));
147
157
  }
148
- const fallback = searchMemoryCandidates(db, projectId, now);
158
+ const fallback = searchMemoryCandidates(db, projectId, now, includeHistory);
149
159
  if (rows.length === 0) {
150
160
  return fallback;
151
161
  }
@@ -0,0 +1,101 @@
1
+ import { claimKey, claimSlot, extractFactClaims, supersessionReason } from "../extract/factClaims.js";
2
+ const toClaim = (row) => ({ source: { kind: row.source_kind, name: row.source_name },
3
+ type: row.relation, target: { kind: row.target_kind, name: row.target_name }, context: row.context,
4
+ attribute: row.attribute, negative: !!row.negative, explicitChange: !!row.explicit_change, replaces: row.replaces_value });
5
+ function prepareStatements(db) {
6
+ return {
7
+ memory: db.prepare("SELECT project_id, created_at FROM memories WHERE id = ?"),
8
+ projectGroups: db.prepare(`SELECT DISTINCT c.source_kind, c.source_name, c.relation FROM memory_claims c
9
+ JOIN memories m ON m.id = c.memory_id WHERE m.project_id = ?`),
10
+ previous: db.prepare("SELECT * FROM memory_claims WHERE memory_id = ?"),
11
+ clear: db.prepare("DELETE FROM memory_claims WHERE memory_id = ? AND claim_key NOT IN (SELECT value FROM json_each(?))"),
12
+ insert: db.prepare(`INSERT INTO memory_claims
13
+ (memory_id, claim_key, source_kind, source_name, relation, target_kind, target_name, context, attribute, negative, explicit_change, replaces_value, observed_at)
14
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
15
+ ON CONFLICT(memory_id, claim_key) DO UPDATE SET negative=excluded.negative,
16
+ explicit_change=excluded.explicit_change, replaces_value=excluded.replaces_value, observed_at=excluded.observed_at`),
17
+ select: db.prepare(`SELECT c.*, m.expires_at FROM memory_claims c JOIN memories m ON m.id = c.memory_id
18
+ WHERE m.project_id = ? AND c.source_kind = ? AND c.source_name = ? AND c.relation = ?
19
+ ORDER BY c.observed_at, m.rowid, c.claim_key`),
20
+ remove: db.prepare("DELETE FROM memory_supersessions WHERE supersedes = ? AND claim_key = ?"),
21
+ link: db.prepare(`INSERT INTO memory_supersessions (supersedes, claim_key, superseded_by, new_claim_key, superseded_at, reason)
22
+ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(supersedes, claim_key) DO UPDATE SET
23
+ superseded_by=excluded.superseded_by, new_claim_key=excluded.new_claim_key, superseded_at=excluded.superseded_at,
24
+ reason=excluded.reason, detected_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
25
+ WHERE superseded_by <> excluded.superseded_by OR new_claim_key <> excluded.new_claim_key OR superseded_at <> excluded.superseded_at OR reason <> excluded.reason`),
26
+ };
27
+ }
28
+ const cache = new WeakMap();
29
+ function statements(db) {
30
+ let stmts = cache.get(db);
31
+ if (!stmts) {
32
+ stmts = prepareStatements(db);
33
+ cache.set(db, stmts);
34
+ }
35
+ return stmts;
36
+ }
37
+ /** Reconcile affected entity/relation groups, including out-of-order imports and rewrites. */
38
+ export function indexMemorySupersessions(db, memoryId, content, sources) {
39
+ const stmts = statements(db);
40
+ const memory = stmts.memory.get(memoryId);
41
+ const previous = stmts.previous.all(memoryId);
42
+ const finalClaims = new Map();
43
+ for (const source of sources ?? [{ text: content, observedAt: memory.created_at }]) {
44
+ for (const claim of extractFactClaims(source.text))
45
+ finalClaims.set(claimSlot(claim), { ...claim, observedAt: source.observedAt });
46
+ }
47
+ const claims = [...finalClaims.values()];
48
+ const groups = new Map();
49
+ for (const row of previous)
50
+ groups.set(JSON.stringify([row.source_kind, row.source_name, row.relation]), [row.source_kind, row.source_name, row.relation]);
51
+ const keys = claims.map(claimKey);
52
+ // Preserve identical claims and links on replays (including detected_at).
53
+ stmts.clear.run(memoryId, JSON.stringify(keys));
54
+ for (const claim of claims) {
55
+ const group = [claim.source.kind, claim.source.name, claim.type];
56
+ groups.set(JSON.stringify(group), group);
57
+ stmts.insert.run(memoryId, claimKey(claim), ...group, claim.target.kind, claim.target.name, claim.context, claim.attribute, Number(claim.negative), Number(claim.explicitChange), claim.replaces, claim.observedAt);
58
+ }
59
+ reconcileGroups(db, memory.project_id, [...groups.values()]);
60
+ }
61
+ /** Deleting an intermediate replacement must not revive an older contradicted fact. */
62
+ export function reconcileProjectSupersessions(db, projectId) {
63
+ const groups = statements(db).projectGroups.all(projectId);
64
+ reconcileGroups(db, projectId, groups.map(row => [row.source_kind, row.source_name, row.relation]));
65
+ }
66
+ function reconcileGroups(db, projectId, groups) {
67
+ const stmts = statements(db);
68
+ for (const group of groups) {
69
+ const rows = stmts.select.all(projectId, ...group);
70
+ for (let i = 0; i < rows.length; i++) {
71
+ const older = rows[i];
72
+ let found = false;
73
+ for (let j = i + 1; j < rows.length; j++) {
74
+ const newer = rows[j];
75
+ if (older.memory_id === newer.memory_id || (older.expires_at && older.expires_at <= newer.observed_at)
76
+ || (newer.expires_at && newer.expires_at <= newer.observed_at))
77
+ continue;
78
+ const reason = supersessionReason(toClaim(older), toClaim(newer));
79
+ if (!reason)
80
+ continue;
81
+ stmts.link.run(older.memory_id, older.claim_key, newer.memory_id, newer.claim_key, newer.observed_at, reason);
82
+ found = true;
83
+ break;
84
+ }
85
+ if (!found)
86
+ stmts.remove.run(older.memory_id, older.claim_key);
87
+ }
88
+ }
89
+ }
90
+ export function listSupersessions(db, projectId) {
91
+ return db.prepare(`SELECT s.supersedes, s.superseded_by AS supersededBy, s.superseded_at AS supersededAt,
92
+ s.detected_at AS detectedAt, s.reason, c.source_name AS entity, c.relation,
93
+ CASE WHEN c.negative THEN 'not ' ELSE '' END || c.target_name AS oldValue,
94
+ CASE WHEN n.negative THEN 'not ' ELSE '' END || n.target_name AS newValue,
95
+ EXISTS (SELECT 1 FROM superseded_memories f WHERE f.memory_id = s.supersedes) AS fullySuperseded
96
+ FROM memory_supersessions s JOIN memories m ON m.id = s.supersedes
97
+ JOIN memory_claims c ON c.memory_id = s.supersedes AND c.claim_key = s.claim_key
98
+ JOIN memory_claims n ON n.memory_id = s.superseded_by AND n.claim_key = s.new_claim_key
99
+ WHERE m.project_id = ? ORDER BY s.superseded_at, s.supersedes, s.superseded_by, s.claim_key`)
100
+ .all(projectId).map(row => ({ ...row, fullySuperseded: !!row.fullySuperseded }));
101
+ }
@@ -1,4 +1,5 @@
1
1
  import Anthropic from "@anthropic-ai/sdk";
2
+ import { capTokens } from "../injection/tokenBudget.js";
2
3
  import { summarizeLocalSessionEvents } from "./localSummarizer.js";
3
4
  import { DEFAULT_SUMMARIZER_MODEL } from "../config/policyConfig.js";
4
5
  const CLOUD_SYSTEM_PROMPT = "You are a memory compressor for AI coding sessions. Given this session transcript summary, produce a compact, high-signal list of facts, decisions, and context that should be remembered. Be extremely concise.";
@@ -26,7 +27,7 @@ export async function summarizeWithCloud(input) {
26
27
  messages: [{ role: "user", content: localResult.summary }],
27
28
  });
28
29
  // Extract text from response content blocks
29
- let text = response.content
30
+ const text = response.content
30
31
  .filter((block) => block.type === "text")
31
32
  .map((block) => block.text)
32
33
  .join("\n");
@@ -35,14 +36,8 @@ export async function summarizeWithCloud(input) {
35
36
  if (!text || text.trim().length === 0) {
36
37
  throw new Error("Cloud summarizer returned empty response");
37
38
  }
38
- // Cap cloud output to summaryTokenCap (rough token→char ratio) for parity
39
- // with the local summarizer, so a verbose cloud response cannot blow past the
40
- // configured budget.
41
- if (input.summaryTokenCap && text.length > input.summaryTokenCap * 4) {
42
- text = text.slice(0, input.summaryTokenCap * 4);
43
- }
44
39
  return {
45
- summary: text,
40
+ summary: capTokens(text, input.summaryTokenCap),
46
41
  warningCodes: localResult.warningCodes,
47
42
  };
48
43
  }
@@ -7,7 +7,7 @@ const MAX_FACT_LINES = 8;
7
7
  * carrying a one-line description ("Ran: npm test", "Edited src/foo.ts"); fall
8
8
  * back to the raw payload for events written by other producers.
9
9
  */
10
- function extractEventText(event) {
10
+ export function extractEventText(event) {
11
11
  try {
12
12
  const parsed = JSON.parse(event.payload_json);
13
13
  if (typeof parsed.text === "string" && parsed.text.trim().length > 0) {
@@ -0,0 +1,181 @@
1
+ // Bundled as JavaScript strings so the existing tsc build and npm package include every asset.
2
+ export const HTML = `<!doctype html>
3
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
4
+ <title>sessionmem · Memory viewer</title><link rel="stylesheet" href="/style.css"><script src="/app.js" defer></script></head>
5
+ <body><header><div class="brand"><span class="mark" aria-hidden="true">s</span><div><strong>sessionmem</strong><span class="muted">Memory viewer</span></div></div><span class="badge">Local · Read-only</span></header>
6
+ <main><div class="intro"><div><p class="eyebrow">YOUR PROJECT CONTEXT</p><h1>What sessionmem knows</h1><p class="muted">Explore saved context, connections, and how facts changed.</p></div><label class="project-label">Project<select id="project" aria-label="Project"></select></label></div>
7
+ <nav aria-label="Views"><button type="button" data-view="memories" aria-pressed="true">Memories</button><button type="button" data-view="entities" aria-pressed="false">Entities &amp; relations</button><button type="button" data-view="history" aria-pressed="false">Supersession chains</button></nav>
8
+ <form id="search"><label for="query">Search memories</label><div class="search-row"><input id="query" type="search" maxlength="1000" placeholder="Search decisions, facts, warnings…"><button type="submit" class="primary">Search</button></div><label class="check"><input id="include-history" type="checkbox"> Include superseded facts in search</label></form>
9
+ <div class="section-heading"><h2 id="view-title">Memories</h2><button type="button" id="refresh">Refresh</button></div><p id="status" role="status" aria-live="polite">Loading your memory store…</p><div id="results"></div>
10
+ <div id="pagination"><button id="previous" type="button">← Previous</button><span id="page-info"></span><button id="next" type="button">Next →</button></div>
11
+ <footer>Served from this computer. Browsing and searching do not change your memories.</footer></main>
12
+ <dialog id="detail"><div class="section-heading"><h2>Memory details</h2><button id="close-detail" type="button">Close</button></div><div id="detail-content"></div></dialog></body></html>`;
13
+ export const CSS = `
14
+ :root{color-scheme:light;--ink:#202b33;--muted:#626f78;--line:#dce3e5;--accent:#176c58;--pale:#eaf4ef;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f6f8f7;color:var(--ink)}
15
+ *{box-sizing:border-box}body{margin:0;font-size:15px;line-height:1.6}header{background:#fff;border-bottom:1px solid var(--line);padding:18px max(24px,calc((100vw - 1160px)/2));display:flex;align-items:center;justify-content:space-between;gap:16px}.brand{display:flex;align-items:center;gap:12px}.brand strong{font-size:20px;letter-spacing:-.7px}.brand .muted{margin-left:14px;font-size:13px}.mark{display:grid;place-items:center;background:var(--accent);color:#fff;border-radius:10px;width:36px;height:36px;font:bold 28px Georgia,serif}.badge{background:var(--pale);color:#215e4d;border:1px solid #cae2d5;border-radius:20px;padding:4px 12px;font-size:12px;white-space:nowrap}main{max-width:1160px;margin:auto;padding:36px 24px}.intro{display:flex;justify-content:space-between;align-items:center;gap:24px;margin-bottom:28px}h1{font-size:32px;line-height:1.2;letter-spacing:-1.1px;margin:4px 0 12px}h2{font-size:19px;margin:0}h3{font-size:16px;margin:0 0 14px}.eyebrow{font-size:11px;font-weight:700;letter-spacing:1.6px;color:var(--accent);margin:0}.muted{color:var(--muted)}p{margin:6px 0}.project-label{font-size:12px;font-weight:600;display:grid;gap:6px;min-width:240px;max-width:40%}select,input[type=search]{background:#fff;border:1px solid #b8c5c7;border-radius:7px;padding:10px 12px;color:var(--ink);font:inherit;width:100%}nav{display:flex;gap:8px;border-bottom:1px solid var(--line);padding-bottom:12px;margin-bottom:24px;flex-wrap:wrap}button{font:inherit;cursor:pointer;color:var(--ink);background:#fff;border:1px solid var(--line);border-radius:7px;padding:7px 13px}button:hover{border-color:var(--accent);background:#f1f7f4}button[aria-pressed=true],.primary{background:var(--accent);border-color:var(--accent);color:#fff}button:disabled{opacity:.45;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #73b6a1;outline-offset:3px}#search>label:first-child{font-size:13px;font-weight:600}.search-row{display:flex;gap:10px;margin:7px 0 10px}.check{display:flex;align-items:center;gap:7px;font-size:13px;color:var(--muted)}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:26px 0 12px}#status{font-size:13px;color:var(--muted);margin-bottom:18px}#status.error{color:#a43327}#results{display:grid;gap:14px}.card{background:#fff;border:1px solid var(--line);border-radius:10px;padding:20px}.card-top{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px}.kind{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--accent);background:var(--pale);padding:2px 8px;border-radius:4px}.meta{font-size:12px;color:var(--muted)}.content{white-space:pre-wrap;overflow-wrap:anywhere;margin:10px 0 16px}.memory-link{font:12px ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--accent);padding:4px 7px;overflow-wrap:anywhere;max-width:100%;text-align:left}.source-links{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.columns{display:grid;grid-template-columns:1fr 1.4fr;gap:18px}.row{padding:14px 0;border-top:1px solid var(--line);overflow-wrap:anywhere}.row:first-child{border:0}.relation{color:var(--accent);font-size:12px;font-weight:600;margin:0 8px}.timeline{border-left:2px solid #c7ddd3;margin-left:6px;padding-left:20px}.transition{position:relative;padding:0 0 24px;overflow-wrap:anywhere}.transition:before{content:"";position:absolute;left:-26px;top:8px;width:9px;height:9px;border-radius:50%;background:var(--accent);border:1px solid #fff}.values{font-size:17px;margin:8px 0}.arrow{color:var(--accent);margin:0 10px}.empty{text-align:center;padding:48px 24px;background:#fff;border:1px dashed #c4d0cd;border-radius:10px;color:var(--muted)}#pagination{display:flex;align-items:center;justify-content:center;gap:20px;margin:24px 0}footer{text-align:center;font-size:12px;color:var(--muted);margin-top:42px}dialog{max-width:800px;width:calc(100% - 32px);max-height:85vh;border:1px solid var(--line);border-radius:12px;padding:24px;color:var(--ink)}dialog::backdrop{background:#152a2780}dialog .section-heading{margin-top:0}dl{display:grid;grid-template-columns:110px 1fr;gap:6px 12px;font-size:13px}dt{color:var(--muted)}dd{margin:0;overflow-wrap:anywhere}[hidden]{display:none!important}
16
+ @media(max-width:700px){header{padding:16px}.brand .muted{display:none}main{padding:24px 16px}.intro{align-items:stretch;flex-direction:column;gap:16px}.project-label{max-width:100%;min-width:0}.columns{grid-template-columns:1fr}h1{font-size:28px}.card{padding:16px}nav button{font-size:13px}}
17
+ `;
18
+ export const JS = String.raw `
19
+ 'use strict';
20
+ const $ = (id) => document.getElementById(id);
21
+ const state = { project: '', view: 'memories', query: '', offset: 0, generation: 0 };
22
+ const titles = { memories: 'Memories', entities: 'Entities & relations', history: 'Supersession chains' };
23
+ function el(tag, text, className) {
24
+ const node = document.createElement(tag);
25
+ if (text !== undefined) node.textContent = text;
26
+ if (className) node.className = className;
27
+ return node;
28
+ }
29
+ function timestamp(value) {
30
+ if (!value) return 'Unknown';
31
+ const date = new Date(value);
32
+ return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
33
+ }
34
+ async function api(path, params = {}) {
35
+ const url = new URL('/api/' + path, location.origin);
36
+ url.search = new URLSearchParams({ ...(state.project ? { project: state.project } : {}), ...params });
37
+ const response = await fetch(url, { cache: 'no-store' });
38
+ const result = await response.json();
39
+ if (!response.ok) throw new Error(result.error || 'Unable to load the memory store');
40
+ return result;
41
+ }
42
+ function source(id) {
43
+ const button = el('button', id, 'memory-link');
44
+ button.type = 'button';
45
+ button.title = 'Inspect source memory';
46
+ button.addEventListener('click', () => showMemory(id));
47
+ return button;
48
+ }
49
+ function sources(ids) {
50
+ const wrapper = el('div', undefined, 'source-links');
51
+ ids.forEach(id => wrapper.append(source(id)));
52
+ return wrapper;
53
+ }
54
+ async function showMemory(id) {
55
+ const project = state.project;
56
+ $('detail-content').textContent = 'Loading…';
57
+ if (!$('detail').open) $('detail').showModal();
58
+ try {
59
+ const { memory } = await api('memory', { id });
60
+ if (project !== state.project) return;
61
+ $('detail-content').replaceChildren(el('div', memory.content, 'content'));
62
+ const details = el('dl');
63
+ for (const key of ['id', 'kind', 'importance', 'author', 'sessionId', 'sourceAdapter', 'createdAt', 'updatedAt', 'expiresAt']) {
64
+ if (memory[key] != null) details.append(el('dt', key), el('dd', String(memory[key])));
65
+ }
66
+ $('detail-content').append(details);
67
+ } catch (error) { $('detail-content').textContent = error.message; }
68
+ }
69
+ function renderMemories(data) {
70
+ for (const memory of data.memories) {
71
+ const card = el('article', undefined, 'card');
72
+ const top = el('div', undefined, 'card-top');
73
+ top.append(el('span', memory.kind, 'kind'), el('span', 'Importance ' + memory.importance + '/10', 'meta'));
74
+ if (memory.score) top.append(el('span', 'Recall score ' + memory.score.total.toFixed(3), 'meta'));
75
+ card.append(top, el('div', memory.content, 'content'), source(memory.id), el('p', 'Updated ' + timestamp(memory.updatedAt) + (memory.author ? ' · ' + memory.author : ''), 'meta'));
76
+ if (memory.expiresAt) card.append(el('p', 'Expires ' + timestamp(memory.expiresAt), 'meta'));
77
+ $('results').append(card);
78
+ }
79
+ if (!data.memories.length) empty(state.query ? 'No matching memories.' : 'No memories in this project yet.');
80
+ $('status').textContent = state.query ? data.total + ' recall results · current facts by default · showing up to 50' : data.total + ' saved memories · includes superseded and expired records';
81
+ $('pagination').hidden = !!state.query || data.total === 0;
82
+ $('previous').disabled = state.offset === 0;
83
+ $('next').disabled = state.offset + 50 >= data.total;
84
+ $('page-info').textContent = (state.offset + 1) + '–' + Math.min(state.offset + 50, data.total) + ' of ' + data.total;
85
+ }
86
+ function empty(message) { $('results').append(el('div', message, 'empty')); }
87
+ function renderKnowledge(data) {
88
+ $('status').textContent = data.entities.length + ' entities · ' + data.relations.length + ' relations · source IDs open memory details';
89
+ if (!data.entities.length && !data.relations.length) { empty('No entities or relations captured for this project.'); return; }
90
+ const columns = el('div', undefined, 'columns');
91
+ for (const [title, rows] of [['Entities', data.entities], ['Relations', data.relations]]) {
92
+ const panel = el('section', undefined, 'card');
93
+ panel.append(el('h3', title));
94
+ for (const row of rows) {
95
+ const item = el('div', undefined, 'row');
96
+ if (title === 'Entities') item.append(el('span', row.kind, 'kind'), el('p', row.name));
97
+ else item.append(el('span', row.sourceName), el('span', row.relation + ' →', 'relation'), el('span', row.targetName), el('p', row.sourceKind + ' → ' + row.targetKind, 'meta'));
98
+ item.append(sources(row.memoryIds));
99
+ panel.append(item);
100
+ }
101
+ if (!rows.length) panel.append(el('p', 'None captured.', 'muted'));
102
+ columns.append(panel);
103
+ }
104
+ $('results').append(columns);
105
+ }
106
+ function renderHistory(data) {
107
+ $('status').textContent = data.links.length + ' replacement links · ordered by assertion time · source IDs open memory details';
108
+ if (!data.links.length) { empty('No supersession chains captured for this project.'); return; }
109
+ const groups = new Map();
110
+ for (const link of data.links) {
111
+ const key = JSON.stringify([link.entity, link.relation]);
112
+ if (!groups.has(key)) groups.set(key, []);
113
+ groups.get(key).push(link);
114
+ }
115
+ for (const links of groups.values()) {
116
+ const card = el('section', undefined, 'card');
117
+ card.append(el('h3', links[0].entity + ' · ' + links[0].relation));
118
+ const timeline = el('div', undefined, 'timeline');
119
+ for (const link of links) {
120
+ const item = el('article', undefined, 'transition');
121
+ item.append(el('div', timestamp(link.supersededAt), 'meta'));
122
+ const values = el('div', undefined, 'values');
123
+ values.append(el('span', link.oldValue), el('span', '→', 'arrow'), el('strong', link.newValue));
124
+ item.append(values);
125
+ const refs = el('div', undefined, 'source-links');
126
+ refs.append(source(link.supersedes), el('span', '→', 'arrow'), source(link.supersededBy));
127
+ item.append(refs, el('p', (link.fullySuperseded ? 'Fully superseded' : 'Partially superseded') + ' · ' + link.reason.replaceAll('_', ' '), 'meta'), el('p', 'Detected ' + timestamp(link.detectedAt), 'meta'));
128
+ timeline.append(item);
129
+ }
130
+ card.append(timeline);
131
+ $('results').append(card);
132
+ }
133
+ }
134
+ async function load() {
135
+ const generation = ++state.generation;
136
+ $('view-title').textContent = titles[state.view];
137
+ $('search').hidden = state.view !== 'memories';
138
+ $('pagination').hidden = true;
139
+ $('results').replaceChildren();
140
+ $('status').className = '';
141
+ $('status').textContent = 'Loading…';
142
+ try {
143
+ const data = await api(state.view === 'entities' ? 'entities' : state.view === 'history' ? 'history' : 'memories', { q: state.query, offset: String(state.offset), history: String($('include-history').checked) });
144
+ if (generation !== state.generation) return;
145
+ if (state.view === 'memories') renderMemories(data);
146
+ else if (state.view === 'entities') renderKnowledge(data);
147
+ else renderHistory(data);
148
+ } catch (error) {
149
+ if (generation !== state.generation) return;
150
+ $('status').className = 'error';
151
+ $('status').textContent = error.message;
152
+ }
153
+ }
154
+ document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', () => {
155
+ state.view = button.dataset.view;
156
+ document.querySelectorAll('[data-view]').forEach(other => other.setAttribute('aria-pressed', String(other === button)));
157
+ load();
158
+ }));
159
+ $('project').addEventListener('change', () => { state.project = $('project').value; state.offset = 0; $('detail').close(); load(); });
160
+ $('search').addEventListener('submit', event => { event.preventDefault(); state.query = $('query').value.trim(); state.offset = 0; load(); });
161
+ $('query').addEventListener('input', () => { if (!$('query').value && state.query) { state.query = ''; state.offset = 0; load(); } });
162
+ $('include-history').addEventListener('change', () => { if (state.query) load(); });
163
+ $('refresh').addEventListener('click', load);
164
+ $('previous').addEventListener('click', () => { state.offset = Math.max(0, state.offset - 50); load(); });
165
+ $('next').addEventListener('click', () => { state.offset += 50; load(); });
166
+ $('close-detail').addEventListener('click', () => $('detail').close());
167
+ (async () => {
168
+ try {
169
+ const data = await api('projects');
170
+ state.project = data.defaultProjectId;
171
+ if (!data.projects.some(project => project.id === state.project)) data.projects.unshift({ id: state.project, count: 0 });
172
+ for (const project of data.projects) {
173
+ const option = el('option', project.id + ' (' + project.count + ')');
174
+ option.value = project.id;
175
+ $('project').append(option);
176
+ }
177
+ $('project').value = state.project;
178
+ await load();
179
+ } catch (error) { $('status').className = 'error'; $('status').textContent = error.message; }
180
+ })();
181
+ `;
@@ -0,0 +1,147 @@
1
+ import { createServer } from "node:http";
2
+ import { listProjectKnowledge } from "../core/storage/knowledgeRepo.js";
3
+ import { listSupersessions } from "../core/storage/supersessionRepo.js";
4
+ import { HTML, CSS, JS } from "./assets.js";
5
+ export const DEFAULT_UI_PORT = 3777;
6
+ export function parseUiPort(value = DEFAULT_UI_PORT) {
7
+ const port = Number(value);
8
+ if (!/^\d+$/.test(String(value)) || !Number.isInteger(port) || port < 0 || port > 65535) {
9
+ throw new Error("--port must be an integer from 0 to 65535 (0 selects a free port)");
10
+ }
11
+ return port;
12
+ }
13
+ function json(response, status, value) {
14
+ response.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
15
+ response.end(JSON.stringify(value));
16
+ }
17
+ function integer(value, fallback, max) {
18
+ if (value === null)
19
+ return fallback;
20
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) > max) {
21
+ throw new Error("Invalid pagination parameter");
22
+ }
23
+ return Number(value);
24
+ }
25
+ /** Fixed read routes only: never dispatch a caller-supplied service method. */
26
+ export async function startUiServer(context, port = DEFAULT_UI_PORT) {
27
+ parseUiPort(port);
28
+ let origin = "";
29
+ const server = createServer(async (request, response) => {
30
+ response.setHeader("Cache-Control", "no-store");
31
+ response.setHeader("X-Content-Type-Options", "nosniff");
32
+ response.setHeader("Referrer-Policy", "no-referrer");
33
+ response.setHeader("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'");
34
+ if (request.method !== "GET" && request.method !== "HEAD") {
35
+ response.setHeader("Allow", "GET, HEAD");
36
+ json(response, 405, { error: "Read-only viewer: method not allowed" });
37
+ return;
38
+ }
39
+ // Block DNS rebinding and cross-origin browser reads of the unauthenticated store.
40
+ if (request.headers.host !== new URL(origin).host
41
+ || (request.headers.origin && request.headers.origin !== origin)
42
+ || request.headers["sec-fetch-site"] === "cross-site") {
43
+ json(response, 403, { error: "Local origin required" });
44
+ return;
45
+ }
46
+ try {
47
+ const url = new URL(request.url ?? "/", origin);
48
+ if (url.origin !== origin) {
49
+ json(response, 403, { error: "Local origin required" });
50
+ return;
51
+ }
52
+ const assets = {
53
+ "/": ["text/html", HTML], "/style.css": ["text/css", CSS], "/app.js": ["text/javascript", JS],
54
+ };
55
+ const asset = Object.hasOwn(assets, url.pathname) ? assets[url.pathname] : undefined;
56
+ if (asset) {
57
+ response.writeHead(200, { "Content-Type": `${asset[0]}; charset=utf-8` });
58
+ response.end(request.method === "HEAD" ? undefined : asset[1]);
59
+ return;
60
+ }
61
+ const params = url.searchParams;
62
+ const projectId = params.get("project") ?? context.projectId;
63
+ if (!projectId || projectId.length > 2000)
64
+ throw new Error("Invalid project ID");
65
+ switch (url.pathname) {
66
+ case "/api/projects": {
67
+ const projects = context.db.prepare(`SELECT project_id AS id, COUNT(*) AS count
68
+ FROM memories GROUP BY project_id ORDER BY project_id`).all();
69
+ json(response, 200, { defaultProjectId: context.projectId, projects });
70
+ break;
71
+ }
72
+ case "/api/memories": {
73
+ const query = params.get("q")?.trim();
74
+ const limit = integer(params.get("limit"), 50, 100);
75
+ if (!limit)
76
+ throw new Error("Limit must be at least 1");
77
+ if (query) {
78
+ if (query.length > 1000)
79
+ throw new Error("Query must be at most 1000 characters");
80
+ const result = await context.service.call("retrieveMemories", {
81
+ projectId, query, limit, mode: "on-demand", depth: "default",
82
+ includeHistory: params.get("history") === "true",
83
+ });
84
+ if (!result.ok) {
85
+ json(response, 400, { error: result.error.message });
86
+ break;
87
+ }
88
+ json(response, 200, { memories: result.memories, total: result.total });
89
+ }
90
+ else {
91
+ const offset = integer(params.get("offset"), 0, Number.MAX_SAFE_INTEGER);
92
+ const memories = context.db.prepare(`SELECT id, kind, content, importance, author,
93
+ created_at AS createdAt, updated_at AS updatedAt, expires_at AS expiresAt
94
+ FROM memories WHERE project_id = ? ORDER BY updated_at DESC, id LIMIT ? OFFSET ?`)
95
+ .all(projectId, limit, offset);
96
+ const { total } = context.db.prepare("SELECT COUNT(*) AS total FROM memories WHERE project_id = ?")
97
+ .get(projectId);
98
+ json(response, 200, { memories, total });
99
+ }
100
+ break;
101
+ }
102
+ case "/api/memory": {
103
+ const id = params.get("id");
104
+ if (!id || id.length > 200)
105
+ throw new Error("Invalid memory ID");
106
+ const result = await context.service.call("getMemory", { projectId, memoryId: id });
107
+ json(response, result.ok ? 200 : 404, result.ok ? { memory: result.memory } : { error: result.error.message });
108
+ break;
109
+ }
110
+ case "/api/entities":
111
+ json(response, 200, listProjectKnowledge(context.db, projectId));
112
+ break;
113
+ case "/api/history":
114
+ json(response, 200, { links: listSupersessions(context.db, projectId) });
115
+ break;
116
+ default:
117
+ json(response, 404, { error: "Not found" });
118
+ }
119
+ }
120
+ catch (error) {
121
+ // Do not send database paths or raw SQL errors to the browser.
122
+ const invalid = error instanceof Error && /^(Invalid |Limit must|Query must)/.test(error.message);
123
+ json(response, invalid ? 400 : 500, { error: invalid ? error.message : "Unable to read the memory store" });
124
+ }
125
+ });
126
+ // Bound incomplete requests and idle clients; close() also terminates open connections.
127
+ server.requestTimeout = 10000;
128
+ server.headersTimeout = 10000;
129
+ await new Promise((resolve, reject) => {
130
+ server.once("error", reject);
131
+ server.listen(port, "127.0.0.1", () => {
132
+ server.removeListener("error", reject);
133
+ const address = server.address();
134
+ if (!address || typeof address === "string")
135
+ throw new Error("Missing HTTP address");
136
+ origin = `http://127.0.0.1:${address.port}`;
137
+ resolve();
138
+ });
139
+ });
140
+ return {
141
+ server, url: `${origin}/`,
142
+ close: () => new Promise((resolve, reject) => {
143
+ server.close(error => error ? reject(error) : resolve());
144
+ server.closeAllConnections();
145
+ }),
146
+ };
147
+ }