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.
- package/.claude/commands/tlc/bootstrap.md +77 -0
- package/.claude/commands/tlc/build.md +20 -6
- package/.claude/commands/tlc/deploy.md +194 -2
- package/.claude/commands/tlc/e2e-verify.md +214 -0
- package/.claude/commands/tlc/guard.md +191 -0
- package/.claude/commands/tlc/help.md +32 -0
- package/.claude/commands/tlc/init.md +73 -37
- package/.claude/commands/tlc/llm.md +19 -4
- package/.claude/commands/tlc/preflight.md +134 -0
- package/.claude/commands/tlc/recall.md +87 -0
- package/.claude/commands/tlc/remember.md +71 -0
- package/.claude/commands/tlc/review.md +17 -4
- package/.claude/commands/tlc/watchci.md +159 -0
- package/.claude/hooks/tlc-block-tools.sh +41 -0
- package/.claude/hooks/tlc-capture-exchange.sh +50 -0
- package/.claude/hooks/tlc-post-build.sh +38 -0
- package/.claude/hooks/tlc-post-push.sh +22 -0
- package/.claude/hooks/tlc-prompt-guard.sh +69 -0
- package/.claude/hooks/tlc-session-init.sh +123 -0
- package/CLAUDE.md +96 -201
- package/bin/install.js +171 -2
- package/bin/postinstall.js +45 -26
- package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/docker-compose.dev.yml +18 -12
- package/package.json +3 -1
- package/server/index.js +240 -1
- package/server/lib/bug-writer.js +204 -0
- package/server/lib/bug-writer.test.js +279 -0
- package/server/lib/capture-bridge.js +242 -0
- package/server/lib/capture-bridge.test.js +363 -0
- package/server/lib/capture-guard.js +140 -0
- package/server/lib/capture-guard.test.js +182 -0
- package/server/lib/claude-cascade.js +247 -0
- package/server/lib/claude-cascade.test.js +245 -0
- package/server/lib/command-runner.js +159 -0
- package/server/lib/command-runner.test.js +92 -0
- package/server/lib/context-injection.js +121 -0
- package/server/lib/context-injection.test.js +340 -0
- package/server/lib/conversation-chunker.js +320 -0
- package/server/lib/conversation-chunker.test.js +573 -0
- package/server/lib/deploy/runners/dependency-runner.js +106 -0
- package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
- package/server/lib/deploy/runners/secrets-runner.js +174 -0
- package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
- package/server/lib/deploy/security-gates.js +11 -24
- package/server/lib/deploy/security-gates.test.js +9 -2
- package/server/lib/deploy-engine.js +182 -0
- package/server/lib/deploy-engine.test.js +147 -0
- package/server/lib/docker-api.js +137 -0
- package/server/lib/docker-api.test.js +202 -0
- package/server/lib/docker-client.js +297 -0
- package/server/lib/docker-client.test.js +308 -0
- package/server/lib/embedding-client.js +160 -0
- package/server/lib/embedding-client.test.js +243 -0
- package/server/lib/global-config.js +198 -0
- package/server/lib/global-config.test.js +288 -0
- package/server/lib/inherited-search.js +184 -0
- package/server/lib/inherited-search.test.js +343 -0
- package/server/lib/input-sanitizer.js +86 -0
- package/server/lib/input-sanitizer.test.js +117 -0
- package/server/lib/launchd-agent.js +225 -0
- package/server/lib/launchd-agent.test.js +185 -0
- package/server/lib/memory-api.js +182 -0
- package/server/lib/memory-api.test.js +320 -0
- package/server/lib/memory-bridge-e2e.test.js +160 -0
- package/server/lib/memory-committer.js +18 -4
- package/server/lib/memory-committer.test.js +21 -0
- package/server/lib/memory-hooks-capture.test.js +415 -0
- package/server/lib/memory-hooks-integration.test.js +98 -0
- package/server/lib/memory-hooks.js +139 -0
- package/server/lib/memory-inheritance.js +179 -0
- package/server/lib/memory-inheritance.test.js +360 -0
- package/server/lib/memory-store-adapter.js +105 -0
- package/server/lib/memory-store-adapter.test.js +141 -0
- package/server/lib/memory-wiring-e2e.test.js +93 -0
- package/server/lib/nginx-config.js +114 -0
- package/server/lib/nginx-config.test.js +82 -0
- package/server/lib/ollama-health.js +91 -0
- package/server/lib/ollama-health.test.js +74 -0
- package/server/lib/plan-writer.js +196 -0
- package/server/lib/plan-writer.test.js +298 -0
- package/server/lib/port-guard.js +44 -0
- package/server/lib/port-guard.test.js +65 -0
- package/server/lib/project-scanner.js +302 -0
- package/server/lib/project-scanner.test.js +541 -0
- package/server/lib/project-status.js +302 -0
- package/server/lib/project-status.test.js +470 -0
- package/server/lib/projects-registry.js +237 -0
- package/server/lib/projects-registry.test.js +275 -0
- package/server/lib/recall-command.js +207 -0
- package/server/lib/recall-command.test.js +306 -0
- package/server/lib/remember-command.js +98 -0
- package/server/lib/remember-command.test.js +288 -0
- package/server/lib/rich-capture.js +221 -0
- package/server/lib/rich-capture.test.js +312 -0
- package/server/lib/roadmap-api.js +200 -0
- package/server/lib/roadmap-api.test.js +318 -0
- package/server/lib/security/crypto-utils.test.js +2 -2
- package/server/lib/semantic-recall.js +242 -0
- package/server/lib/semantic-recall.test.js +463 -0
- package/server/lib/setup-generator.js +315 -0
- package/server/lib/setup-generator.test.js +303 -0
- package/server/lib/ssh-client.js +184 -0
- package/server/lib/ssh-client.test.js +127 -0
- package/server/lib/test-inventory.js +112 -0
- package/server/lib/test-inventory.test.js +360 -0
- package/server/lib/vector-indexer.js +246 -0
- package/server/lib/vector-indexer.test.js +459 -0
- package/server/lib/vector-store.js +260 -0
- package/server/lib/vector-store.test.js +706 -0
- package/server/lib/vps-api.js +184 -0
- package/server/lib/vps-api.test.js +208 -0
- package/server/lib/vps-bootstrap.js +124 -0
- package/server/lib/vps-bootstrap.test.js +79 -0
- package/server/lib/vps-monitor.js +126 -0
- package/server/lib/vps-monitor.test.js +98 -0
- package/server/lib/workspace-api.js +992 -0
- package/server/lib/workspace-api.test.js +1217 -0
- package/server/lib/workspace-bootstrap.js +164 -0
- package/server/lib/workspace-bootstrap.test.js +503 -0
- package/server/lib/workspace-context.js +129 -0
- package/server/lib/workspace-context.test.js +214 -0
- package/server/lib/workspace-detector.js +162 -0
- package/server/lib/workspace-detector.test.js +193 -0
- package/server/lib/workspace-init.js +307 -0
- package/server/lib/workspace-init.test.js +244 -0
- package/server/lib/workspace-snapshot.js +236 -0
- package/server/lib/workspace-snapshot.test.js +444 -0
- package/server/lib/workspace-watcher.js +162 -0
- package/server/lib/workspace-watcher.test.js +257 -0
- package/server/package-lock.json +1306 -17
- package/server/package.json +7 -0
- package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
- package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
- 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
|
+
});
|