claude-code-session-manager 0.31.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/assets/{TiptapBody-Q7puQu_h.js → TiptapBody-PPa1p3aA.js} +1 -1
- package/dist/assets/{index-hreMRE8u.js → index-CogvVs0W.js} +608 -608
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/scripts/postinstall.cjs +14 -5
- package/src/main/kg.cjs +141 -40
- package/src/main/lib/cleanEnv.cjs +7 -3
- package/src/main/lib/kgLite.cjs +195 -0
- package/src/main/lib/kgPrune.cjs +87 -0
- package/src/main/pluginInstall.cjs +14 -1
- package/src/preload/api.d.ts +11 -0
- package/src/preload/index.cjs +6 -0
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-
|
|
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.
|
|
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/scripts/postinstall.cjs
CHANGED
|
@@ -12,13 +12,22 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
const { spawnSync } = require('node:child_process');
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
// Resolve the electron-rebuild bin from THIS package's own node_modules first,
|
|
19
|
+
// so a global install never has `npx` reach out to the registry (and risk
|
|
20
|
+
// pulling the wrong/deprecated `electron-rebuild` package). Fall back to npx.
|
|
21
|
+
function rebuildArgv() {
|
|
22
|
+
const pkgRoot = path.join(__dirname, '..');
|
|
23
|
+
const local = path.join(pkgRoot, 'node_modules', '.bin', process.platform === 'win32' ? 'electron-rebuild.cmd' : 'electron-rebuild');
|
|
24
|
+
if (fs.existsSync(local)) return [local, ['-f', '-w', 'node-pty']];
|
|
25
|
+
return [process.platform === 'win32' ? 'npx.cmd' : 'npx', ['electron-rebuild', '-f', '-w', 'node-pty']];
|
|
26
|
+
}
|
|
15
27
|
|
|
16
28
|
function run() {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
['electron-rebuild', '-f', '-w', 'node-pty'],
|
|
20
|
-
{ stdio: 'inherit' },
|
|
21
|
-
);
|
|
29
|
+
const [cmd, args] = rebuildArgv();
|
|
30
|
+
const r = spawnSync(cmd, args, { stdio: 'inherit', cwd: path.join(__dirname, '..') });
|
|
22
31
|
return r.status === 0 && !r.error;
|
|
23
32
|
}
|
|
24
33
|
|
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
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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. */
|
|
@@ -25,10 +25,14 @@ function userBinDirs() {
|
|
|
25
25
|
];
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/** Electron's PATH
|
|
29
|
-
* in a spawned child's PATH so `claude`, node, git, etc. resolve
|
|
28
|
+
/** Electron's PATH with the user/Homebrew bin dirs APPENDED as a fallback — the
|
|
29
|
+
* value to put in a spawned child's PATH so `claude`, node, git, etc. resolve
|
|
30
|
+
* on macOS even under a stripped Finder/Dock PATH. Appended (not prepended) so
|
|
31
|
+
* a user's version manager (nvm/asdf/volta/mise) earlier in PATH still wins and
|
|
32
|
+
* isn't shadowed by /opt/homebrew. */
|
|
30
33
|
function pathWithUserBins() {
|
|
31
|
-
|
|
34
|
+
const base = process.env.PATH || '';
|
|
35
|
+
return base ? `${base}:${userBinDirs().join(':')}` : userBinDirs().join(':');
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
function cleanChildEnv(extra = {}) {
|
|
@@ -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 };
|
|
@@ -17,7 +17,16 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
const { ipcMain } = require('electron');
|
|
20
|
-
|
|
20
|
+
// Guard like pty.cjs: a node-pty ABI mismatch must not crash the whole app at
|
|
21
|
+
// module load (index.cjs requires this unconditionally). install() reports a
|
|
22
|
+
// clean error if the native module is unavailable.
|
|
23
|
+
let pty = null;
|
|
24
|
+
try {
|
|
25
|
+
pty = require('node-pty');
|
|
26
|
+
} catch (e) {
|
|
27
|
+
// eslint-disable-next-line no-console
|
|
28
|
+
console.error('[pluginInstall] node-pty failed to load:', e?.message);
|
|
29
|
+
}
|
|
21
30
|
const path = require('node:path');
|
|
22
31
|
const os = require('node:os');
|
|
23
32
|
const fs = require('node:fs');
|
|
@@ -72,6 +81,10 @@ function childEnv() {
|
|
|
72
81
|
*/
|
|
73
82
|
function runStep({ slug, args, register, unregister }) {
|
|
74
83
|
return new Promise((resolve) => {
|
|
84
|
+
if (!pty) {
|
|
85
|
+
resolve({ ok: false, exitCode: -1, error: 'node-pty unavailable (native module failed to load)' });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
75
88
|
const claudeBin = resolveClaudeBin();
|
|
76
89
|
let proc;
|
|
77
90
|
try {
|