claude-code-session-manager 0.32.0 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html CHANGED
@@ -7,7 +7,7 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
- <script type="module" crossorigin src="./assets/index-CHTpW79G.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-CogvVs0W.js"></script>
11
11
  <link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
12
12
  <link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
13
13
  <link rel="stylesheet" crossorigin href="./assets/index-DMIi9YZH.css">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.32.0",
3
+ "version": "0.32.1",
4
4
  "description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
5
5
  "type": "module",
6
6
  "main": "src/main/index.cjs",
package/src/main/kg.cjs CHANGED
@@ -40,6 +40,8 @@ const os = require('node:os');
40
40
  const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
41
41
  const { encodeCwd } = require('./lib/encodeCwd.cjs');
42
42
  const { writeJson } = require('./config.cjs');
43
+ const { pruneGraph } = require('./lib/kgPrune.cjs');
44
+ const { canonicalize, ENTITY_TYPES, liteExtract } = require('./lib/kgLite.cjs');
43
45
 
44
46
  const HOME = os.homedir();
45
47
  const KG_DIR = path.join(HOME, '.claude', 'knowledge-log');
@@ -47,6 +49,61 @@ const LOG_PATH = path.join(KG_DIR, 'prompts.jsonl');
47
49
  const GRAPHS_DIR = path.join(KG_DIR, 'graphs');
48
50
  const INGEST_STATE_PATH = path.join(KG_DIR, 'ingest-state.json');
49
51
  const PROMPT_INDEX_PATH = path.join(KG_DIR, 'prompt-index.json');
52
+ const KG_CONFIG_PATH = path.join(KG_DIR, 'kg-config.json');
53
+
54
+ const DEFAULT_MAX_GRAPH_NODES = 300;
55
+
56
+ /** Read kg-config.json; returns defaults when file is absent or malformed.
57
+ * `captureMode` is derived from the explicit field (new) or the legacy
58
+ * `extractionEnabled` boolean (old configs) for backward compatibility. */
59
+ function kgConfig() {
60
+ try {
61
+ const c = JSON.parse(fs.readFileSync(KG_CONFIG_PATH, 'utf8'));
62
+ let captureMode;
63
+ if (c.captureMode === 'lite' || c.captureMode === 'off' || c.captureMode === 'llm') {
64
+ captureMode = c.captureMode;
65
+ } else {
66
+ // Legacy: derive from extractionEnabled boolean.
67
+ captureMode = c.extractionEnabled === false ? 'off' : 'llm';
68
+ }
69
+ return {
70
+ extractionEnabled: captureMode !== 'off',
71
+ captureMode,
72
+ // 0 = cap disabled; undefined/null → default
73
+ maxGraphNodes: typeof c.maxGraphNodes === 'number' ? c.maxGraphNodes : DEFAULT_MAX_GRAPH_NODES,
74
+ };
75
+ } catch {
76
+ return { extractionEnabled: true, captureMode: 'llm', maxGraphNodes: DEFAULT_MAX_GRAPH_NODES };
77
+ }
78
+ }
79
+
80
+ /** Active capture mode — SM_KG_DISABLE=1 forces 'off' regardless of config. */
81
+ function currentCaptureMode() {
82
+ if (process.env.SM_KG_DISABLE === '1') return 'off';
83
+ return kgConfig().captureMode;
84
+ }
85
+
86
+ /** Extraction toggle — lets the user stop the recurring `claude -p` cost when
87
+ * they aren't using the graph. Default ON; env SM_KG_DISABLE=1 forces OFF. */
88
+ function extractionEnabled() {
89
+ return currentCaptureMode() !== 'off';
90
+ }
91
+
92
+ async function setExtractionEnabled(enabled) {
93
+ const current = kgConfig();
94
+ // Toggling off → 'off'; toggling on → restore prior active mode or default 'llm'.
95
+ const newMode = enabled ? (current.captureMode === 'off' ? 'llm' : current.captureMode) : 'off';
96
+ await writeJson(KG_CONFIG_PATH, { ...current, captureMode: newMode, extractionEnabled: !!enabled });
97
+ return { ok: true, extractionEnabled: !!enabled };
98
+ }
99
+
100
+ /** Set capture mode: 'llm' | 'lite' | 'off'. */
101
+ async function setCaptureMode(mode) {
102
+ if (mode !== 'llm' && mode !== 'lite' && mode !== 'off') return { ok: false, error: 'invalid mode' };
103
+ const current = kgConfig();
104
+ await writeJson(KG_CONFIG_PATH, { ...current, captureMode: mode, extractionEnabled: mode !== 'off' });
105
+ return { ok: true, captureMode: mode };
106
+ }
50
107
  const BATCH = 20; // prompts per extraction call (also a per-project cap)
51
108
  const KNOWN_VOCAB = 200; // top node names pre-seeded for dedup-at-extraction
52
109
  const MAX_TAIL_BYTES = 8 * 1024 * 1024; // bound bytes scanned per ingest run
@@ -63,7 +120,7 @@ const MAX_LOG_BYTES = 256 * 1024 * 1024;
63
120
  // lets prompts accumulate into fuller batches; the KG tab tolerates the lag.
64
121
  const WATCH_COALESCE_MS = 5 * 60_000;
65
122
 
66
- const ENTITY_TYPES = ['project', 'feature', 'tool', 'tech', 'concept', 'goal', 'person'];
123
+ // ENTITY_TYPES and canonicalize are imported from ./lib/kgLite.cjs (single source of truth).
67
124
 
68
125
  // Prompts our own `claude -p` calls send. The SM_KG_INTERNAL env guard on the
69
126
  // logging hook prevents NEW ones; these prefixes drop any already in the log.
@@ -150,9 +207,45 @@ async function loadGraphFor(cwd) {
150
207
  }
151
208
 
152
209
  async function saveGraph(g) {
210
+ const { maxGraphNodes } = kgConfig();
211
+ if (maxGraphNodes && g.nodes.length > maxGraphNodes) {
212
+ const result = pruneGraph(g, maxGraphNodes, Date.now());
213
+ g.nodes = result.nodes;
214
+ g.edges = result.edges;
215
+ if (result.evicted > 0) {
216
+ logger.writeLine({ scope: 'kg', level: 'info', message: 'graph pruned', meta: { cwd: g.cwd, evicted: result.evicted, remaining: g.nodes.length, cap: maxGraphNodes } });
217
+ }
218
+ }
153
219
  await writeJson(graphPath(g.cwd), g);
154
220
  }
155
221
 
222
+ /** Purge ONE project's graph. The global ingest cursor has already passed those
223
+ * prompt lines, so this is a forget (no auto-rebuild) — exactly what a "this
224
+ * graph is a useless hairball, reset it" button wants. */
225
+ async function clearGraph(cwd) {
226
+ const target = cwd || await defaultCwd();
227
+ try { await fsp.unlink(graphPath(target)); } catch { /* already gone */ }
228
+ try {
229
+ const idx = await readPromptIndex();
230
+ if (idx) { delete idx[encodeCwd(target)]; await savePromptIndex(idx); }
231
+ } catch { /* index best-effort */ }
232
+ return { ok: true, cleared: shortLabel(target) };
233
+ }
234
+
235
+ /** Purge ALL project graphs. Leaves prompts.jsonl/cursor intact so future
236
+ * prompts still build fresh graphs (unless extraction is disabled). */
237
+ async function clearAllGraphs() {
238
+ let removed = 0;
239
+ try {
240
+ for (const f of fs.readdirSync(GRAPHS_DIR)) {
241
+ if (!f.endsWith('.json')) continue;
242
+ try { fs.unlinkSync(path.join(GRAPHS_DIR, f)); removed++; } catch { /* skip */ }
243
+ }
244
+ } catch { /* no dir yet */ }
245
+ try { await savePromptIndex({}); } catch { /* */ }
246
+ return { ok: true, removed };
247
+ }
248
+
156
249
  async function loadIngestState() {
157
250
  try {
158
251
  const s = JSON.parse(await fsp.readFile(INGEST_STATE_PATH, 'utf8'));
@@ -201,16 +294,6 @@ async function savePromptIndex(idx) {
201
294
  await writeJson(PROMPT_INDEX_PATH, idx);
202
295
  }
203
296
 
204
- /** Canonical dedup key: lowercase, strip leading article, collapse whitespace. */
205
- function canonicalize(name) {
206
- return String(name || '')
207
- .trim()
208
- .toLowerCase()
209
- .replace(/^(the|a|an)\s+/i, '')
210
- .replace(/\s+/g, ' ')
211
- .replace(/[.,;:!?]+$/, '');
212
- }
213
-
214
297
  /** Read every prompt line currently in the log (real prompts only). */
215
298
  async function readAllPrompts() {
216
299
  let raw;
@@ -376,6 +459,8 @@ function planUnits(tailText) {
376
459
  */
377
460
  async function ingest() {
378
461
  if (ingesting) return { ok: false, error: 'already running' };
462
+ const mode = currentCaptureMode();
463
+ if (mode === 'off') return { ok: true, added: 0, note: 'extraction disabled' };
379
464
  ingesting = true;
380
465
  broadcast('kg:ingest-progress', { phase: 'start', ingesting: true });
381
466
  try {
@@ -462,34 +547,42 @@ async function ingest() {
462
547
  const g = await graphFor(u.cwd);
463
548
  const byKey = new Map(g.nodes.map((n) => [n.key, n]));
464
549
  const byEdge = new Map(g.edges.map((e) => [`${e.src} ${e.relation} ${e.dst}`, e]));
465
- const known = [...byKey.values()].sort((a, b) => b.count - a.count).slice(0, KNOWN_VOCAB).map((n) => ({ key: n.key, name: n.name }));
466
-
467
- const r = await runClaude(EXTRACTION_PROMPT(u.entries, known), { model: 'haiku', timeoutMs: 180_000, systemPrompt: EXTRACTION_SYSTEM });
468
- extractions++;
469
- // Transient failure (timeout / spawn error / rate-limit): stop and stay
470
- // resumabledo NOT advance the watermark, so we retry these exact prompts.
471
- if (!r.ok) { logger.writeLine({ scope: 'kg', level: 'warn', message: 'extraction failed; pausing (resumable)', meta: { cwd: u.cwd, error: r.error } }); failed = true; break; }
472
- const parsed = extractJson(r.out);
473
- // Content failure (model refused / returned non-JSON): these prompts are
474
- // un-extractable. QUARANTINE the batch advance past it and CONTINUE so a
475
- // single bad batch can't freeze the whole graph (the head-of-line bug).
476
- if (!parsed) {
477
- logger.writeLine({ scope: 'kg', level: 'warn', message: 'extraction unparseable; skipping batch', meta: { cwd: u.cwd, prompts: u.entries.length } });
478
- skipped += u.entries.length;
479
- st.lastOffset += u.bytes;
480
- st.lastTs = u.entries[u.entries.length - 1].ts || st.lastTs;
481
- st.updatedAt = new Date().toISOString();
482
- // Write index before advancing watermark: if we crash between these two
483
- // writes, the watermark hasn't moved so the batch will be re-processed
484
- // (the index count may be slightly high) rather than advanced past a
485
- // batch whose index entry was never written.
486
- if (!promptIdx[u.enc]) promptIdx[u.enc] = { count: 0, cwd: u.cwd };
487
- promptIdx[u.enc].count += u.entries.length;
488
- promptIdx[u.enc].cwd = u.cwd;
489
- await savePromptIndex(promptIdx);
490
- await saveIngestState(st);
491
- if (extractions >= MAX_EXTRACTIONS_PER_RUN) { capped = true; break; }
492
- continue;
550
+ const known = [...byKey.values()].sort((a, b) => b.count - a.count).slice(0, KNOWN_VOCAB).map((n) => ({ key: n.key, name: n.name, type: n.type }));
551
+
552
+ // Derive entities/relations: heuristic (lite) or LLM (llm).
553
+ let parsed;
554
+ if (mode === 'lite') {
555
+ // Heuristic extraction O(W) per prompt, always succeeds, no claude spawn.
556
+ parsed = liteExtract(u.entries, known);
557
+ } else {
558
+ // LLM extraction (mode === 'llm')
559
+ const r = await runClaude(EXTRACTION_PROMPT(u.entries, known), { model: 'haiku', timeoutMs: 180_000, systemPrompt: EXTRACTION_SYSTEM });
560
+ extractions++;
561
+ // Transient failure (timeout / spawn error / rate-limit): stop and stay
562
+ // resumable do NOT advance the watermark, so we retry these exact prompts.
563
+ if (!r.ok) { logger.writeLine({ scope: 'kg', level: 'warn', message: 'extraction failed; pausing (resumable)', meta: { cwd: u.cwd, error: r.error } }); failed = true; break; }
564
+ parsed = extractJson(r.out);
565
+ // Content failure (model refused / returned non-JSON): these prompts are
566
+ // un-extractable. QUARANTINE the batch — advance past it and CONTINUE so a
567
+ // single bad batch can't freeze the whole graph (the head-of-line bug).
568
+ if (!parsed) {
569
+ logger.writeLine({ scope: 'kg', level: 'warn', message: 'extraction unparseable; skipping batch', meta: { cwd: u.cwd, prompts: u.entries.length } });
570
+ skipped += u.entries.length;
571
+ st.lastOffset += u.bytes;
572
+ st.lastTs = u.entries[u.entries.length - 1].ts || st.lastTs;
573
+ st.updatedAt = new Date().toISOString();
574
+ // Write index before advancing watermark: if we crash between these two
575
+ // writes, the watermark hasn't moved so the batch will be re-processed
576
+ // (the index count may be slightly high) rather than advanced past a
577
+ // batch whose index entry was never written.
578
+ if (!promptIdx[u.enc]) promptIdx[u.enc] = { count: 0, cwd: u.cwd };
579
+ promptIdx[u.enc].count += u.entries.length;
580
+ promptIdx[u.enc].cwd = u.cwd;
581
+ await savePromptIndex(promptIdx);
582
+ await saveIngestState(st);
583
+ if (extractions >= MAX_EXTRACTIONS_PER_RUN) { capped = true; break; }
584
+ continue;
585
+ }
493
586
  }
494
587
 
495
588
  const batchTs = u.entries[u.entries.length - 1].ts || st.lastTs || new Date().toISOString();
@@ -519,7 +612,8 @@ async function ingest() {
519
612
  // Tell the renderer this batch landed so it can refresh the graph live.
520
613
  broadcast('kg:ingest-progress', { phase: 'batch', ingesting: true, batch: batchNo, totalBatches, cwd: u.cwd, added });
521
614
 
522
- if (extractions >= MAX_EXTRACTIONS_PER_RUN) { capped = true; break; }
615
+ // Cap only applies to LLM mode (lite has negligible cost; no claude calls to bound).
616
+ if (mode === 'llm' && extractions >= MAX_EXTRACTIONS_PER_RUN) { capped = true; break; }
523
617
  }
524
618
 
525
619
  // More to do? Either we hit the per-run cap, or the bounded tail didn't reach
@@ -603,6 +697,7 @@ async function getState(cwd) {
603
697
  } else {
604
698
  totalPrompts = idx[enc]?.count ?? 0;
605
699
  }
700
+ const cfg = kgConfig();
606
701
  return {
607
702
  cwd: target,
608
703
  label: shortLabel(target),
@@ -615,6 +710,9 @@ async function getState(cwd) {
615
710
  lastIngest: g.updatedAt,
616
711
  ingesting,
617
712
  logPath: LOG_PATH,
713
+ extractionEnabled: cfg.extractionEnabled,
714
+ captureMode: cfg.captureMode,
715
+ maxGraphNodes: cfg.maxGraphNodes,
618
716
  },
619
717
  };
620
718
  }
@@ -666,6 +764,9 @@ function registerHandlers() {
666
764
  ipcMain.handle('kg:projects', () => listProjects());
667
765
  ipcMain.handle('kg:ingest', () => ingest());
668
766
  ipcMain.handle('kg:ask', (_e, { question, cwd } = {}) => ask(question, cwd));
767
+ ipcMain.handle('kg:clear', (_e, arg = {}) => (arg.all ? clearAllGraphs() : clearGraph(arg.cwd)));
768
+ ipcMain.handle('kg:set-extraction', (_e, arg = {}) => setExtractionEnabled(arg.enabled));
769
+ ipcMain.handle('kg:set-capture-mode', (_e, arg = {}) => setCaptureMode(arg.mode));
669
770
  }
670
771
 
671
772
  /** Watch the log; debounce-ingest new prompts. Cheap — only new bytes are read. */
@@ -0,0 +1,195 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * kgLite.cjs — Heuristic knowledge-graph extraction (no LLM, no network).
5
+ *
6
+ * Exports `canonicalize` and `ENTITY_TYPES` as the shared vocabulary contract
7
+ * (kg.cjs imports these so there is ONE definition for both paths), and
8
+ * `liteExtract` which is the cheap alternative to the `claude -p` extractor.
9
+ *
10
+ * Precision vs LLM path:
11
+ * Higher precision on known-vocab entities (exact canonical-key match).
12
+ * Lower recall on novel entities — extracts capitalized phrases, quoted
13
+ * identifiers, CamelCase tokens, and tech keywords; misses entities stated
14
+ * only in plain lowercase prose. Relations are always generic `related_to`
15
+ * co-occurrences (no semantic labelling). A lite graph grows meaningfully
16
+ * but is sparser and less richly typed than the LLM path. Good for
17
+ * cost-sensitive setups where search/Q&A value matters but per-prompt
18
+ * claude -p cost does not.
19
+ */
20
+
21
+ /** Canonical dedup key: lowercase, strip leading article, collapse whitespace.
22
+ * This function is the SINGLE definition shared with kg.cjs — any change here
23
+ * affects both extraction paths equally. */
24
+ function canonicalize(name) {
25
+ return String(name || '')
26
+ .trim()
27
+ .toLowerCase()
28
+ .replace(/^(the|a|an)\s+/i, '')
29
+ .replace(/\s+/g, ' ')
30
+ .replace(/[.,;:!?]+$/, '');
31
+ }
32
+
33
+ const ENTITY_TYPES = ['project', 'feature', 'tool', 'tech', 'concept', 'goal', 'person'];
34
+
35
+ // Tech terms that map directly to type 'tech' on first encounter.
36
+ const TECH_KEYWORDS = new Set([
37
+ 'react', 'electron', 'typescript', 'javascript', 'python', 'node', 'nodejs',
38
+ 'vite', 'tailwind', 'zustand', 'xterm', 'playwright', 'sqlite', 'postgres',
39
+ 'postgresql', 'redis', 'docker', 'kubernetes', 'webpack', 'esbuild', 'jest',
40
+ 'vitest', 'graphql', 'rest', 'api', 'http', 'https', 'websocket', 'pty',
41
+ 'ipc', 'mcp', 'git', 'npm', 'npx', 'pnpm', 'yarn', 'bash', 'zsh', 'curl',
42
+ 'json', 'yaml', 'tsx', 'jsx', 'cjs', 'esm', 'commonjs', 'llm', 'rag',
43
+ 'openai', 'anthropic', 'claude', 'oauth', 'jwt', 'cors',
44
+ ]);
45
+
46
+ // File extensions that mark a quoted/path identifier as type 'tech'.
47
+ const TECH_EXTS = new Set([
48
+ '.ts', '.tsx', '.cjs', '.mjs', '.js', '.jsx', '.py', '.go', '.rs', '.sh',
49
+ '.md', '.json', '.yaml', '.yml', '.toml', '.env',
50
+ ]);
51
+
52
+ // Common English words that appear capitalized at sentence start or as pronouns
53
+ // and are NOT meaningful developer-domain entities. Filter before adding.
54
+ const STOP_CAPS = new Set([
55
+ 'I', 'A', 'An', 'The', 'In', 'On', 'At', 'To', 'For', 'Of', 'With', 'By',
56
+ 'From', 'As', 'Is', 'Was', 'Are', 'Were', 'Be', 'Been', 'Have', 'Has', 'Had',
57
+ 'Do', 'Does', 'Did', 'Will', 'Would', 'Could', 'Should', 'May', 'Might',
58
+ 'Can', 'If', 'But', 'And', 'Or', 'Not', 'So', 'This', 'That', 'These',
59
+ 'Those', 'We', 'You', 'They', 'It', 'My', 'Your', 'Our', 'Its', 'Their',
60
+ 'When', 'Where', 'Who', 'What', 'How', 'Why', 'Which', 'Then', 'Just', 'Now',
61
+ 'Also', 'Here', 'There', 'Add', 'Get', 'Use', 'Run', 'Make', 'Fix', 'New',
62
+ 'Yes', 'No', 'Ok', 'Let', 'See', 'Set', 'Put', 'Try', 'Keep', 'Show', 'Go',
63
+ 'Tab', 'Now', 'Out', 'Up', 'Down', 'All', 'Any', 'Some', 'One', 'Two', 'Its',
64
+ ]);
65
+
66
+ /** Infer entity type from surface form when not in known vocab. */
67
+ function inferType(raw) {
68
+ const lower = raw.toLowerCase();
69
+ if (TECH_KEYWORDS.has(lower)) return 'tech';
70
+ const extM = raw.match(/(\.[a-zA-Z0-9]+)$/);
71
+ if (extM && TECH_EXTS.has(extM[1].toLowerCase())) return 'tech';
72
+ return 'concept';
73
+ }
74
+
75
+ /**
76
+ * Heuristic entity/relation extraction — no network, no child_process.spawn.
77
+ *
78
+ * Algorithm per prompt (Time: O(W) for extraction, O(k²) for edges):
79
+ * 1. Known-vocab scan: tokenize on whitespace/punctuation, look up each
80
+ * token and adjacent bigram in the vocab Map — O(W) with Map lookups.
81
+ * 2. Multi-word capitalized phrases + acronyms (regex pass): "Knowledge
82
+ * Graph", "Session Manager", "PRD". Higher precision than singles.
83
+ * 3. CamelCase identifiers (regex pass): `KnowledgeGraph`, `SchedulerDock`.
84
+ * 4. Quoted identifiers (regex pass): `scheduler.cjs`, "lite mode".
85
+ * 5. Single capitalized words (regex pass, STOP_CAPS filtered): "Scheduler".
86
+ * 6. Co-occurrence edges among all entities found in the same prompt.
87
+ * O(k²) where k = distinct entities per prompt — typically < 10.
88
+ *
89
+ * Dedup: canonicalize() maps every surface form to the same key ("The
90
+ * Scheduler" / "scheduler" / "Scheduler" all → key "scheduler"), so
91
+ * entityMap never accumulates duplicates.
92
+ *
93
+ * @param {Array<{prompt:string,ts?:string}>} prompts
94
+ * @param {Array<{key:string,name:string,type?:string}>} knownVocab top-N nodes from the graph
95
+ * @returns {{ entities: Array<{key,name,type,description}>, relations: Array<{src,dst,relation,description}> }}
96
+ */
97
+ function liteExtract(prompts, knownVocab) {
98
+ // O(V) build once; per-prompt lookups are O(1).
99
+ const vocabByKey = new Map((knownVocab || []).map((n) => [n.key, n]));
100
+
101
+ // Canonical key → entity — ensures no duplicates across the whole batch.
102
+ const entityMap = new Map();
103
+
104
+ function addEnt(raw, typeHint) {
105
+ const key = canonicalize(raw);
106
+ // Skip empty, pure-digit, or single-char keys.
107
+ if (!key || key.length < 2 || /^\d+$/.test(key)) return null;
108
+ if (!entityMap.has(key)) {
109
+ const known = vocabByKey.get(key);
110
+ const type = (known && ENTITY_TYPES.includes(known.type)) ? known.type
111
+ : ENTITY_TYPES.includes(typeHint) ? typeHint
112
+ : 'concept';
113
+ entityMap.set(key, { key, name: known ? known.name : raw, type, description: '' });
114
+ }
115
+ return key;
116
+ }
117
+
118
+ const relationSet = new Set(); // "src relation dst" dedup key
119
+ const relations = [];
120
+
121
+ // Regexes used across prompts — reset lastIndex per prompt.
122
+ // Multi-word capitalized phrase OR all-caps acronym (>=2 chars).
123
+ const CAP_PHRASE_RE = /\b([A-Z][a-zA-Z]*(?:\s+[A-Z][a-zA-Z]*)+|[A-Z]{2,})\b/g;
124
+ // CamelCase: two or more capitalized segments joined without space.
125
+ const CAMEL_RE = /\b([A-Z][a-z]+(?:[A-Z][a-z]*)+)\b/g;
126
+ // Quoted identifier. Backreference \1 ensures the closing delimiter matches
127
+ // the opening one — prevents "`foo` isn't" from closing the backtick-span
128
+ // on the apostrophe in "isn't" and extracting a garbage entity.
129
+ const QUOTED_RE = /([`"'])([^`"'\n]{2,40})\1/g;
130
+ // Single capitalized word (Title Case, not an acronym — handled by CAP_PHRASE_RE).
131
+ const CAP_WORD_RE = /\b([A-Z][a-z]{1,})\b/g;
132
+
133
+ for (const p of prompts) {
134
+ const text = String(p.prompt || '');
135
+ const promptKeys = [];
136
+
137
+ function record(key) {
138
+ if (key && !promptKeys.includes(key)) promptKeys.push(key);
139
+ }
140
+
141
+ // 1. Known-vocab scan: split on whitespace + punctuation, check token and bigram.
142
+ const tokens = text.split(/[\s,;:.!?()\[\]{}<>|/\\]+/).filter(Boolean);
143
+ for (let i = 0; i < tokens.length; i++) {
144
+ const k1 = canonicalize(tokens[i]);
145
+ if (vocabByKey.has(k1)) record(addEnt(tokens[i], vocabByKey.get(k1).type));
146
+ if (i + 1 < tokens.length) {
147
+ const bigram = tokens[i] + ' ' + tokens[i + 1];
148
+ const k2 = canonicalize(bigram);
149
+ if (vocabByKey.has(k2)) record(addEnt(bigram, vocabByKey.get(k2).type));
150
+ }
151
+ }
152
+
153
+ // 2. Multi-word capitalized phrases + acronyms — highest precision.
154
+ let m;
155
+ CAP_PHRASE_RE.lastIndex = 0;
156
+ while ((m = CAP_PHRASE_RE.exec(text)) !== null) {
157
+ if (!STOP_CAPS.has(m[1])) record(addEnt(m[1], inferType(m[1])));
158
+ }
159
+
160
+ // 3. CamelCase identifiers: KnowledgeGraph, SchedulerDock, etc.
161
+ CAMEL_RE.lastIndex = 0;
162
+ while ((m = CAMEL_RE.exec(text)) !== null) {
163
+ record(addEnt(m[1], inferType(m[1])));
164
+ }
165
+
166
+ // 4. Quoted identifiers: `scheduler.cjs`, "lite mode", etc.
167
+ QUOTED_RE.lastIndex = 0;
168
+ while ((m = QUOTED_RE.exec(text)) !== null) {
169
+ const raw = m[2].trim();
170
+ if (raw.length >= 2) record(addEnt(raw, inferType(raw)));
171
+ }
172
+
173
+ // 5. Single capitalized words (mid-sentence entities like "Scheduler").
174
+ // STOP_CAPS filters common sentence-start words and English pronouns.
175
+ CAP_WORD_RE.lastIndex = 0;
176
+ while ((m = CAP_WORD_RE.exec(text)) !== null) {
177
+ if (!STOP_CAPS.has(m[1])) record(addEnt(m[1], inferType(m[1])));
178
+ }
179
+
180
+ // Emit co-occurrence edges: O(k²) where k = promptKeys.length (typically <10).
181
+ for (let i = 0; i < promptKeys.length; i++) {
182
+ for (let j = i + 1; j < promptKeys.length; j++) {
183
+ const ek = `${promptKeys[i]} related_to ${promptKeys[j]}`;
184
+ if (!relationSet.has(ek)) {
185
+ relationSet.add(ek);
186
+ relations.push({ src: promptKeys[i], dst: promptKeys[j], relation: 'related_to', description: '' });
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ return { entities: [...entityMap.values()], relations };
193
+ }
194
+
195
+ module.exports = { canonicalize, ENTITY_TYPES, liteExtract };
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * kgPrune.cjs — decay-based node eviction for per-project knowledge graphs.
5
+ *
6
+ * API:
7
+ * nodeEvictionScore(node, degree, nowMs) → number (higher = keep)
8
+ * pruneGraph(graph, maxNodes, nowMs) → { nodes, edges, evicted }
9
+ *
10
+ * Complexity:
11
+ * nodeEvictionScore: O(1)
12
+ * pruneGraph: O(E + N log N) where N=nodes, E=edges
13
+ * - one O(E) pass to build degree map
14
+ * - one O(N log N) sort to select top-maxNodes
15
+ * - one O(E) pass to drop dangling edges
16
+ */
17
+
18
+ const HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
19
+ const W_RECENCY = 0.5; // freshness — recently-touched nodes stay relevant
20
+ const W_DEGREE = 0.3; // connectivity — hubs link disparate ideas together
21
+ const W_COUNT = 0.2; // frequency — often-mentioned = important to the dev
22
+
23
+ /**
24
+ * Score a node for retention. Higher = more worth keeping.
25
+ *
26
+ * Weighting rationale:
27
+ * Recency (50%): a node last seen 30 days ago decays to ~50% of a just-seen
28
+ * node — exponential decay keeps the graph current without hard cutoffs.
29
+ * Degree (30%): hub nodes (many connections) preserve graph coherence; without
30
+ * them the remaining graph fragments into disconnected islands.
31
+ * Count (20%): how often the developer mentioned something matters, but a
32
+ * high-count stale node should still decay below a freshly-active low-count one.
33
+ *
34
+ * @param {object} node KgNode with lastTs (ISO string|null), count (number)
35
+ * @param {number} degree edge count touching this node (src or dst)
36
+ * @param {number} nowMs Date.now() equivalent from caller (kept pure/testable)
37
+ * @returns {number}
38
+ */
39
+ function nodeEvictionScore(node, degree, nowMs) {
40
+ let recency = 0;
41
+ if (node.lastTs) {
42
+ const ageMs = nowMs - new Date(node.lastTs).getTime();
43
+ recency = Math.exp(-ageMs / HALF_LIFE_MS); // 1.0 = just seen, decays to 0
44
+ }
45
+ const degScore = Math.log1p(degree); // log scale: 0→0, 1→0.69, 10→2.4
46
+ const cntScore = Math.log1p(node.count ?? 0); // same — avoids linear blow-up
47
+ return W_RECENCY * recency + W_DEGREE * degScore + W_COUNT * cntScore;
48
+ }
49
+
50
+ /**
51
+ * Prune a graph to at most maxNodes nodes, evicting the lowest-scored ones and
52
+ * all edges that referenced evicted nodes.
53
+ *
54
+ * @param {{ nodes: object[], edges: object[] }} graph
55
+ * @param {number} maxNodes cap; 0 = disabled (returns graph unchanged)
56
+ * @param {number} nowMs caller-supplied timestamp (keeps scorer pure)
57
+ * @returns {{ nodes: object[], edges: object[], evicted: number }}
58
+ */
59
+ function pruneGraph(graph, maxNodes, nowMs) {
60
+ if (!maxNodes || graph.nodes.length <= maxNodes) {
61
+ return { nodes: graph.nodes, edges: graph.edges, evicted: 0 };
62
+ }
63
+
64
+ // O(E): build id → degree map in a single pass over edges.
65
+ const degree = new Map();
66
+ for (const e of graph.edges) {
67
+ degree.set(e.src, (degree.get(e.src) ?? 0) + 1);
68
+ degree.set(e.dst, (degree.get(e.dst) ?? 0) + 1);
69
+ }
70
+
71
+ // O(N log N): score + sort; keep the top maxNodes.
72
+ const scored = graph.nodes.map((n) => ({ n, s: nodeEvictionScore(n, degree.get(n.id) ?? 0, nowMs) }));
73
+ scored.sort((a, b) => b.s - a.s); // descending: highest score first
74
+ const kept = scored.slice(0, maxNodes);
75
+ const evicted = scored.length - kept.length;
76
+
77
+ if (evicted === 0) return { nodes: graph.nodes, edges: graph.edges, evicted: 0 };
78
+
79
+ const keptIds = new Set(kept.map((x) => x.n.id));
80
+
81
+ // O(E): drop every edge whose src or dst was evicted (no dangling references).
82
+ const edges = graph.edges.filter((e) => keptIds.has(e.src) && keptIds.has(e.dst));
83
+
84
+ return { nodes: kept.map((x) => x.n), edges, evicted };
85
+ }
86
+
87
+ module.exports = { nodeEvictionScore, pruneGraph };
@@ -1129,6 +1129,12 @@ export interface SessionManagerAPI {
1129
1129
  ingest: () => Promise<{ ok: boolean; added?: number; projects?: number; stopped?: boolean; error?: string; note?: string }>;
1130
1130
  /** Ask a question answered from ONE project's graph + prompts via claude -p. */
1131
1131
  ask: (question: string, cwd?: string) => Promise<{ ok: boolean; answer?: string; cited?: { ts: string; prompt: string }[]; error?: string }>;
1132
+ /** Purge graphs: one project (`{ cwd }`) or all (`{ all: true }`). */
1133
+ clear: (arg?: { cwd?: string; all?: boolean }) => Promise<{ ok: boolean; cleared?: string; removed?: number }>;
1134
+ /** Toggle the recurring claude -p extraction on/off (sets captureMode 'llm'/'off'). */
1135
+ setExtraction: (enabled: boolean) => Promise<{ ok: boolean; extractionEnabled: boolean }>;
1136
+ /** Set capture mode: 'llm' = claude -p extraction; 'lite' = heuristic (free); 'off' = disabled. */
1137
+ setCaptureMode: (mode: 'llm' | 'lite' | 'off') => Promise<{ ok: boolean; captureMode?: string; error?: string }>;
1132
1138
  onIngestProgress: (handler: (ev: KgIngestProgress) => void) => () => void;
1133
1139
  };
1134
1140
  }
@@ -1179,6 +1185,11 @@ export interface KgState {
1179
1185
  lastIngest: string | null;
1180
1186
  ingesting: boolean;
1181
1187
  logPath: string;
1188
+ extractionEnabled: boolean;
1189
+ /** Active capture mode: 'llm' | 'lite' | 'off'. */
1190
+ captureMode: 'llm' | 'lite' | 'off';
1191
+ /** Node cap from kg-config.json; 0 = disabled; default 300. */
1192
+ maxGraphNodes: number;
1182
1193
  };
1183
1194
  }
1184
1195
  export interface KgIngestProgress {
@@ -326,6 +326,12 @@ contextBridge.exposeInMainWorld('api', {
326
326
  projects: () => ipcRenderer.invoke('kg:projects'),
327
327
  ingest: () => ipcRenderer.invoke('kg:ingest'),
328
328
  ask: (question, cwd) => ipcRenderer.invoke('kg:ask', { question, cwd }),
329
+ /** Purge graphs: one project (cwd) or all (`{ all: true }`). */
330
+ clear: (arg) => ipcRenderer.invoke('kg:clear', arg ?? {}),
331
+ /** Toggle the recurring `claude -p` extraction on/off. */
332
+ setExtraction: (enabled) => ipcRenderer.invoke('kg:set-extraction', { enabled }),
333
+ /** Set capture mode: 'llm' | 'lite' | 'off'. */
334
+ setCaptureMode: (mode) => ipcRenderer.invoke('kg:set-capture-mode', { mode }),
329
335
  onIngestProgress: (handler) => {
330
336
  const listener = (_e, payload) => handler(payload);
331
337
  ipcRenderer.on('kg:ingest-progress', listener);