claude-code-session-manager 0.33.1 → 0.35.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 (41) hide show
  1. package/README.md +5 -1
  2. package/dist/assets/{TiptapBody-BqQFXHkk.js → TiptapBody-BqDK21Pr.js} +51 -51
  3. package/dist/assets/index-CPTin6qz.css +32 -0
  4. package/dist/assets/index-zepGuf8m.js +3536 -0
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/plugins/session-manager-dev/skills/discover-features/SKILL.md +95 -0
  8. package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +9 -9
  9. package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +77 -62
  10. package/plugins/session-manager-dev/skills/project-status/SKILL.md +13 -0
  11. package/src/main/__tests__/chat-queue.test.cjs +65 -0
  12. package/src/main/__tests__/chat-stop-signal.test.cjs +52 -0
  13. package/src/main/__tests__/exchanges.test.cjs +177 -0
  14. package/src/main/__tests__/files-reject-credentials.test.cjs +40 -0
  15. package/src/main/__tests__/kg-augment.test.cjs +195 -0
  16. package/src/main/__tests__/memoryAggregate.test.cjs +70 -0
  17. package/src/main/agentMemory.cjs +0 -21
  18. package/src/main/chatRunner.cjs +398 -0
  19. package/src/main/exchanges.cjs +125 -0
  20. package/src/main/files.cjs +20 -8
  21. package/src/main/historyAggregator.cjs +0 -162
  22. package/src/main/index.cjs +76 -57
  23. package/src/main/ipcSchemas.cjs +70 -30
  24. package/src/main/lib/kgExchangePairing.cjs +75 -0
  25. package/src/main/lib/reaperHelpers.cjs +7 -1
  26. package/src/main/lib/summarize.cjs +114 -0
  27. package/src/main/lib/toolUseClassify.cjs +19 -0
  28. package/src/main/memoryAggregate.cjs +250 -0
  29. package/src/main/pluginInstall.cjs +4 -2
  30. package/src/main/scheduler.cjs +67 -21
  31. package/src/main/supervisor.cjs +16 -0
  32. package/src/main/transcripts.cjs +22 -2
  33. package/src/main/voiceHotkey.cjs +0 -2
  34. package/src/main/webRemote.cjs +11 -78
  35. package/src/preload/api.d.ts +140 -125
  36. package/src/preload/index.cjs +50 -29
  37. package/dist/assets/index-1PpZBVUr.js +0 -3535
  38. package/dist/assets/index-AKeGl-VM.css +0 -32
  39. package/src/main/kg.cjs +0 -792
  40. package/src/main/lib/kgLite.cjs +0 -195
  41. package/src/main/lib/kgPrune.cjs +0 -87
@@ -0,0 +1,177 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Unit test for exchanges.cjs.
5
+ *
6
+ * Runs against a tmp HOME so no real files are touched.
7
+ * Stubs summarize.cjs to avoid any network calls.
8
+ *
9
+ * Run: timeout 120 node --test src/main/__tests__/exchanges.test.cjs
10
+ */
11
+
12
+ const { test, describe, before, after } = require('node:test');
13
+ const assert = require('node:assert/strict');
14
+ const fs = require('node:fs');
15
+ const fsp = require('node:fs/promises');
16
+ const path = require('node:path');
17
+ const os = require('node:os');
18
+
19
+ // ─── Tmp HOME setup ─────────────────────────────────────────────────────────
20
+
21
+ let tmpHome;
22
+ let origHome;
23
+
24
+ before(() => {
25
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'exchanges-test-'));
26
+ origHome = process.env.HOME;
27
+ // node:os.homedir() reads HOME on Linux
28
+ process.env.HOME = tmpHome;
29
+ });
30
+
31
+ after(async () => {
32
+ process.env.HOME = origHome;
33
+ await fsp.rm(tmpHome, { recursive: true, force: true });
34
+ });
35
+
36
+ // ─── Module loading under tmp HOME ──────────────────────────────────────────
37
+
38
+ // Clear module cache so encodeCwd / config / exchanges pick up the new HOME
39
+ function freshRequire(mod) {
40
+ delete require.cache[require.resolve(mod)];
41
+ return require(mod);
42
+ }
43
+
44
+ // ─── Tests ───────────────────────────────────────────────────────────────────
45
+
46
+ describe('recordExchange', () => {
47
+ test('writes a complete record when summarize succeeds', async () => {
48
+ // Stub summarize to return a fixed summary without a network call
49
+ const summarizePath = require.resolve('../lib/summarize.cjs');
50
+ delete require.cache[summarizePath];
51
+ require.cache[summarizePath] = {
52
+ id: summarizePath,
53
+ filename: summarizePath,
54
+ loaded: true,
55
+ exports: {
56
+ summarize: async () => ({ summary: 'Test summary.', model: 'claude-haiku-4-5' }),
57
+ },
58
+ };
59
+
60
+ // Clear exchanges so it picks up the stub
61
+ const exchangesPath = require.resolve('../exchanges.cjs');
62
+ delete require.cache[exchangesPath];
63
+ // Also clear config so it re-reads the new HOME
64
+ const configPath = require.resolve('../config.cjs');
65
+ delete require.cache[configPath];
66
+ const encodeCwdPath = require.resolve('../lib/encodeCwd.cjs');
67
+ delete require.cache[encodeCwdPath];
68
+
69
+ const { recordExchange, EXCHANGES_DIR } = require('../exchanges.cjs');
70
+
71
+ const sessionId = 'sess-abc-123';
72
+ const cwd = '/home/user/myproject';
73
+ const prompt = 'What is 2+2?';
74
+ const result = 'The answer is 4.';
75
+
76
+ await recordExchange({ sessionId, cwd, prompt, result });
77
+
78
+ // Re-resolve the exchanges dir using the actual module's path (under tmpHome)
79
+ const { encodeCwd } = require('../lib/encodeCwd.cjs');
80
+ const encoded = encodeCwd(cwd);
81
+ const logPath = path.join(tmpHome, '.claude', 'knowledge-log', 'exchanges', `${encoded}.jsonl`);
82
+
83
+ const content = await fsp.readFile(logPath, 'utf8');
84
+ const lines = content.trim().split('\n').filter(Boolean);
85
+ assert.equal(lines.length, 1, 'should write exactly one line');
86
+
87
+ const record = JSON.parse(lines[0]);
88
+ assert.equal(record.sessionId, sessionId);
89
+ assert.equal(record.cwd, cwd);
90
+ assert.equal(record.prompt, prompt);
91
+ assert.equal(record.result, result);
92
+ assert.equal(record.summary, 'Test summary.');
93
+ assert.equal(record.model, 'claude-haiku-4-5');
94
+ assert.ok(record.ts, 'ts field should be present');
95
+ assert.equal(record.degraded, undefined, 'should not have degraded field on success');
96
+ });
97
+
98
+ test('still writes a record when summarize degrades (no_api_key)', async () => {
99
+ // Stub summarize to return a degraded result
100
+ const summarizePath = require.resolve('../lib/summarize.cjs');
101
+ delete require.cache[summarizePath];
102
+ require.cache[summarizePath] = {
103
+ id: summarizePath,
104
+ filename: summarizePath,
105
+ loaded: true,
106
+ exports: {
107
+ summarize: async (text) => ({
108
+ summary: text.slice(0, 600),
109
+ model: 'raw',
110
+ degraded: 'no_api_key',
111
+ }),
112
+ },
113
+ };
114
+
115
+ const exchangesPath = require.resolve('../exchanges.cjs');
116
+ delete require.cache[exchangesPath];
117
+
118
+ const { recordExchange } = require('../exchanges.cjs');
119
+ const { encodeCwd } = require('../lib/encodeCwd.cjs');
120
+
121
+ const sessionId = 'sess-degrade-456';
122
+ const cwd = '/home/user/anotherproject';
123
+ const result = 'Some verbatim result text from the assistant.';
124
+
125
+ await recordExchange({ sessionId, cwd, prompt: 'do something', result });
126
+
127
+ const encoded = encodeCwd(cwd);
128
+ const logPath = path.join(tmpHome, '.claude', 'knowledge-log', 'exchanges', `${encoded}.jsonl`);
129
+
130
+ const content = await fsp.readFile(logPath, 'utf8');
131
+ const lines = content.trim().split('\n').filter(Boolean);
132
+ assert.equal(lines.length, 1);
133
+
134
+ const record = JSON.parse(lines[0]);
135
+ assert.equal(record.sessionId, sessionId);
136
+ assert.equal(record.result, result, 'verbatim result must be stored');
137
+ assert.equal(record.degraded, 'no_api_key');
138
+ assert.equal(record.model, 'raw');
139
+ // summary is the raw slice of the result
140
+ assert.equal(record.summary, result.slice(0, 600));
141
+ });
142
+
143
+ test('appends multiple records to the same file', async () => {
144
+ // Stub summarize
145
+ const summarizePath = require.resolve('../lib/summarize.cjs');
146
+ delete require.cache[summarizePath];
147
+ require.cache[summarizePath] = {
148
+ id: summarizePath,
149
+ filename: summarizePath,
150
+ loaded: true,
151
+ exports: {
152
+ summarize: async () => ({ summary: 'ok', model: 'claude-haiku-4-5' }),
153
+ },
154
+ };
155
+
156
+ const exchangesPath = require.resolve('../exchanges.cjs');
157
+ delete require.cache[exchangesPath];
158
+ const { recordExchange } = require('../exchanges.cjs');
159
+ const { encodeCwd } = require('../lib/encodeCwd.cjs');
160
+
161
+ const cwd = '/home/user/multiproject';
162
+
163
+ await recordExchange({ sessionId: 's1', cwd, prompt: 'first', result: 'res1' });
164
+ await recordExchange({ sessionId: 's2', cwd, prompt: 'second', result: 'res2' });
165
+
166
+ const encoded = encodeCwd(cwd);
167
+ const logPath = path.join(tmpHome, '.claude', 'knowledge-log', 'exchanges', `${encoded}.jsonl`);
168
+ const content = await fsp.readFile(logPath, 'utf8');
169
+ const lines = content.trim().split('\n').filter(Boolean);
170
+ assert.equal(lines.length, 2);
171
+
172
+ const r1 = JSON.parse(lines[0]);
173
+ const r2 = JSON.parse(lines[1]);
174
+ assert.equal(r1.sessionId, 's1');
175
+ assert.equal(r2.sessionId, 's2');
176
+ });
177
+ });
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+
3
+ // rejectCredentials — write-side denylist for files:* mutations (write/create/
4
+ // rename/delete all call it). Pure path logic; no fs touched.
5
+
6
+ const { test } = require('node:test');
7
+ const assert = require('node:assert/strict');
8
+ const os = require('node:os');
9
+ const path = require('node:path');
10
+
11
+ const { rejectCredentials } = require('../files.cjs');
12
+
13
+ const home = os.homedir();
14
+ const under = (...p) => path.join(home, ...p);
15
+
16
+ test('denies writing the OAuth credentials file', () => {
17
+ assert.throws(() => rejectCredentials(under('.claude', '.credentials.json')), /credentials\.json denied/);
18
+ });
19
+
20
+ test('denies writes anywhere inside ~/.ssh (keys, authorized_keys, config)', () => {
21
+ for (const p of ['authorized_keys', 'authorized_keys2', 'id_ed25519', 'config']) {
22
+ assert.throws(() => rejectCredentials(under('.ssh', p)), /\.ssh denied/, `~/.ssh/${p} should be denied`);
23
+ }
24
+ assert.throws(() => rejectCredentials(under('.ssh')), /\.ssh denied/);
25
+ });
26
+
27
+ test('denies writing autostart persistence entries', () => {
28
+ assert.throws(() => rejectCredentials(under('.config', 'autostart', 'evil.desktop')), /autostart denied/);
29
+ });
30
+
31
+ test('allows ordinary files — including shell rc, which developers do edit', () => {
32
+ for (const p of [under('Projects', 'app', 'src', 'index.ts'), under('.bashrc'), under('.zshrc'), under('.config', 'session-manager', 'voice.json')]) {
33
+ assert.doesNotThrow(() => rejectCredentials(p), `${p} should be allowed`);
34
+ }
35
+ });
36
+
37
+ test('a path that merely starts with ".ssh" but is a sibling is not over-blocked', () => {
38
+ // ~/.sshfoo is a different entry — must NOT be caught by the ~/.ssh rule.
39
+ assert.doesNotThrow(() => rejectCredentials(under('.sshfoo', 'x')));
40
+ });
@@ -0,0 +1,195 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Unit test for kg exchange-pairing logic (kgExchangePairing.cjs).
5
+ *
6
+ * Verifies:
7
+ * 1. When a prompt has a matching exchange record, the enriched entry includes
8
+ * result and summary from the exchange.
9
+ * 2. When no exchange file exists for the cwd, entries fall back to prompt-only
10
+ * (no crash, no empty-graph wipe).
11
+ * 3. When an exchange exists but the prompt text doesn't match, the entry is
12
+ * returned unchanged (no false enrichment).
13
+ * 4. normalizePromptKey trims, lowercases, and collapses whitespace so minor
14
+ * differences between log sources don't break the match.
15
+ *
16
+ * Does NOT spawn claude -p. Run:
17
+ * timeout 120 node --test src/main/__tests__/kg-augment.test.cjs
18
+ */
19
+
20
+ const { test, describe, before, after } = require('node:test');
21
+ const assert = require('node:assert/strict');
22
+ const fs = require('node:fs');
23
+ const fsp = require('node:fs/promises');
24
+ const path = require('node:path');
25
+ const os = require('node:os');
26
+
27
+ // ─── Tmp directory ───────────────────────────────────────────────────────────
28
+
29
+ let tmpDir;
30
+
31
+ before(() => {
32
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kg-augment-test-'));
33
+ });
34
+
35
+ after(async () => {
36
+ await fsp.rm(tmpDir, { recursive: true, force: true });
37
+ });
38
+
39
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
40
+
41
+ function freshRequire(mod) {
42
+ delete require.cache[require.resolve(mod)];
43
+ return require(mod);
44
+ }
45
+
46
+ const PAIRING_MOD = '../lib/kgExchangePairing.cjs';
47
+
48
+ /** Write NDJSON exchange records to a file under a fresh exchanges dir. */
49
+ async function writeExchangeFile(exchangesDir, cwd, records) {
50
+ // Derive the encoded cwd the same way the module does
51
+ const { encodeCwd } = require('../lib/encodeCwd.cjs');
52
+ const encoded = encodeCwd(cwd);
53
+ const filePath = path.join(exchangesDir, `${encoded}.jsonl`);
54
+ await fsp.mkdir(exchangesDir, { recursive: true });
55
+ const content = records.map((r) => JSON.stringify(r)).join('\n') + '\n';
56
+ await fsp.writeFile(filePath, content, 'utf8');
57
+ }
58
+
59
+ // ─── Tests ───────────────────────────────────────────────────────────────────
60
+
61
+ describe('normalizePromptKey', () => {
62
+ test('lowercases, collapses whitespace, trims', () => {
63
+ const { normalizePromptKey } = freshRequire(PAIRING_MOD);
64
+ assert.equal(normalizePromptKey(' Hello World '), 'hello world');
65
+ assert.equal(normalizePromptKey('Fix\nthe\t\tbug'), 'fix the bug');
66
+ });
67
+
68
+ test('truncates at 500 chars', () => {
69
+ const { normalizePromptKey } = freshRequire(PAIRING_MOD);
70
+ const long = 'a'.repeat(800);
71
+ assert.equal(normalizePromptKey(long).length, 500);
72
+ });
73
+
74
+ test('handles empty / null / undefined gracefully', () => {
75
+ const { normalizePromptKey } = freshRequire(PAIRING_MOD);
76
+ assert.equal(normalizePromptKey(''), '');
77
+ assert.equal(normalizePromptKey(null), '');
78
+ assert.equal(normalizePromptKey(undefined), '');
79
+ });
80
+ });
81
+
82
+ describe('loadExchangeIndex', () => {
83
+ test('returns empty Map when exchanges file does not exist', async () => {
84
+ const { loadExchangeIndex } = freshRequire(PAIRING_MOD);
85
+ const exchangesDir = path.join(tmpDir, 'missing-dir');
86
+ const idx = await loadExchangeIndex(exchangesDir, '/some/project');
87
+ assert.ok(idx instanceof Map);
88
+ assert.equal(idx.size, 0, 'should return empty Map on missing file');
89
+ });
90
+
91
+ test('indexes exchange records by normalized prompt key', async () => {
92
+ const { loadExchangeIndex } = freshRequire(PAIRING_MOD);
93
+ const exchangesDir = path.join(tmpDir, 'idx-test-exchanges');
94
+ const cwd = '/home/user/testproject';
95
+
96
+ await writeExchangeFile(exchangesDir, cwd, [
97
+ { ts: '2026-06-25T01:00:00Z', sessionId: 's1', cwd, prompt: 'Fix the bug', result: 'I fixed it.', summary: 'Bug fixed.' },
98
+ { ts: '2026-06-25T01:01:00Z', sessionId: 's1', cwd, prompt: 'Add a feature', result: 'Feature added.', summary: 'Feature added.' },
99
+ ]);
100
+
101
+ const idx = await loadExchangeIndex(exchangesDir, cwd);
102
+ assert.equal(idx.size, 2);
103
+ assert.ok(idx.has('fix the bug'), 'should have normalized key');
104
+ assert.equal(idx.get('fix the bug').result, 'I fixed it.');
105
+ assert.equal(idx.get('fix the bug').summary, 'Bug fixed.');
106
+ });
107
+
108
+ test('skips malformed NDJSON lines without throwing', async () => {
109
+ const { loadExchangeIndex, normalizePromptKey } = freshRequire(PAIRING_MOD);
110
+ const { encodeCwd } = require('../lib/encodeCwd.cjs');
111
+ const exchangesDir = path.join(tmpDir, 'malformed-test');
112
+ const cwd = '/home/user/malformed';
113
+
114
+ await fsp.mkdir(exchangesDir, { recursive: true });
115
+ const encoded = encodeCwd(cwd);
116
+ const filePath = path.join(exchangesDir, `${encoded}.jsonl`);
117
+ await fsp.writeFile(filePath, [
118
+ 'not-json-at-all',
119
+ JSON.stringify({ ts: '2026-06-25T00:00:00Z', sessionId: 's1', cwd, prompt: 'valid prompt', result: 'ok', summary: 'ok' }),
120
+ '{ broken json',
121
+ ].join('\n') + '\n', 'utf8');
122
+
123
+ const idx = await loadExchangeIndex(exchangesDir, cwd);
124
+ assert.equal(idx.size, 1, 'should index only the valid line');
125
+ assert.ok(idx.has(normalizePromptKey('valid prompt')));
126
+ });
127
+ });
128
+
129
+ describe('enrichEntries', () => {
130
+ test('adds result and summary when exchange matches by prompt text', async () => {
131
+ const { loadExchangeIndex, enrichEntries } = freshRequire(PAIRING_MOD);
132
+ const exchangesDir = path.join(tmpDir, 'enrich-match');
133
+ const cwd = '/home/user/myapp';
134
+
135
+ await writeExchangeFile(exchangesDir, cwd, [
136
+ { ts: '2026-06-25T10:00:00Z', sessionId: 's1', cwd, prompt: 'Refactor the scheduler', result: 'I refactored it.', summary: 'Scheduler refactored.' },
137
+ ]);
138
+
139
+ const idx = await loadExchangeIndex(exchangesDir, cwd);
140
+
141
+ const entries = [
142
+ { ts: '2026-06-25T09:59:00Z', session_id: 's1', cwd, prompt: 'Refactor the scheduler' },
143
+ { ts: '2026-06-25T09:58:00Z', session_id: 's1', cwd, prompt: 'Unrelated prompt' },
144
+ ];
145
+
146
+ const enriched = enrichEntries(entries, idx);
147
+
148
+ // First entry has a match — should have result + summary
149
+ assert.equal(enriched[0].result, 'I refactored it.', 'matched entry should get result');
150
+ assert.equal(enriched[0].summary, 'Scheduler refactored.', 'matched entry should get summary');
151
+ // Original fields preserved
152
+ assert.equal(enriched[0].prompt, 'Refactor the scheduler');
153
+ assert.equal(enriched[0].cwd, cwd);
154
+
155
+ // Second entry has no match — returned unchanged (no result/summary)
156
+ assert.equal(enriched[1].result, undefined, 'unmatched entry should not get result');
157
+ assert.equal(enriched[1].summary, undefined, 'unmatched entry should not get summary');
158
+ assert.equal(enriched[1].prompt, 'Unrelated prompt');
159
+ });
160
+
161
+ test('falls back to prompt-only when exchange index is empty (missing file)', async () => {
162
+ const { loadExchangeIndex, enrichEntries } = freshRequire(PAIRING_MOD);
163
+ // Point at a directory that doesn't exist
164
+ const idx = await loadExchangeIndex(path.join(tmpDir, 'nonexistent'), '/some/cwd');
165
+ assert.equal(idx.size, 0);
166
+
167
+ const entries = [
168
+ { ts: '2026-06-25T00:00:00Z', session_id: 'sx', cwd: '/some/cwd', prompt: 'Do something' },
169
+ ];
170
+ const enriched = enrichEntries(entries, idx);
171
+ // Entry returned unchanged — no crash
172
+ assert.equal(enriched.length, 1);
173
+ assert.equal(enriched[0].prompt, 'Do something');
174
+ assert.equal(enriched[0].result, undefined);
175
+ assert.equal(enriched[0].summary, undefined);
176
+ });
177
+
178
+ test('whitespace-tolerant matching (trailing newline in one source)', async () => {
179
+ const { loadExchangeIndex, enrichEntries } = freshRequire(PAIRING_MOD);
180
+ const exchangesDir = path.join(tmpDir, 'ws-tolerant');
181
+ const cwd = '/home/user/wstol';
182
+
183
+ // Exchange record has trailing whitespace/newline in prompt
184
+ await writeExchangeFile(exchangesDir, cwd, [
185
+ { ts: '2026-06-25T00:00:00Z', sessionId: 's1', cwd, prompt: ' Build the widget \n', result: 'Widget built.', summary: 'Widget.' },
186
+ ]);
187
+
188
+ const idx = await loadExchangeIndex(exchangesDir, cwd);
189
+
190
+ // Prompt log entry has clean text — should still match after normalization
191
+ const entries = [{ ts: '2026-06-25T00:00:01Z', session_id: 's1', cwd, prompt: 'Build the widget' }];
192
+ const enriched = enrichEntries(entries, idx);
193
+ assert.equal(enriched[0].result, 'Widget built.', 'should match despite whitespace differences');
194
+ });
195
+ });
@@ -0,0 +1,70 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { parseClusters } = require('../memoryAggregate.cjs');
6
+
7
+ test('parseClusters maps well-formed JSON to the result shape and routes unlisted slugs to orphans', () => {
8
+ const slugs = ['scheduler_concurrency_memory', 'no_schedule_self_e2e', 'unrelated_note'];
9
+ const raw = JSON.stringify({
10
+ clusters: [
11
+ {
12
+ name: 'Scheduler ops',
13
+ summary: 'Scheduler concurrency + e2e isolation rules.',
14
+ memberSlugs: ['scheduler_concurrency_memory', 'no_schedule_self_e2e'],
15
+ links: [{ from: 'scheduler_concurrency_memory', to: 'no_schedule_self_e2e', label: 'related' }],
16
+ },
17
+ ],
18
+ orphans: ['unrelated_note'],
19
+ });
20
+
21
+ const result = parseClusters(raw, slugs);
22
+
23
+ assert.equal(result.clusters.length, 1);
24
+ assert.equal(result.clusters[0].name, 'Scheduler ops');
25
+ assert.deepEqual(result.clusters[0].memberSlugs, ['scheduler_concurrency_memory', 'no_schedule_self_e2e']);
26
+ assert.deepEqual(result.clusters[0].links, [{ from: 'scheduler_concurrency_memory', to: 'no_schedule_self_e2e', label: 'related' }]);
27
+ assert.deepEqual(result.orphans, ['unrelated_note']);
28
+ });
29
+
30
+ test('parseClusters routes a slug omitted from the response to orphans even without explicit listing', () => {
31
+ const slugs = ['a', 'b', 'c'];
32
+ const raw = JSON.stringify({
33
+ clusters: [{ name: 'Cluster A', summary: 's', memberSlugs: ['a'], links: [] }],
34
+ });
35
+
36
+ const result = parseClusters(raw, slugs);
37
+
38
+ assert.deepEqual(result.orphans.sort(), ['b', 'c']);
39
+ });
40
+
41
+ test('parseClusters returns empty clusters and all slugs as orphans on garbage LLM output', () => {
42
+ const slugs = ['one', 'two', 'three'];
43
+ const result = parseClusters('not json at all, just prose garbage', slugs);
44
+
45
+ assert.deepEqual(result.clusters, []);
46
+ assert.deepEqual(result.orphans, slugs);
47
+ });
48
+
49
+ test('parseClusters is robust to empty/null input', () => {
50
+ const slugs = ['x'];
51
+ assert.deepEqual(parseClusters('', slugs), { clusters: [], orphans: ['x'] });
52
+ assert.deepEqual(parseClusters(null, slugs), { clusters: [], orphans: ['x'] });
53
+ });
54
+
55
+ test('parseClusters drops link endpoints and members that reference nonexistent slugs', () => {
56
+ const slugs = ['known'];
57
+ const raw = JSON.stringify({
58
+ clusters: [{
59
+ name: 'C',
60
+ summary: 's',
61
+ memberSlugs: ['known', 'ghost'],
62
+ links: [{ from: 'known', to: 'ghost' }],
63
+ }],
64
+ });
65
+
66
+ const result = parseClusters(raw, slugs);
67
+ assert.deepEqual(result.clusters[0].memberSlugs, ['known']);
68
+ assert.deepEqual(result.clusters[0].links, []);
69
+ assert.deepEqual(result.orphans, []);
70
+ });
@@ -228,33 +228,12 @@ async function deleteEntry({ agentId, entryId }) {
228
228
  }
229
229
  }
230
230
 
231
- async function listAgents() {
232
- try {
233
- await ensureRoot();
234
- const dir = rootDir();
235
- const r = await config.listDir(dir, { filesOnly: true });
236
- if (!r.ok) return { agents: [], error: r.error };
237
- const agents = r.entries
238
- .filter((e) => e.name.endsWith('.json'))
239
- .map((e) => ({
240
- agentId: e.name.replace(/\.json$/, ''),
241
- bytes: e.size,
242
- mtimeMs: e.mtimeMs,
243
- }))
244
- .sort((a, b) => a.agentId.localeCompare(b.agentId));
245
- return { agents, error: null };
246
- } catch (e) {
247
- return { agents: [], error: e.message };
248
- }
249
- }
250
-
251
231
  function registerAgentMemoryHandlers() {
252
232
  const { schemas: s, validated: v } = require('./ipcSchemas.cjs');
253
233
  ipcMain.handle('agent-memory:list', v(s.agentMemoryList, list));
254
234
  ipcMain.handle('agent-memory:get', v(s.agentMemoryGet, get));
255
235
  ipcMain.handle('agent-memory:set', v(s.agentMemorySet, set));
256
236
  ipcMain.handle('agent-memory:delete', v(s.agentMemoryDelete, deleteEntry));
257
- ipcMain.handle('agent-memory:list-agents', () => listAgents());
258
237
  }
259
238
 
260
239
  module.exports = {