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,306 @@
1
+ /**
2
+ * Recall Command Tests
3
+ *
4
+ * Tests for /tlc:recall — semantic search command for querying memory.
5
+ * "What did we decide about X?" Returns ranked results with similarity
6
+ * scores, supports scope/type/limit options, and falls back to text
7
+ * search when the vector store is unavailable.
8
+ */
9
+
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
11
+ import { createRecallCommand } from './recall-command.js';
12
+
13
+ /**
14
+ * Creates a mock semanticRecall dependency with a vi.fn() recall method.
15
+ * Returns configurable results so each test can set its own scenario.
16
+ *
17
+ * @param {Array} [results=[]] - Default results the recall method returns
18
+ * @returns {object} Mock semanticRecall with a `recall` method
19
+ */
20
+ function createMockSemanticRecall(results = []) {
21
+ return {
22
+ recall: vi.fn().mockResolvedValue(results),
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Creates a single mock recall result entry.
28
+ *
29
+ * @param {object} overrides - Properties to override on the default result
30
+ * @returns {object} A mock recall result
31
+ */
32
+ function createMockResult(overrides = {}) {
33
+ return {
34
+ id: 'mem-1',
35
+ text: 'Use SQLite for vector storage because it is lightweight and embeddable',
36
+ score: 0.92,
37
+ type: 'decision',
38
+ source: {
39
+ project: 'my-project',
40
+ workspace: '/ws',
41
+ branch: 'main',
42
+ sourceFile: 'memory/decisions/use-sqlite.md',
43
+ },
44
+ date: Date.now() - 86400000,
45
+ permanent: false,
46
+ ...overrides,
47
+ };
48
+ }
49
+
50
+ describe('recall-command', () => {
51
+ let mockSemanticRecall;
52
+ let recallCmd;
53
+
54
+ beforeEach(() => {
55
+ mockSemanticRecall = createMockSemanticRecall();
56
+ recallCmd = createRecallCommand({
57
+ semanticRecall: mockSemanticRecall,
58
+ });
59
+ });
60
+
61
+ afterEach(() => {
62
+ vi.restoreAllMocks();
63
+ });
64
+
65
+ it('returns relevant results for a query', async () => {
66
+ const mockResults = [
67
+ createMockResult({ id: 'mem-1', text: 'Use SQLite for vector storage', score: 0.92 }),
68
+ createMockResult({ id: 'mem-2', text: 'Postgres for production data', score: 0.85 }),
69
+ ];
70
+ mockSemanticRecall.recall.mockResolvedValue(mockResults);
71
+
72
+ const result = await recallCmd.execute('/project', {
73
+ query: 'database architecture',
74
+ });
75
+
76
+ expect(result.success).toBe(true);
77
+ expect(result.results).toHaveLength(2);
78
+ expect(mockSemanticRecall.recall).toHaveBeenCalledWith(
79
+ 'database architecture',
80
+ expect.objectContaining({}),
81
+ expect.objectContaining({}),
82
+ );
83
+ });
84
+
85
+ it('results include title, date, score, type, excerpt', async () => {
86
+ const now = Date.now();
87
+ const mockResults = [
88
+ createMockResult({
89
+ id: 'mem-1',
90
+ text: 'Use SQLite for vector storage because it is lightweight and embeddable',
91
+ score: 0.92,
92
+ type: 'decision',
93
+ date: now - 86400000,
94
+ source: {
95
+ project: 'my-project',
96
+ workspace: '/ws',
97
+ branch: 'main',
98
+ sourceFile: 'memory/decisions/use-sqlite.md',
99
+ },
100
+ permanent: false,
101
+ }),
102
+ ];
103
+ mockSemanticRecall.recall.mockResolvedValue(mockResults);
104
+
105
+ const result = await recallCmd.execute('/project', {
106
+ query: 'vector storage',
107
+ });
108
+
109
+ expect(result.success).toBe(true);
110
+ expect(result.results).toHaveLength(1);
111
+
112
+ const entry = result.results[0];
113
+ expect(entry).toHaveProperty('title');
114
+ expect(entry).toHaveProperty('date');
115
+ expect(entry).toHaveProperty('score');
116
+ expect(entry).toHaveProperty('type');
117
+ expect(entry).toHaveProperty('excerpt');
118
+ expect(entry.type).toBe('decision');
119
+ expect(entry.score).toBe(0.92);
120
+ });
121
+
122
+ it('score displayed as percentage in formatted output', async () => {
123
+ const mockResults = [
124
+ createMockResult({ score: 0.92 }),
125
+ ];
126
+ mockSemanticRecall.recall.mockResolvedValue(mockResults);
127
+
128
+ const result = await recallCmd.execute('/project', {
129
+ query: 'database architecture',
130
+ });
131
+
132
+ expect(result.success).toBe(true);
133
+ expect(result.formatted).toBeDefined();
134
+ // 0.92 should appear as "92%" in the formatted output
135
+ expect(result.formatted).toMatch(/92%/);
136
+ });
137
+
138
+ it('--scope flag filters correctly (passes scope to semanticRecall)', async () => {
139
+ mockSemanticRecall.recall.mockResolvedValue([]);
140
+
141
+ await recallCmd.execute('/project', {
142
+ query: 'test query',
143
+ scope: 'workspace',
144
+ });
145
+
146
+ expect(mockSemanticRecall.recall).toHaveBeenCalledWith(
147
+ 'test query',
148
+ expect.objectContaining({}),
149
+ expect.objectContaining({ scope: 'workspace' }),
150
+ );
151
+
152
+ // Test with project scope
153
+ await recallCmd.execute('/project', {
154
+ query: 'test query',
155
+ scope: 'project',
156
+ });
157
+
158
+ expect(mockSemanticRecall.recall).toHaveBeenCalledWith(
159
+ 'test query',
160
+ expect.objectContaining({}),
161
+ expect.objectContaining({ scope: 'project' }),
162
+ );
163
+
164
+ // Test with global scope
165
+ await recallCmd.execute('/project', {
166
+ query: 'test query',
167
+ scope: 'global',
168
+ });
169
+
170
+ expect(mockSemanticRecall.recall).toHaveBeenCalledWith(
171
+ 'test query',
172
+ expect.objectContaining({}),
173
+ expect.objectContaining({ scope: 'global' }),
174
+ );
175
+ });
176
+
177
+ it('--type flag filters correctly (passes types to semanticRecall)', async () => {
178
+ mockSemanticRecall.recall.mockResolvedValue([]);
179
+
180
+ await recallCmd.execute('/project', {
181
+ query: 'test query',
182
+ types: ['decision'],
183
+ });
184
+
185
+ expect(mockSemanticRecall.recall).toHaveBeenCalledWith(
186
+ 'test query',
187
+ expect.objectContaining({}),
188
+ expect.objectContaining({ types: ['decision'] }),
189
+ );
190
+
191
+ // Test with multiple types
192
+ await recallCmd.execute('/project', {
193
+ query: 'test query',
194
+ types: ['decision', 'gotcha'],
195
+ });
196
+
197
+ expect(mockSemanticRecall.recall).toHaveBeenCalledWith(
198
+ 'test query',
199
+ expect.objectContaining({}),
200
+ expect.objectContaining({ types: ['decision', 'gotcha'] }),
201
+ );
202
+ });
203
+
204
+ it('--limit flag respected', async () => {
205
+ mockSemanticRecall.recall.mockResolvedValue([]);
206
+
207
+ await recallCmd.execute('/project', {
208
+ query: 'test query',
209
+ limit: 3,
210
+ });
211
+
212
+ expect(mockSemanticRecall.recall).toHaveBeenCalledWith(
213
+ 'test query',
214
+ expect.objectContaining({}),
215
+ expect.objectContaining({ limit: 3 }),
216
+ );
217
+ });
218
+
219
+ it('falls back gracefully when semanticRecall returns empty', async () => {
220
+ mockSemanticRecall.recall.mockResolvedValue([]);
221
+
222
+ const result = await recallCmd.execute('/project', {
223
+ query: 'nonexistent topic',
224
+ });
225
+
226
+ expect(result.success).toBe(true);
227
+ expect(result.results).toEqual([]);
228
+ expect(result.formatted).toBeDefined();
229
+ // Should contain a helpful "no results" message
230
+ expect(result.formatted).toMatch(/no (results|memories|matches)/i);
231
+ });
232
+
233
+ it('source file path shown in results', async () => {
234
+ const mockResults = [
235
+ createMockResult({
236
+ source: {
237
+ project: 'my-project',
238
+ workspace: '/ws',
239
+ branch: 'main',
240
+ sourceFile: 'memory/decisions/use-sqlite.md',
241
+ },
242
+ }),
243
+ ];
244
+ mockSemanticRecall.recall.mockResolvedValue(mockResults);
245
+
246
+ const result = await recallCmd.execute('/project', {
247
+ query: 'sqlite',
248
+ });
249
+
250
+ expect(result.results[0]).toHaveProperty('sourceFile');
251
+ expect(result.results[0].sourceFile).toBe('memory/decisions/use-sqlite.md');
252
+ // Source file should also appear in the formatted output
253
+ expect(result.formatted).toContain('memory/decisions/use-sqlite.md');
254
+ });
255
+
256
+ it('permanent items marked with indicator in formatted output', async () => {
257
+ const mockResults = [
258
+ createMockResult({ id: 'mem-perm', permanent: true, score: 0.95 }),
259
+ createMockResult({ id: 'mem-temp', permanent: false, score: 0.80 }),
260
+ ];
261
+ mockSemanticRecall.recall.mockResolvedValue(mockResults);
262
+
263
+ const result = await recallCmd.execute('/project', {
264
+ query: 'something',
265
+ });
266
+
267
+ expect(result.results).toHaveLength(2);
268
+
269
+ const permResult = result.results.find((r) => r.permanent === true);
270
+ const tempResult = result.results.find((r) => r.permanent === false);
271
+ expect(permResult).toBeDefined();
272
+ expect(tempResult).toBeDefined();
273
+
274
+ // The formatted output should contain an indicator for the permanent item
275
+ // Common indicators: [PERMANENT], pin emoji, star, etc.
276
+ expect(result.formatted).toMatch(/permanent|\[PERMANENT\]|pinned/i);
277
+ });
278
+
279
+ it('empty/no results returns helpful message', async () => {
280
+ mockSemanticRecall.recall.mockResolvedValue([]);
281
+
282
+ const result = await recallCmd.execute('/project', {
283
+ query: 'completely unrelated topic xyz',
284
+ });
285
+
286
+ expect(result.success).toBe(true);
287
+ expect(result.results).toEqual([]);
288
+ expect(result.formatted).toBeDefined();
289
+ expect(result.formatted.length).toBeGreaterThan(0);
290
+ // Should not just be empty — should tell the user nothing was found
291
+ expect(result.formatted).toMatch(/no (results|memories|matches)|nothing found/i);
292
+ });
293
+
294
+ it('empty query returns usage help', async () => {
295
+ const result = await recallCmd.execute('/project', {
296
+ query: '',
297
+ });
298
+
299
+ expect(result.success).toBe(false);
300
+ expect(result.formatted).toBeDefined();
301
+ // Should contain usage instructions
302
+ expect(result.formatted).toMatch(/usage|query|provide/i);
303
+ // semanticRecall should NOT be called for empty queries
304
+ expect(mockSemanticRecall.recall).not.toHaveBeenCalled();
305
+ });
306
+ });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Remember Command
3
+ *
4
+ * Provides the /tlc:remember command that captures conversation context
5
+ * or explicit text as permanent memory entries.
6
+ *
7
+ * Permanent memories:
8
+ * - Have `permanent: true` in frontmatter and vector metadata
9
+ * - Are written to memory/conversations/ with [PERMANENT] prefix in title
10
+ * - Are indexed in the vector store with permanent = 1
11
+ * - Are never pruned or archived
12
+ *
13
+ * @module remember-command
14
+ */
15
+
16
+ /**
17
+ * Creates a remember command instance with injected dependencies.
18
+ *
19
+ * @param {object} deps
20
+ * @param {object} deps.richCapture - Writer with writeConversationChunk(projectRoot, chunk)
21
+ * @param {object} deps.vectorIndexer - Indexer with indexChunk(projectRoot, chunk)
22
+ * @param {object} deps.embeddingClient - Client with embed(text) for generating embeddings
23
+ * @returns {{ execute: (projectRoot: string, options: object) => Promise<object> }}
24
+ */
25
+ export function createRememberCommand({ richCapture, vectorIndexer, embeddingClient }) {
26
+ /**
27
+ * Execute the remember command.
28
+ *
29
+ * @param {string} projectRoot - Absolute path to the project root
30
+ * @param {object} options - Command options
31
+ * @param {string} [options.text] - Explicit text to remember
32
+ * @param {Array<{user: string, assistant: string, timestamp?: number}>} [options.exchanges] - Recent conversation exchanges
33
+ * @returns {Promise<{success: boolean, message: string, filePath?: string}>}
34
+ */
35
+ async function execute(projectRoot, options = {}) {
36
+ const { text, exchanges } = options;
37
+
38
+ // Handle empty text — return guidance
39
+ if (text !== undefined && !text) {
40
+ return {
41
+ success: false,
42
+ message:
43
+ 'Please provide text to remember, or pass recent exchanges for automatic capture. ' +
44
+ 'Usage: /tlc:remember "Always use UTC timestamps"',
45
+ };
46
+ }
47
+
48
+ // Build the chunk for richCapture
49
+ const chunk = {
50
+ permanent: true,
51
+ };
52
+
53
+ if (text) {
54
+ // Explicit text mode
55
+ chunk.title = `[PERMANENT] ${text}`;
56
+ chunk.topic = text;
57
+ chunk.content = text;
58
+ chunk.text = text;
59
+ } else if (exchanges && exchanges.length > 0) {
60
+ // Exchange capture mode
61
+ const summary = exchanges
62
+ .map((ex) => ex.user)
63
+ .join('; ')
64
+ .slice(0, 80);
65
+ chunk.title = `[PERMANENT] ${summary}`;
66
+ chunk.topic = summary;
67
+ chunk.exchanges = exchanges;
68
+ chunk.text = summary;
69
+ } else {
70
+ return {
71
+ success: false,
72
+ message:
73
+ 'Nothing to remember. Provide text or recent exchanges. ' +
74
+ 'Usage: /tlc:remember "Always use UTC timestamps"',
75
+ };
76
+ }
77
+
78
+ // Write the chunk to disk via richCapture
79
+ const filePath = await richCapture.writeConversationChunk(projectRoot, chunk);
80
+
81
+ // Index in the vector store with permanent flag
82
+ await vectorIndexer.indexChunk(projectRoot, {
83
+ ...chunk,
84
+ filePath,
85
+ permanent: true,
86
+ });
87
+
88
+ // Build confirmation message
89
+ const displayText = text || chunk.topic;
90
+ return {
91
+ success: true,
92
+ message: `Remembered permanently: ${displayText}`,
93
+ filePath,
94
+ };
95
+ }
96
+
97
+ return { execute };
98
+ }
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Remember Command Tests
3
+ *
4
+ * Tests for the /tlc:remember command that captures conversation context
5
+ * or explicit text as permanent memory entries.
6
+ *
7
+ * Permanent memories:
8
+ * - Have `permanent: true` in frontmatter and vector metadata
9
+ * - Are written to memory/conversations/ with [PERMANENT] prefix in title
10
+ * - Are indexed in the vector store with permanent = 1
11
+ * - Are never pruned or archived
12
+ *
13
+ * These tests are written BEFORE the implementation (Red phase).
14
+ */
15
+
16
+ import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
17
+ import fs from 'fs';
18
+ import path from 'path';
19
+ import os from 'os';
20
+ import { createRememberCommand } from './remember-command.js';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Mock factories
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * Creates a mock richCapture dependency with vi.fn() stubs.
28
+ * writeConversationChunk resolves to a deterministic file path.
29
+ * @param {string} projectRoot - Used to build the returned file path
30
+ * @returns {object}
31
+ */
32
+ function createMockRichCapture(projectRoot) {
33
+ return {
34
+ writeConversationChunk: vi.fn().mockImplementation(async (root, chunk) => {
35
+ const convDir = path.join(root, 'memory', 'conversations');
36
+ const dateStr = new Date().toISOString().split('T')[0];
37
+ const slug = (chunk.topic || 'permanent-memory')
38
+ .toLowerCase()
39
+ .replace(/[^a-z0-9]+/g, '-')
40
+ .replace(/^-|-$/g, '')
41
+ .slice(0, 60);
42
+ const filepath = path.join(convDir, `${dateStr}-${slug}.md`);
43
+ // Simulate file creation so downstream assertions can inspect it
44
+ fs.mkdirSync(convDir, { recursive: true });
45
+ const lines = [];
46
+ if (chunk.permanent) {
47
+ lines.push('---');
48
+ lines.push('permanent: true');
49
+ lines.push('---');
50
+ lines.push('');
51
+ }
52
+ lines.push(`# ${chunk.title}`);
53
+ lines.push('');
54
+ if (chunk.exchanges) {
55
+ for (const ex of chunk.exchanges) {
56
+ lines.push(`**User:** ${ex.user}`);
57
+ lines.push(`**Assistant:** ${ex.assistant}`);
58
+ lines.push('');
59
+ }
60
+ }
61
+ fs.writeFileSync(filepath, lines.join('\n'), 'utf8');
62
+ return filepath;
63
+ }),
64
+ writeDecisionDetail: vi.fn().mockResolvedValue('/mock/decision.md'),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Creates a mock vectorIndexer dependency.
70
+ * @returns {object}
71
+ */
72
+ function createMockVectorIndexer() {
73
+ return {
74
+ indexChunk: vi.fn().mockResolvedValue({ success: true }),
75
+ indexFile: vi.fn().mockResolvedValue({ success: true }),
76
+ indexAll: vi.fn().mockResolvedValue({ indexed: 0, errors: 0 }),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Creates a mock embeddingClient dependency.
82
+ * @returns {object}
83
+ */
84
+ function createMockEmbeddingClient() {
85
+ return {
86
+ embed: vi.fn().mockResolvedValue(new Float32Array(1024).fill(0.1)),
87
+ };
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Sample data
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /** Recent exchanges to simulate conversation context */
95
+ const sampleExchanges = [
96
+ {
97
+ user: 'Should we always use UTC timestamps?',
98
+ assistant: 'Yes, UTC avoids timezone confusion in distributed systems.',
99
+ timestamp: Date.now() - 60000,
100
+ },
101
+ {
102
+ user: 'Even for user-facing display?',
103
+ assistant: 'Store UTC, convert to local for display.',
104
+ timestamp: Date.now() - 30000,
105
+ },
106
+ ];
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Tests
110
+ // ---------------------------------------------------------------------------
111
+
112
+ describe('remember-command', () => {
113
+ let testDir;
114
+ let mockRichCapture;
115
+ let mockVectorIndexer;
116
+ let mockEmbeddingClient;
117
+ let remember;
118
+
119
+ beforeEach(() => {
120
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-remember-cmd-test-'));
121
+ fs.mkdirSync(path.join(testDir, 'memory', 'conversations'), { recursive: true });
122
+
123
+ mockRichCapture = createMockRichCapture(testDir);
124
+ mockVectorIndexer = createMockVectorIndexer();
125
+ mockEmbeddingClient = createMockEmbeddingClient();
126
+
127
+ remember = createRememberCommand({
128
+ richCapture: mockRichCapture,
129
+ vectorIndexer: mockVectorIndexer,
130
+ embeddingClient: mockEmbeddingClient,
131
+ });
132
+ });
133
+
134
+ afterEach(() => {
135
+ fs.rmSync(testDir, { recursive: true, force: true });
136
+ });
137
+
138
+ // -------------------------------------------------------------------------
139
+ // 1. Captures explicit text as permanent memory
140
+ // -------------------------------------------------------------------------
141
+ it('captures explicit text as permanent memory (writes file with permanent: true frontmatter)', async () => {
142
+ const result = await remember.execute(testDir, { text: 'Always use UTC timestamps' });
143
+
144
+ expect(result.success).toBe(true);
145
+
146
+ // richCapture.writeConversationChunk should have been called
147
+ expect(mockRichCapture.writeConversationChunk).toHaveBeenCalledTimes(1);
148
+
149
+ // The chunk passed to writeConversationChunk should have permanent: true
150
+ const calledChunk = mockRichCapture.writeConversationChunk.mock.calls[0][1];
151
+ expect(calledChunk.permanent).toBe(true);
152
+
153
+ // The written file should contain permanent: true in frontmatter
154
+ const writtenFile = result.filePath;
155
+ const content = fs.readFileSync(writtenFile, 'utf8');
156
+ expect(content).toMatch(/permanent:\s*true/);
157
+ });
158
+
159
+ // -------------------------------------------------------------------------
160
+ // 2. Captures recent exchanges when no text provided
161
+ // -------------------------------------------------------------------------
162
+ it('captures recent exchanges when no text provided', async () => {
163
+ const result = await remember.execute(testDir, { exchanges: sampleExchanges });
164
+
165
+ expect(result.success).toBe(true);
166
+
167
+ // Should have called writeConversationChunk with the exchanges
168
+ expect(mockRichCapture.writeConversationChunk).toHaveBeenCalledTimes(1);
169
+ const calledChunk = mockRichCapture.writeConversationChunk.mock.calls[0][1];
170
+ expect(calledChunk.exchanges).toBeDefined();
171
+ expect(calledChunk.exchanges.length).toBeGreaterThan(0);
172
+ });
173
+
174
+ // -------------------------------------------------------------------------
175
+ // 3. File written to memory/conversations/ directory
176
+ // -------------------------------------------------------------------------
177
+ it('file written to memory/conversations/ directory', async () => {
178
+ const result = await remember.execute(testDir, { text: 'Use named exports everywhere' });
179
+
180
+ expect(result.filePath).toBeDefined();
181
+
182
+ // File path should be under memory/conversations/
183
+ const convDir = path.join(testDir, 'memory', 'conversations');
184
+ expect(result.filePath.startsWith(convDir)).toBe(true);
185
+
186
+ // File should exist on disk
187
+ expect(fs.existsSync(result.filePath)).toBe(true);
188
+ });
189
+
190
+ // -------------------------------------------------------------------------
191
+ // 4. File has [PERMANENT] prefix in title
192
+ // -------------------------------------------------------------------------
193
+ it('file has [PERMANENT] prefix in title', async () => {
194
+ const result = await remember.execute(testDir, { text: 'Never commit .env files' });
195
+
196
+ expect(mockRichCapture.writeConversationChunk).toHaveBeenCalledTimes(1);
197
+ const calledChunk = mockRichCapture.writeConversationChunk.mock.calls[0][1];
198
+
199
+ // Title should start with [PERMANENT]
200
+ expect(calledChunk.title).toMatch(/^\[PERMANENT\]/);
201
+ });
202
+
203
+ // -------------------------------------------------------------------------
204
+ // 5. permanent: true set in frontmatter of written file
205
+ // -------------------------------------------------------------------------
206
+ it('permanent: true set in frontmatter of written file', async () => {
207
+ const result = await remember.execute(testDir, { text: 'Always run lint before commit' });
208
+
209
+ const content = fs.readFileSync(result.filePath, 'utf8');
210
+
211
+ // File should start with YAML frontmatter containing permanent: true
212
+ expect(content).toMatch(/^---\n[\s\S]*permanent:\s*true[\s\S]*\n---/);
213
+ });
214
+
215
+ // -------------------------------------------------------------------------
216
+ // 6. Vector indexer called with permanent flag
217
+ // -------------------------------------------------------------------------
218
+ it('vector indexer called with permanent flag', async () => {
219
+ await remember.execute(testDir, { text: 'Use Vitest for all tests' });
220
+
221
+ // indexChunk should have been called
222
+ expect(mockVectorIndexer.indexChunk).toHaveBeenCalledTimes(1);
223
+
224
+ // The chunk passed to indexChunk should have permanent: true (or permanent = 1)
225
+ const indexedChunk = mockVectorIndexer.indexChunk.mock.calls[0][1];
226
+ expect(indexedChunk.permanent).toBeTruthy();
227
+ });
228
+
229
+ // -------------------------------------------------------------------------
230
+ // 7. Returns success with confirmation message
231
+ // -------------------------------------------------------------------------
232
+ it('returns success with confirmation message', async () => {
233
+ const result = await remember.execute(testDir, { text: 'Always use UTC timestamps' });
234
+
235
+ expect(result.success).toBe(true);
236
+ expect(result.message).toBeDefined();
237
+ expect(result.message).toContain('Remembered permanently');
238
+ expect(result.message).toContain('Always use UTC timestamps');
239
+ });
240
+
241
+ // -------------------------------------------------------------------------
242
+ // 8. Returns file path in result
243
+ // -------------------------------------------------------------------------
244
+ it('returns file path in result', async () => {
245
+ const result = await remember.execute(testDir, { text: 'Prefer composition over inheritance' });
246
+
247
+ expect(result).toHaveProperty('filePath');
248
+ expect(typeof result.filePath).toBe('string');
249
+ expect(result.filePath.length).toBeGreaterThan(0);
250
+ // Should be an absolute path ending in .md
251
+ expect(result.filePath).toMatch(/\.md$/);
252
+ });
253
+
254
+ // -------------------------------------------------------------------------
255
+ // 9. Handles empty text gracefully (returns error/guidance)
256
+ // -------------------------------------------------------------------------
257
+ it('handles empty text gracefully (returns error/guidance)', async () => {
258
+ const result = await remember.execute(testDir, { text: '' });
259
+
260
+ expect(result.success).toBe(false);
261
+ expect(result.message).toBeDefined();
262
+ // Should provide guidance about what to provide
263
+ expect(result.message.length).toBeGreaterThan(0);
264
+ });
265
+
266
+ // -------------------------------------------------------------------------
267
+ // 10. Phase 81: chunk.text must be set for vector indexing
268
+ // -------------------------------------------------------------------------
269
+ it('explicit text remember sets chunk.text on indexChunk call', async () => {
270
+ await remember.execute(testDir, { text: 'Always use UTC timestamps' });
271
+
272
+ expect(mockVectorIndexer.indexChunk).toHaveBeenCalledTimes(1);
273
+ const indexedChunk = mockVectorIndexer.indexChunk.mock.calls[0][1];
274
+ // chunk.text MUST be set — vectorIndexer.indexChunk only reads chunk.text
275
+ expect(indexedChunk.text).toBe('Always use UTC timestamps');
276
+ });
277
+
278
+ it('exchange capture sets chunk.text to exchange summary', async () => {
279
+ await remember.execute(testDir, { exchanges: sampleExchanges });
280
+
281
+ expect(mockVectorIndexer.indexChunk).toHaveBeenCalledTimes(1);
282
+ const indexedChunk = mockVectorIndexer.indexChunk.mock.calls[0][1];
283
+ // chunk.text MUST be non-empty for vector indexing to work
284
+ expect(indexedChunk.text).toBeDefined();
285
+ expect(typeof indexedChunk.text).toBe('string');
286
+ expect(indexedChunk.text.length).toBeGreaterThan(0);
287
+ });
288
+ });