tlc-claude-code 1.8.5 → 2.1.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 (138) hide show
  1. package/.claude/commands/tlc/bootstrap.md +77 -0
  2. package/.claude/commands/tlc/build.md +20 -6
  3. package/.claude/commands/tlc/deploy.md +194 -2
  4. package/.claude/commands/tlc/e2e-verify.md +214 -0
  5. package/.claude/commands/tlc/guard.md +191 -0
  6. package/.claude/commands/tlc/help.md +32 -0
  7. package/.claude/commands/tlc/init.md +73 -37
  8. package/.claude/commands/tlc/llm.md +19 -4
  9. package/.claude/commands/tlc/preflight.md +134 -0
  10. package/.claude/commands/tlc/recall.md +87 -0
  11. package/.claude/commands/tlc/remember.md +71 -0
  12. package/.claude/commands/tlc/review.md +17 -4
  13. package/.claude/commands/tlc/watchci.md +159 -0
  14. package/.claude/hooks/tlc-block-tools.sh +41 -0
  15. package/.claude/hooks/tlc-capture-exchange.sh +50 -0
  16. package/.claude/hooks/tlc-post-build.sh +38 -0
  17. package/.claude/hooks/tlc-post-push.sh +22 -0
  18. package/.claude/hooks/tlc-prompt-guard.sh +69 -0
  19. package/.claude/hooks/tlc-session-init.sh +123 -0
  20. package/CLAUDE.md +96 -201
  21. package/bin/install.js +171 -2
  22. package/bin/postinstall.js +45 -26
  23. package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
  24. package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
  25. package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
  26. package/dashboard-web/dist/index.html +2 -2
  27. package/docker-compose.dev.yml +18 -12
  28. package/package.json +3 -1
  29. package/server/index.js +240 -1
  30. package/server/lib/bug-writer.js +204 -0
  31. package/server/lib/bug-writer.test.js +279 -0
  32. package/server/lib/capture-bridge.js +242 -0
  33. package/server/lib/capture-bridge.test.js +363 -0
  34. package/server/lib/capture-guard.js +140 -0
  35. package/server/lib/capture-guard.test.js +182 -0
  36. package/server/lib/claude-cascade.js +247 -0
  37. package/server/lib/claude-cascade.test.js +245 -0
  38. package/server/lib/command-runner.js +159 -0
  39. package/server/lib/command-runner.test.js +92 -0
  40. package/server/lib/context-injection.js +121 -0
  41. package/server/lib/context-injection.test.js +340 -0
  42. package/server/lib/conversation-chunker.js +320 -0
  43. package/server/lib/conversation-chunker.test.js +573 -0
  44. package/server/lib/deploy/runners/dependency-runner.js +106 -0
  45. package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
  46. package/server/lib/deploy/runners/secrets-runner.js +174 -0
  47. package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
  48. package/server/lib/deploy/security-gates.js +11 -24
  49. package/server/lib/deploy/security-gates.test.js +9 -2
  50. package/server/lib/deploy-engine.js +182 -0
  51. package/server/lib/deploy-engine.test.js +147 -0
  52. package/server/lib/docker-api.js +137 -0
  53. package/server/lib/docker-api.test.js +202 -0
  54. package/server/lib/docker-client.js +297 -0
  55. package/server/lib/docker-client.test.js +308 -0
  56. package/server/lib/embedding-client.js +160 -0
  57. package/server/lib/embedding-client.test.js +243 -0
  58. package/server/lib/global-config.js +198 -0
  59. package/server/lib/global-config.test.js +288 -0
  60. package/server/lib/inherited-search.js +184 -0
  61. package/server/lib/inherited-search.test.js +343 -0
  62. package/server/lib/input-sanitizer.js +86 -0
  63. package/server/lib/input-sanitizer.test.js +117 -0
  64. package/server/lib/launchd-agent.js +225 -0
  65. package/server/lib/launchd-agent.test.js +185 -0
  66. package/server/lib/memory-api.js +182 -0
  67. package/server/lib/memory-api.test.js +320 -0
  68. package/server/lib/memory-bridge-e2e.test.js +160 -0
  69. package/server/lib/memory-committer.js +18 -4
  70. package/server/lib/memory-committer.test.js +21 -0
  71. package/server/lib/memory-hooks-capture.test.js +415 -0
  72. package/server/lib/memory-hooks-integration.test.js +98 -0
  73. package/server/lib/memory-hooks.js +139 -0
  74. package/server/lib/memory-inheritance.js +179 -0
  75. package/server/lib/memory-inheritance.test.js +360 -0
  76. package/server/lib/memory-store-adapter.js +105 -0
  77. package/server/lib/memory-store-adapter.test.js +141 -0
  78. package/server/lib/memory-wiring-e2e.test.js +93 -0
  79. package/server/lib/nginx-config.js +114 -0
  80. package/server/lib/nginx-config.test.js +82 -0
  81. package/server/lib/ollama-health.js +91 -0
  82. package/server/lib/ollama-health.test.js +74 -0
  83. package/server/lib/plan-writer.js +196 -0
  84. package/server/lib/plan-writer.test.js +298 -0
  85. package/server/lib/port-guard.js +44 -0
  86. package/server/lib/port-guard.test.js +65 -0
  87. package/server/lib/project-scanner.js +302 -0
  88. package/server/lib/project-scanner.test.js +541 -0
  89. package/server/lib/project-status.js +302 -0
  90. package/server/lib/project-status.test.js +470 -0
  91. package/server/lib/projects-registry.js +237 -0
  92. package/server/lib/projects-registry.test.js +275 -0
  93. package/server/lib/recall-command.js +207 -0
  94. package/server/lib/recall-command.test.js +306 -0
  95. package/server/lib/remember-command.js +98 -0
  96. package/server/lib/remember-command.test.js +288 -0
  97. package/server/lib/rich-capture.js +221 -0
  98. package/server/lib/rich-capture.test.js +312 -0
  99. package/server/lib/roadmap-api.js +200 -0
  100. package/server/lib/roadmap-api.test.js +318 -0
  101. package/server/lib/security/crypto-utils.test.js +2 -2
  102. package/server/lib/semantic-recall.js +242 -0
  103. package/server/lib/semantic-recall.test.js +463 -0
  104. package/server/lib/setup-generator.js +315 -0
  105. package/server/lib/setup-generator.test.js +303 -0
  106. package/server/lib/ssh-client.js +184 -0
  107. package/server/lib/ssh-client.test.js +127 -0
  108. package/server/lib/test-inventory.js +112 -0
  109. package/server/lib/test-inventory.test.js +360 -0
  110. package/server/lib/vector-indexer.js +246 -0
  111. package/server/lib/vector-indexer.test.js +459 -0
  112. package/server/lib/vector-store.js +260 -0
  113. package/server/lib/vector-store.test.js +706 -0
  114. package/server/lib/vps-api.js +184 -0
  115. package/server/lib/vps-api.test.js +208 -0
  116. package/server/lib/vps-bootstrap.js +124 -0
  117. package/server/lib/vps-bootstrap.test.js +79 -0
  118. package/server/lib/vps-monitor.js +126 -0
  119. package/server/lib/vps-monitor.test.js +98 -0
  120. package/server/lib/workspace-api.js +992 -0
  121. package/server/lib/workspace-api.test.js +1217 -0
  122. package/server/lib/workspace-bootstrap.js +164 -0
  123. package/server/lib/workspace-bootstrap.test.js +503 -0
  124. package/server/lib/workspace-context.js +129 -0
  125. package/server/lib/workspace-context.test.js +214 -0
  126. package/server/lib/workspace-detector.js +162 -0
  127. package/server/lib/workspace-detector.test.js +193 -0
  128. package/server/lib/workspace-init.js +307 -0
  129. package/server/lib/workspace-init.test.js +244 -0
  130. package/server/lib/workspace-snapshot.js +236 -0
  131. package/server/lib/workspace-snapshot.test.js +444 -0
  132. package/server/lib/workspace-watcher.js +162 -0
  133. package/server/lib/workspace-watcher.test.js +257 -0
  134. package/server/package-lock.json +1306 -17
  135. package/server/package.json +7 -0
  136. package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
  137. package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
  138. package/dashboard-web/dist/assets/index-Trhg1C1Y.css +0 -1
@@ -0,0 +1,360 @@
1
+ /**
2
+ * @file test-inventory.test.js
3
+ * @description Tests for the Test Suite Inventory API (Phase 75, Task 2).
4
+ *
5
+ * Tests the factory function `createTestInventory(deps)` which accepts injected
6
+ * dependencies (globSync, fs) and returns functions for discovering test files,
7
+ * counting tests per file, grouping by directory, and reading cached test runs.
8
+ *
9
+ * Factory returns: { getTestInventory(projectPath), getLastTestRun(projectPath) }
10
+ *
11
+ * All dependencies are fully mocked with vi.fn() — no real filesystem access.
12
+ * These tests are written BEFORE the implementation (Red phase).
13
+ */
14
+ import { describe, it, beforeEach, expect, vi } from 'vitest';
15
+ import { createTestInventory } from './test-inventory.js';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Mock factories
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /**
22
+ * Creates a mock globSync function that returns the given file paths.
23
+ * @param {string[]} files - Array of file paths to return from globSync
24
+ * @returns {Function} vi.fn() that returns the files array
25
+ */
26
+ function createMockGlob(files = []) {
27
+ return vi.fn().mockReturnValue(files);
28
+ }
29
+
30
+ /**
31
+ * Creates a mock fs object with readFileSync and existsSync stubs.
32
+ * readFileSync returns content from the fileContents map, or throws ENOENT.
33
+ * existsSync returns true if the path exists in the map.
34
+ * @param {Object<string, string>} fileContents - Map of file path to file content
35
+ * @returns {{ readFileSync: Function, existsSync: Function }} Mock fs object
36
+ */
37
+ function createMockFs(fileContents = {}) {
38
+ return {
39
+ readFileSync: vi.fn((p) => {
40
+ if (p in fileContents) return fileContents[p];
41
+ throw new Error(`ENOENT: no such file or directory, open '${p}'`);
42
+ }),
43
+ existsSync: vi.fn((p) => p in fileContents),
44
+ };
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Sample test file contents for counting
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /** A typical test file with 3 tests (2 it + 1 test) */
52
+ const threeTestFile = `
53
+ import { describe, it, expect } from 'vitest';
54
+
55
+ describe('auth', () => {
56
+ it('logs in with valid credentials', () => {
57
+ expect(true).toBe(true);
58
+ });
59
+
60
+ it('rejects invalid password', () => {
61
+ expect(true).toBe(true);
62
+ });
63
+
64
+ test('handles missing email', () => {
65
+ expect(true).toBe(true);
66
+ });
67
+ });
68
+ `;
69
+
70
+ /** A test file with 2 tests using it.only and it.skip */
71
+ const twoTestFileWithModifiers = `
72
+ import { describe, it, expect } from 'vitest';
73
+
74
+ describe('session', () => {
75
+ it.only('creates new session', () => {
76
+ expect(true).toBe(true);
77
+ });
78
+
79
+ it.skip('expires after timeout', () => {
80
+ expect(true).toBe(true);
81
+ });
82
+ });
83
+ `;
84
+
85
+ /** A test file with 1 test using test.only */
86
+ const oneTestFile = `
87
+ import { describe, test, expect } from 'vitest';
88
+
89
+ describe('utils', () => {
90
+ test.only('formats date correctly', () => {
91
+ expect(true).toBe(true);
92
+ });
93
+ });
94
+ `;
95
+
96
+ /** An empty test file (no it/test calls) */
97
+ const emptyTestFile = `
98
+ import { describe, expect } from 'vitest';
99
+
100
+ describe('placeholder', () => {
101
+ // TODO: add tests
102
+ });
103
+ `;
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Tests
107
+ // ---------------------------------------------------------------------------
108
+
109
+ describe('test-inventory', () => {
110
+ let inventory;
111
+ let mockGlob;
112
+ let mockFs;
113
+
114
+ beforeEach(() => {
115
+ mockGlob = createMockGlob();
116
+ mockFs = createMockFs();
117
+ inventory = createTestInventory({ globSync: mockGlob, fs: mockFs });
118
+ });
119
+
120
+ describe('getTestInventory', () => {
121
+ it('discovers test files by glob patterns', () => {
122
+ const files = [
123
+ '/project/server/lib/auth.test.js',
124
+ '/project/server/lib/tasks.test.ts',
125
+ '/project/dashboard-web/src/App.test.tsx',
126
+ '/project/server/lib/utils.spec.js',
127
+ ];
128
+ mockGlob.mockReturnValue(files);
129
+ mockFs.readFileSync.mockReturnValue(emptyTestFile);
130
+
131
+ inventory.getTestInventory('/project');
132
+
133
+ // Verify globSync was called with patterns that cover all test extensions
134
+ const callArgs = mockGlob.mock.calls[0];
135
+ const patterns = callArgs[0];
136
+
137
+ // Should search for .test.js, .test.ts, .test.tsx, and .spec.* patterns
138
+ expect(patterns).toEqual(
139
+ expect.arrayContaining([
140
+ expect.stringContaining('*.test.js'),
141
+ expect.stringContaining('*.test.ts'),
142
+ expect.stringContaining('*.test.tsx'),
143
+ expect.stringContaining('*.spec.*'),
144
+ ])
145
+ );
146
+ });
147
+
148
+ it('groups files by directory', () => {
149
+ const files = [
150
+ '/project/server/lib/auth.test.js',
151
+ '/project/server/lib/tasks.test.js',
152
+ '/project/dashboard-web/src/Login.test.tsx',
153
+ '/project/dashboard-web/src/App.test.tsx',
154
+ ];
155
+ mockGlob.mockReturnValue(files);
156
+ mockFs.readFileSync.mockReturnValue(threeTestFile);
157
+
158
+ const result = inventory.getTestInventory('/project');
159
+
160
+ expect(result.groups).toHaveLength(2);
161
+
162
+ const groupNames = result.groups.map((g) => g.name);
163
+ expect(groupNames).toContain('server/lib');
164
+ expect(groupNames).toContain('dashboard-web/src');
165
+
166
+ const serverGroup = result.groups.find((g) => g.name === 'server/lib');
167
+ expect(serverGroup.fileCount).toBe(2);
168
+
169
+ const dashGroup = result.groups.find((g) => g.name === 'dashboard-web/src');
170
+ expect(dashGroup.fileCount).toBe(2);
171
+ });
172
+
173
+ it('counts tests per file (it/test occurrences)', () => {
174
+ const files = ['/project/server/lib/auth.test.js'];
175
+ mockGlob.mockReturnValue(files);
176
+ mockFs.readFileSync.mockReturnValue(threeTestFile);
177
+
178
+ const result = inventory.getTestInventory('/project');
179
+
180
+ const file = result.groups[0].files[0];
181
+ expect(file.testCount).toBe(3);
182
+ });
183
+
184
+ it('summary totals are correct', () => {
185
+ const files = [
186
+ '/project/server/lib/auth.test.js',
187
+ '/project/server/lib/session.test.js',
188
+ '/project/server/lib/utils.test.js',
189
+ ];
190
+ mockGlob.mockReturnValue(files);
191
+
192
+ // auth has 3, session has 2, utils has 1 = 6 total
193
+ const contents = {
194
+ '/project/server/lib/auth.test.js': threeTestFile,
195
+ '/project/server/lib/session.test.js': twoTestFileWithModifiers,
196
+ '/project/server/lib/utils.test.js': oneTestFile,
197
+ };
198
+ mockFs.readFileSync.mockImplementation((p) => {
199
+ if (p in contents) return contents[p];
200
+ throw new Error(`ENOENT: ${p}`);
201
+ });
202
+
203
+ const result = inventory.getTestInventory('/project');
204
+
205
+ expect(result.totalFiles).toBe(3);
206
+ expect(result.totalTests).toBe(6);
207
+
208
+ // Group total should also sum correctly
209
+ const group = result.groups[0];
210
+ expect(group.testCount).toBe(6);
211
+ expect(group.fileCount).toBe(3);
212
+ });
213
+
214
+ it('groups sorted by test count descending', () => {
215
+ const files = [
216
+ // Group A: server/lib — 1 file with 1 test
217
+ '/project/server/lib/utils.test.js',
218
+ // Group B: dashboard-web/src — 1 file with 3 tests
219
+ '/project/dashboard-web/src/App.test.tsx',
220
+ ];
221
+ mockGlob.mockReturnValue(files);
222
+
223
+ const contents = {
224
+ '/project/server/lib/utils.test.js': oneTestFile,
225
+ '/project/dashboard-web/src/App.test.tsx': threeTestFile,
226
+ };
227
+ mockFs.readFileSync.mockImplementation((p) => {
228
+ if (p in contents) return contents[p];
229
+ throw new Error(`ENOENT: ${p}`);
230
+ });
231
+
232
+ const result = inventory.getTestInventory('/project');
233
+
234
+ // dashboard-web/src has 3 tests, server/lib has 1 — dashboard first
235
+ expect(result.groups[0].name).toBe('dashboard-web/src');
236
+ expect(result.groups[0].testCount).toBe(3);
237
+ expect(result.groups[1].name).toBe('server/lib');
238
+ expect(result.groups[1].testCount).toBe(1);
239
+ });
240
+
241
+ it('ignores node_modules and dist', () => {
242
+ mockGlob.mockReturnValue([]);
243
+
244
+ inventory.getTestInventory('/project');
245
+
246
+ const callArgs = mockGlob.mock.calls[0];
247
+ const options = callArgs[1];
248
+
249
+ // The options should include ignore patterns for node_modules, dist, .git
250
+ expect(options).toBeDefined();
251
+ expect(options.ignore).toEqual(
252
+ expect.arrayContaining([
253
+ expect.stringContaining('node_modules'),
254
+ expect.stringContaining('dist'),
255
+ expect.stringContaining('.git'),
256
+ ])
257
+ );
258
+ });
259
+
260
+ it('returns empty inventory for no test files', () => {
261
+ mockGlob.mockReturnValue([]);
262
+
263
+ const result = inventory.getTestInventory('/project');
264
+
265
+ expect(result.totalFiles).toBe(0);
266
+ expect(result.totalTests).toBe(0);
267
+ expect(result.groups).toEqual([]);
268
+ });
269
+
270
+ it('handles mixed extensions (.test.js, .test.ts, .test.tsx)', () => {
271
+ const files = [
272
+ '/project/src/auth.test.js',
273
+ '/project/src/config.test.ts',
274
+ '/project/src/App.test.tsx',
275
+ ];
276
+ mockGlob.mockReturnValue(files);
277
+ mockFs.readFileSync.mockReturnValue(oneTestFile);
278
+
279
+ const result = inventory.getTestInventory('/project');
280
+
281
+ expect(result.totalFiles).toBe(3);
282
+ // All three files should be discovered regardless of extension
283
+ const filePaths = result.groups[0].files.map((f) => f.relativePath);
284
+ expect(filePaths).toContain('src/auth.test.js');
285
+ expect(filePaths).toContain('src/config.test.ts');
286
+ expect(filePaths).toContain('src/App.test.tsx');
287
+ });
288
+
289
+ it('handles spec files (.spec.js)', () => {
290
+ const files = [
291
+ '/project/src/utils.spec.js',
292
+ '/project/src/helpers.spec.ts',
293
+ ];
294
+ mockGlob.mockReturnValue(files);
295
+ mockFs.readFileSync.mockReturnValue(threeTestFile);
296
+
297
+ const result = inventory.getTestInventory('/project');
298
+
299
+ expect(result.totalFiles).toBe(2);
300
+ const filePaths = result.groups[0].files.map((f) => f.relativePath);
301
+ expect(filePaths).toContain('src/utils.spec.js');
302
+ expect(filePaths).toContain('src/helpers.spec.ts');
303
+ });
304
+
305
+ it('works with nested directory structures', () => {
306
+ const files = [
307
+ '/project/server/lib/auth/login.test.js',
308
+ '/project/server/lib/auth/logout.test.js',
309
+ '/project/server/lib/tasks.test.js',
310
+ '/project/dashboard-web/src/pages/Home.test.tsx',
311
+ ];
312
+ mockGlob.mockReturnValue(files);
313
+ mockFs.readFileSync.mockReturnValue(oneTestFile);
314
+
315
+ const result = inventory.getTestInventory('/project');
316
+
317
+ // Nested dirs should be grouped by their top-level directory segments
318
+ const groupNames = result.groups.map((g) => g.name);
319
+
320
+ // server/lib/auth/ and server/lib/ files should group under server/lib
321
+ // (or similar sensible grouping)
322
+ expect(groupNames.length).toBeGreaterThanOrEqual(2);
323
+
324
+ // All 4 files should be accounted for
325
+ expect(result.totalFiles).toBe(4);
326
+ });
327
+ });
328
+
329
+ describe('getLastTestRun', () => {
330
+ it('returns null when no cached run exists', () => {
331
+ mockFs.existsSync.mockReturnValue(false);
332
+
333
+ const result = inventory.getLastTestRun('/project');
334
+
335
+ expect(result).toBeNull();
336
+ });
337
+
338
+ it('returns cached test run data when available', () => {
339
+ const cachedRun = JSON.stringify({
340
+ timestamp: '2026-02-09T10:30:00Z',
341
+ passed: 347,
342
+ failed: 3,
343
+ total: 350,
344
+ duration: 12500,
345
+ });
346
+
347
+ mockFs.existsSync.mockReturnValue(true);
348
+ mockFs.readFileSync.mockReturnValue(cachedRun);
349
+
350
+ const result = inventory.getLastTestRun('/project');
351
+
352
+ expect(result).not.toBeNull();
353
+ expect(result.timestamp).toBe('2026-02-09T10:30:00Z');
354
+ expect(result.passed).toBe(347);
355
+ expect(result.failed).toBe(3);
356
+ expect(result.total).toBe(350);
357
+ expect(result.duration).toBe(12500);
358
+ });
359
+ });
360
+ });
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Vector Indexer — embeds and indexes memory content into the vector store.
3
+ *
4
+ * Reads markdown files from memory/decisions/, memory/gotchas/, and
5
+ * memory/conversations/, strips formatting, generates embeddings, and
6
+ * inserts into the vector store for semantic search.
7
+ *
8
+ * @module vector-indexer
9
+ */
10
+
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import crypto from 'crypto';
14
+
15
+ /** Memory subdirectories and their types */
16
+ const MEMORY_DIRS = [
17
+ { dir: 'decisions', type: 'decision' },
18
+ { dir: 'gotchas', type: 'gotcha' },
19
+ { dir: 'conversations', type: 'conversation' },
20
+ ];
21
+
22
+ /**
23
+ * Strip markdown formatting from text for cleaner embeddings.
24
+ * @param {string} text
25
+ * @returns {string}
26
+ */
27
+ function stripMarkdown(text) {
28
+ return text
29
+ .replace(/^---[\s\S]*?---\n?/m, '') // Remove frontmatter
30
+ .replace(/^#+\s+/gm, '') // Remove # headers
31
+ .replace(/\*\*([^*]+)\*\*/g, '$1') // Remove bold
32
+ .replace(/\*([^*]+)\*/g, '$1') // Remove italic
33
+ .replace(/`([^`]+)`/g, '$1') // Remove inline code
34
+ .replace(/```[\s\S]*?```/g, '') // Remove code blocks
35
+ .replace(/^\s*[-*]\s+/gm, '') // Remove list markers
36
+ .replace(/\n{3,}/g, '\n\n') // Collapse multiple newlines
37
+ .trim();
38
+ }
39
+
40
+ /**
41
+ * Parse YAML frontmatter from markdown content.
42
+ * @param {string} content
43
+ * @returns {{ permanent: boolean }}
44
+ */
45
+ function parseFrontmatter(content) {
46
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
47
+ if (!match) return { permanent: false };
48
+
49
+ const yaml = match[1];
50
+ const permanentMatch = yaml.match(/permanent:\s*(true|false)/i);
51
+ return {
52
+ permanent: permanentMatch ? permanentMatch[1].toLowerCase() === 'true' : false,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Detect memory type from file path based on parent directory.
58
+ * @param {string} filePath
59
+ * @returns {string}
60
+ */
61
+ function detectType(filePath) {
62
+ for (const { dir, type } of MEMORY_DIRS) {
63
+ if (filePath.includes(`/${dir}/`) || filePath.includes(`\\${dir}\\`)) {
64
+ return type;
65
+ }
66
+ }
67
+ return 'unknown';
68
+ }
69
+
70
+ /**
71
+ * Generate a deterministic ID for a file.
72
+ * @param {string} filePath
73
+ * @returns {string}
74
+ */
75
+ function fileId(filePath) {
76
+ return crypto.createHash('sha256').update(filePath).digest('hex').slice(0, 16);
77
+ }
78
+
79
+ /**
80
+ * List all .md files in a directory (non-recursive).
81
+ * @param {string} dirPath
82
+ * @returns {string[]}
83
+ */
84
+ function listMarkdownFiles(dirPath) {
85
+ if (!fs.existsSync(dirPath)) return [];
86
+ return fs.readdirSync(dirPath)
87
+ .filter((f) => f.endsWith('.md'))
88
+ .map((f) => path.join(dirPath, f))
89
+ .sort();
90
+ }
91
+
92
+ /**
93
+ * Create a vector indexer that embeds and stores memory content.
94
+ *
95
+ * @param {object} deps
96
+ * @param {object} deps.vectorStore - Vector store instance
97
+ * @param {object} deps.embeddingClient - Embedding client instance
98
+ * @returns {object} Indexer with indexAll/indexFile/indexChunk/isIndexed/rebuildIndex
99
+ */
100
+ export function createVectorIndexer({ vectorStore, embeddingClient }) {
101
+ /**
102
+ * Index all memory files from a project.
103
+ */
104
+ async function indexAll(projectRoot, options = {}) {
105
+ const { onProgress } = options;
106
+ const memoryRoot = path.join(projectRoot, 'memory');
107
+
108
+ // Collect all files to index
109
+ const filesToIndex = [];
110
+ for (const { dir, type } of MEMORY_DIRS) {
111
+ const dirPath = path.join(memoryRoot, dir);
112
+ const files = listMarkdownFiles(dirPath);
113
+ for (const f of files) {
114
+ filesToIndex.push({ path: f, type });
115
+ }
116
+ }
117
+
118
+ let indexed = 0;
119
+ let skipped = 0;
120
+ let errors = 0;
121
+
122
+ for (let i = 0; i < filesToIndex.length; i++) {
123
+ const file = filesToIndex[i];
124
+ try {
125
+ const result = await indexSingleFile(projectRoot, file.path, file.type);
126
+ if (result.success) {
127
+ indexed++;
128
+ } else {
129
+ errors++;
130
+ }
131
+ } catch {
132
+ errors++;
133
+ }
134
+
135
+ if (onProgress) {
136
+ onProgress({
137
+ indexed: indexed,
138
+ total: filesToIndex.length,
139
+ current: path.basename(file.path),
140
+ });
141
+ }
142
+ }
143
+
144
+ return { indexed, skipped, errors };
145
+ }
146
+
147
+ /**
148
+ * Index a single file.
149
+ */
150
+ async function indexFile(projectRoot, filePath) {
151
+ const type = detectType(filePath);
152
+ return indexSingleFile(projectRoot, filePath, type);
153
+ }
154
+
155
+ /**
156
+ * Internal: index a single file with known type.
157
+ */
158
+ async function indexSingleFile(projectRoot, filePath, type) {
159
+ try {
160
+ const rawContent = fs.readFileSync(filePath, 'utf8');
161
+ const frontmatter = parseFrontmatter(rawContent);
162
+ const cleanText = stripMarkdown(rawContent);
163
+
164
+ if (!cleanText || cleanText.length === 0) {
165
+ return { success: false };
166
+ }
167
+
168
+ const embedding = await embeddingClient.embed(cleanText);
169
+ if (!embedding) {
170
+ return { success: false };
171
+ }
172
+
173
+ const id = fileId(filePath);
174
+ vectorStore.insert({
175
+ id,
176
+ text: cleanText,
177
+ embedding,
178
+ type,
179
+ project: null,
180
+ workspace: projectRoot,
181
+ branch: null,
182
+ timestamp: Date.now(),
183
+ sourceFile: filePath,
184
+ permanent: frontmatter.permanent,
185
+ });
186
+
187
+ return { success: true };
188
+ } catch {
189
+ return { success: false };
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Index a conversation chunk object directly (not from file).
195
+ */
196
+ async function indexChunk(projectRoot, chunk) {
197
+ try {
198
+ const text = chunk.text || '';
199
+ if (!text) return { success: false };
200
+
201
+ const embedding = await embeddingClient.embed(text);
202
+ if (!embedding) return { success: false };
203
+
204
+ vectorStore.insert({
205
+ id: chunk.id,
206
+ text,
207
+ embedding,
208
+ type: chunk.type || 'conversation',
209
+ project: chunk.project || null,
210
+ workspace: chunk.workspace || projectRoot,
211
+ branch: null,
212
+ timestamp: chunk.timestamp || Date.now(),
213
+ sourceFile: chunk.sourceFile || null,
214
+ permanent: chunk.permanent || false,
215
+ });
216
+
217
+ return { success: true };
218
+ } catch {
219
+ return { success: false };
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Check if a file is already indexed in the store.
225
+ */
226
+ async function isIndexed(filePath) {
227
+ const all = vectorStore.getAll();
228
+ return all.some((entry) => entry.sourceFile === filePath);
229
+ }
230
+
231
+ /**
232
+ * Drop all vectors and re-index everything from text files.
233
+ */
234
+ async function rebuildIndex(projectRoot) {
235
+ vectorStore.rebuild();
236
+ await indexAll(projectRoot);
237
+ }
238
+
239
+ return {
240
+ indexAll,
241
+ indexFile,
242
+ indexChunk,
243
+ isIndexed,
244
+ rebuildIndex,
245
+ };
246
+ }