tlc-claude-code 1.8.5 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/tlc/bootstrap.md +77 -0
- package/.claude/commands/tlc/build.md +20 -6
- package/.claude/commands/tlc/recall.md +87 -0
- package/.claude/commands/tlc/remember.md +71 -0
- package/CLAUDE.md +84 -201
- package/dashboard-web/dist/assets/index-Uhc49PE-.css +1 -0
- package/dashboard-web/dist/assets/index-W36XHPC5.js +431 -0
- package/dashboard-web/dist/assets/index-W36XHPC5.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/index.js +13 -0
- package/server/lib/bug-writer.js +204 -0
- package/server/lib/bug-writer.test.js +279 -0
- package/server/lib/claude-cascade.js +247 -0
- package/server/lib/claude-cascade.test.js +245 -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/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/memory-api.js +180 -0
- package/server/lib/memory-api.test.js +322 -0
- package/server/lib/memory-hooks-capture.test.js +350 -0
- package/server/lib/memory-hooks.js +101 -0
- package/server/lib/memory-inheritance.js +179 -0
- package/server/lib/memory-inheritance.test.js +360 -0
- package/server/lib/plan-writer.js +196 -0
- package/server/lib/plan-writer.test.js +298 -0
- package/server/lib/project-scanner.js +267 -0
- package/server/lib/project-scanner.test.js +389 -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 +96 -0
- package/server/lib/remember-command.test.js +265 -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/semantic-recall.js +242 -0
- package/server/lib/semantic-recall.test.js +446 -0
- package/server/lib/setup-generator.js +315 -0
- package/server/lib/setup-generator.test.js +303 -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/workspace-api.js +811 -0
- package/server/lib/workspace-api.test.js +743 -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 +552 -0
- package/server/package.json +4 -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,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Injection Tests
|
|
3
|
+
* Tests for integrating semantic recall into context building.
|
|
4
|
+
*
|
|
5
|
+
* Task 7 (Phase 71): Enhanced Context Injection
|
|
6
|
+
* - buildSemanticContext() calls recallForContext when vectorStore provided
|
|
7
|
+
* - Falls back to empty conversations when no vectorStore
|
|
8
|
+
* - New SEMANTIC_WEIGHTS with VECTOR_SIMILARITY: 0.35
|
|
9
|
+
* - Conversation chunks included in semantic context output
|
|
10
|
+
* - formatContextForInjection() produces "Related Conversations" section
|
|
11
|
+
* - Backward compatible: no vector store = same format as before
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
|
15
|
+
import {
|
|
16
|
+
buildSemanticContext,
|
|
17
|
+
formatContextForInjection,
|
|
18
|
+
SEMANTIC_WEIGHTS,
|
|
19
|
+
} from './context-injection.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Creates a mock vector store with vi.fn() methods.
|
|
23
|
+
* @returns {object} Mock vector store
|
|
24
|
+
*/
|
|
25
|
+
function createMockVectorStore() {
|
|
26
|
+
return {
|
|
27
|
+
insert: vi.fn(),
|
|
28
|
+
search: vi.fn().mockReturnValue([]),
|
|
29
|
+
delete: vi.fn(),
|
|
30
|
+
count: vi.fn().mockReturnValue(0),
|
|
31
|
+
rebuild: vi.fn(),
|
|
32
|
+
getAll: vi.fn().mockReturnValue([]),
|
|
33
|
+
close: vi.fn(),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Creates a mock embedding client that returns deterministic embeddings.
|
|
39
|
+
* @returns {object} Mock embedding client
|
|
40
|
+
*/
|
|
41
|
+
function createMockEmbeddingClient() {
|
|
42
|
+
return {
|
|
43
|
+
embed: vi.fn().mockResolvedValue(new Float32Array(1024).fill(0.5)),
|
|
44
|
+
embedBatch: vi.fn().mockResolvedValue([]),
|
|
45
|
+
isAvailable: vi.fn().mockResolvedValue(true),
|
|
46
|
+
getModelInfo: () => ({ model: 'mxbai-embed-large', dimensions: 1024 }),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Creates a mock semantic recall instance with recallForContext.
|
|
52
|
+
* @param {Array} results - Results to return from recallForContext
|
|
53
|
+
* @returns {object} Mock semantic recall with vi.fn() methods
|
|
54
|
+
*/
|
|
55
|
+
function createMockSemanticRecall(results = []) {
|
|
56
|
+
return {
|
|
57
|
+
recall: vi.fn().mockResolvedValue(results),
|
|
58
|
+
recallForContext: vi.fn().mockResolvedValue(results),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Creates a mock conversation recall result.
|
|
64
|
+
* @param {object} overrides - Properties to override
|
|
65
|
+
* @returns {object} A mock conversation result
|
|
66
|
+
*/
|
|
67
|
+
function createConversationResult(overrides = {}) {
|
|
68
|
+
return {
|
|
69
|
+
id: 'conv-1',
|
|
70
|
+
text: 'Discussed auth token refresh strategy using short-lived JWTs with rotation.',
|
|
71
|
+
score: 0.85,
|
|
72
|
+
type: 'conversation',
|
|
73
|
+
source: {
|
|
74
|
+
project: 'my-project',
|
|
75
|
+
workspace: '/ws',
|
|
76
|
+
branch: 'main',
|
|
77
|
+
sourceFile: 'sessions/2025-01-15.md',
|
|
78
|
+
},
|
|
79
|
+
date: Date.now() - 86400000,
|
|
80
|
+
permanent: false,
|
|
81
|
+
...overrides,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('context-injection', () => {
|
|
86
|
+
let mockVectorStore;
|
|
87
|
+
let mockEmbeddingClient;
|
|
88
|
+
let mockSemanticRecall;
|
|
89
|
+
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
mockVectorStore = createMockVectorStore();
|
|
92
|
+
mockEmbeddingClient = createMockEmbeddingClient();
|
|
93
|
+
mockSemanticRecall = createMockSemanticRecall();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
vi.restoreAllMocks();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('SEMANTIC_WEIGHTS', () => {
|
|
101
|
+
it('new relevance weights sum to 1.0', () => {
|
|
102
|
+
// Task 7 defines new weights:
|
|
103
|
+
// VECTOR_SIMILARITY: 0.35, FILE_MATCH: 0.20, BRANCH_MATCH: 0.20,
|
|
104
|
+
// RECENCY: 0.15, KEYWORD_MATCH: 0.10
|
|
105
|
+
const sum = Object.values(SEMANTIC_WEIGHTS).reduce((a, b) => a + b, 0);
|
|
106
|
+
expect(sum).toBeCloseTo(1.0, 10);
|
|
107
|
+
|
|
108
|
+
// Verify individual weights exist with correct values
|
|
109
|
+
expect(SEMANTIC_WEIGHTS).toHaveProperty('VECTOR_SIMILARITY', 0.35);
|
|
110
|
+
expect(SEMANTIC_WEIGHTS).toHaveProperty('FILE_MATCH', 0.20);
|
|
111
|
+
expect(SEMANTIC_WEIGHTS).toHaveProperty('BRANCH_MATCH', 0.20);
|
|
112
|
+
expect(SEMANTIC_WEIGHTS).toHaveProperty('RECENCY', 0.15);
|
|
113
|
+
expect(SEMANTIC_WEIGHTS).toHaveProperty('KEYWORD_MATCH', 0.10);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('buildSemanticContext', () => {
|
|
118
|
+
it('calls recallForContext when vectorStore is provided', async () => {
|
|
119
|
+
const conversationResults = [
|
|
120
|
+
createConversationResult({ id: 'conv-1', text: 'Auth token discussion' }),
|
|
121
|
+
createConversationResult({ id: 'conv-2', text: 'Database migration plan' }),
|
|
122
|
+
];
|
|
123
|
+
mockSemanticRecall = createMockSemanticRecall(conversationResults);
|
|
124
|
+
|
|
125
|
+
const context = {
|
|
126
|
+
projectId: 'my-project',
|
|
127
|
+
workspace: '/ws',
|
|
128
|
+
branch: 'main',
|
|
129
|
+
touchedFiles: ['src/auth/token.js'],
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const result = await buildSemanticContext('/path/to/project', context, {
|
|
133
|
+
semanticRecall: mockSemanticRecall,
|
|
134
|
+
vectorStore: mockVectorStore,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// recallForContext should have been called with the project root and context
|
|
138
|
+
expect(mockSemanticRecall.recallForContext).toHaveBeenCalledWith(
|
|
139
|
+
'/path/to/project',
|
|
140
|
+
context
|
|
141
|
+
);
|
|
142
|
+
// Result should include the recalled conversations
|
|
143
|
+
expect(result.conversations).toBeDefined();
|
|
144
|
+
expect(result.conversations).toHaveLength(2);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('returns empty conversations when no vectorStore is provided', async () => {
|
|
148
|
+
const context = {
|
|
149
|
+
projectId: 'my-project',
|
|
150
|
+
workspace: '/ws',
|
|
151
|
+
branch: 'main',
|
|
152
|
+
touchedFiles: [],
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const result = await buildSemanticContext('/path/to/project', context, {
|
|
156
|
+
// No vectorStore, no semanticRecall
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Should fall back gracefully with empty conversations
|
|
160
|
+
expect(result.conversations).toBeDefined();
|
|
161
|
+
expect(result.conversations).toHaveLength(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('vector similarity score integrated into ranking (scoreWithVector)', async () => {
|
|
165
|
+
// When vector store is available, the ranking should incorporate
|
|
166
|
+
// VECTOR_SIMILARITY weight (0.35) from SEMANTIC_WEIGHTS
|
|
167
|
+
const conversationResults = [
|
|
168
|
+
createConversationResult({
|
|
169
|
+
id: 'conv-high',
|
|
170
|
+
text: 'High relevance auth discussion',
|
|
171
|
+
score: 0.95,
|
|
172
|
+
}),
|
|
173
|
+
createConversationResult({
|
|
174
|
+
id: 'conv-low',
|
|
175
|
+
text: 'Low relevance logging setup',
|
|
176
|
+
score: 0.40,
|
|
177
|
+
}),
|
|
178
|
+
];
|
|
179
|
+
mockSemanticRecall = createMockSemanticRecall(conversationResults);
|
|
180
|
+
|
|
181
|
+
const context = {
|
|
182
|
+
projectId: 'my-project',
|
|
183
|
+
workspace: '/ws',
|
|
184
|
+
branch: 'main',
|
|
185
|
+
touchedFiles: ['src/auth/login.js'],
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const result = await buildSemanticContext('/path/to/project', context, {
|
|
189
|
+
semanticRecall: mockSemanticRecall,
|
|
190
|
+
vectorStore: mockVectorStore,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Conversations should be ordered by score (highest first)
|
|
194
|
+
expect(result.conversations.length).toBeGreaterThanOrEqual(2);
|
|
195
|
+
expect(result.conversations[0].score).toBeGreaterThanOrEqual(
|
|
196
|
+
result.conversations[1].score
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('includes conversation chunks in semantic context output', async () => {
|
|
201
|
+
const conversationResults = [
|
|
202
|
+
createConversationResult({
|
|
203
|
+
id: 'conv-1',
|
|
204
|
+
text: 'We decided to use JWT for auth tokens with 15-minute expiry and refresh rotation.',
|
|
205
|
+
type: 'conversation',
|
|
206
|
+
score: 0.90,
|
|
207
|
+
}),
|
|
208
|
+
createConversationResult({
|
|
209
|
+
id: 'conv-2',
|
|
210
|
+
text: 'Database migration strategy: use knex with timestamped migration files.',
|
|
211
|
+
type: 'conversation',
|
|
212
|
+
score: 0.80,
|
|
213
|
+
}),
|
|
214
|
+
];
|
|
215
|
+
mockSemanticRecall = createMockSemanticRecall(conversationResults);
|
|
216
|
+
|
|
217
|
+
const context = {
|
|
218
|
+
projectId: 'my-project',
|
|
219
|
+
workspace: '/ws',
|
|
220
|
+
branch: 'main',
|
|
221
|
+
touchedFiles: [],
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const result = await buildSemanticContext('/path/to/project', context, {
|
|
225
|
+
semanticRecall: mockSemanticRecall,
|
|
226
|
+
vectorStore: mockVectorStore,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Context output should contain actual conversation text chunks
|
|
230
|
+
expect(result.conversations).toEqual(
|
|
231
|
+
expect.arrayContaining([
|
|
232
|
+
expect.objectContaining({
|
|
233
|
+
id: 'conv-1',
|
|
234
|
+
text: expect.stringContaining('JWT'),
|
|
235
|
+
}),
|
|
236
|
+
expect.objectContaining({
|
|
237
|
+
id: 'conv-2',
|
|
238
|
+
text: expect.stringContaining('migration'),
|
|
239
|
+
}),
|
|
240
|
+
])
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('conversation summaries are concise (not full exchanges)', async () => {
|
|
245
|
+
// Conversations returned should have text that is a summary, not a
|
|
246
|
+
// full multi-turn exchange. The text in each result should be
|
|
247
|
+
// a concise chunk suitable for context injection.
|
|
248
|
+
const longExchange = 'A'.repeat(5000); // Simulating a very long conversation
|
|
249
|
+
const conversationResults = [
|
|
250
|
+
createConversationResult({
|
|
251
|
+
id: 'conv-long',
|
|
252
|
+
text: longExchange,
|
|
253
|
+
score: 0.85,
|
|
254
|
+
}),
|
|
255
|
+
];
|
|
256
|
+
mockSemanticRecall = createMockSemanticRecall(conversationResults);
|
|
257
|
+
|
|
258
|
+
const context = {
|
|
259
|
+
projectId: 'my-project',
|
|
260
|
+
workspace: '/ws',
|
|
261
|
+
branch: 'main',
|
|
262
|
+
touchedFiles: [],
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const result = await buildSemanticContext('/path/to/project', context, {
|
|
266
|
+
semanticRecall: mockSemanticRecall,
|
|
267
|
+
vectorStore: mockVectorStore,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// Each conversation in the output should be truncated to a concise summary
|
|
271
|
+
// (implementation should cap at a reasonable length, e.g., 300 chars)
|
|
272
|
+
for (const convo of result.conversations) {
|
|
273
|
+
expect(convo.text.length).toBeLessThanOrEqual(300);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe('formatContextForInjection', () => {
|
|
279
|
+
it('includes "Related Conversations" section when conversations exist', () => {
|
|
280
|
+
const semanticContext = {
|
|
281
|
+
conversations: [
|
|
282
|
+
createConversationResult({
|
|
283
|
+
id: 'conv-1',
|
|
284
|
+
text: 'Auth token refresh uses short-lived JWTs.',
|
|
285
|
+
score: 0.90,
|
|
286
|
+
}),
|
|
287
|
+
createConversationResult({
|
|
288
|
+
id: 'conv-2',
|
|
289
|
+
text: 'Database indexes on user_id for performance.',
|
|
290
|
+
score: 0.80,
|
|
291
|
+
}),
|
|
292
|
+
],
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const formatted = formatContextForInjection(semanticContext);
|
|
296
|
+
|
|
297
|
+
// Should contain the Related Conversations header
|
|
298
|
+
expect(formatted).toContain('## Related Conversations');
|
|
299
|
+
// Should contain the conversation text
|
|
300
|
+
expect(formatted).toContain('Auth token refresh');
|
|
301
|
+
expect(formatted).toContain('Database indexes');
|
|
302
|
+
// Should be valid markdown
|
|
303
|
+
expect(formatted).toContain('- ');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('omits conversations section when conversations array is empty', () => {
|
|
307
|
+
const semanticContext = {
|
|
308
|
+
conversations: [],
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const formatted = formatContextForInjection(semanticContext);
|
|
312
|
+
|
|
313
|
+
// Should NOT contain the Related Conversations header
|
|
314
|
+
expect(formatted).not.toContain('## Related Conversations');
|
|
315
|
+
// Formatted output may be empty or contain other sections, but no conversations
|
|
316
|
+
expect(formatted).not.toContain('conversation');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('backward compatible: without vector store, produces same format', () => {
|
|
320
|
+
// When no vector store was used, semanticContext has empty conversations.
|
|
321
|
+
// The formatted output should match the pre-Task-7 format: no new sections,
|
|
322
|
+
// no "Related Conversations" header, just the existing context structure.
|
|
323
|
+
const semanticContextWithoutVector = {
|
|
324
|
+
conversations: [],
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const formatted = formatContextForInjection(semanticContextWithoutVector);
|
|
328
|
+
|
|
329
|
+
// No new sections should appear
|
|
330
|
+
expect(formatted).not.toContain('## Related Conversations');
|
|
331
|
+
|
|
332
|
+
// The output should be a string (possibly empty) that doesn't break
|
|
333
|
+
// existing CLAUDE.md injection
|
|
334
|
+
expect(typeof formatted).toBe('string');
|
|
335
|
+
|
|
336
|
+
// If there's nothing to inject, the result should be empty or whitespace-only
|
|
337
|
+
expect(formatted.trim()).toBe('');
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
});
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation Chunker — splits exchanges into topic-coherent chunks.
|
|
3
|
+
*
|
|
4
|
+
* Detects boundaries from TLC commands (hard), user signals (soft),
|
|
5
|
+
* and topic shifts (semantic). Generates titles, summaries, and metadata.
|
|
6
|
+
*
|
|
7
|
+
* @module conversation-chunker
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import crypto from 'crypto';
|
|
11
|
+
|
|
12
|
+
/** TLC command patterns that trigger hard boundaries */
|
|
13
|
+
const TLC_COMMAND_PATTERN = /^\/?tlc:/i;
|
|
14
|
+
|
|
15
|
+
/** User signals that indicate topic transition (soft boundaries) */
|
|
16
|
+
const SOFT_BOUNDARY_SIGNALS = [
|
|
17
|
+
/^ok\b/i,
|
|
18
|
+
/^okay\b/i,
|
|
19
|
+
/^done\b/i,
|
|
20
|
+
/^next\b/i,
|
|
21
|
+
/^moving on/i,
|
|
22
|
+
/^let'?s move on/i,
|
|
23
|
+
/^let'?s build/i,
|
|
24
|
+
/^let'?s do/i,
|
|
25
|
+
/^sounds good/i,
|
|
26
|
+
/^got it/i,
|
|
27
|
+
/^alright/i,
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** File path pattern */
|
|
31
|
+
const FILE_PATH_PATTERN = /(?:^|\s|['"`(])([a-zA-Z0-9._-]+\/[a-zA-Z0-9._\-/]+\.[a-zA-Z]{1,10})(?:['"`)\s,]|$)/g;
|
|
32
|
+
|
|
33
|
+
/** Decision patterns */
|
|
34
|
+
const DECISION_PATTERNS = [
|
|
35
|
+
/let'?s use (\S+.*?)(?:\.|$)/gi,
|
|
36
|
+
/we (?:decided|chose|agreed) to (.*?)(?:\.|$)/gi,
|
|
37
|
+
/(?:going|switched?) (?:with|to) (.*?)(?:\.|$)/gi,
|
|
38
|
+
/instead of (\S+),?\s+(?:we'?ll|let'?s|use) (.*?)(?:\.|$)/gi,
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/** Minimum keyword overlap ratio to consider exchanges on the same topic */
|
|
42
|
+
const SEMANTIC_SIMILARITY_THRESHOLD = 0.15;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Extract significant words from text for keyword overlap comparison.
|
|
46
|
+
* @param {string} text
|
|
47
|
+
* @returns {Set<string>}
|
|
48
|
+
*/
|
|
49
|
+
function extractKeywords(text) {
|
|
50
|
+
const words = text.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/);
|
|
51
|
+
return new Set(words.filter((w) => w.length > 3));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Calculate keyword overlap ratio between two texts.
|
|
56
|
+
* @param {string} textA
|
|
57
|
+
* @param {string} textB
|
|
58
|
+
* @returns {number} 0-1 overlap ratio
|
|
59
|
+
*/
|
|
60
|
+
function keywordOverlap(textA, textB) {
|
|
61
|
+
const wordsA = extractKeywords(textA);
|
|
62
|
+
const wordsB = extractKeywords(textB);
|
|
63
|
+
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
|
64
|
+
let intersection = 0;
|
|
65
|
+
for (const w of wordsA) {
|
|
66
|
+
if (wordsB.has(w)) intersection++;
|
|
67
|
+
}
|
|
68
|
+
return intersection / Math.min(wordsA.size, wordsB.size);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get full text of an exchange (user + assistant combined).
|
|
73
|
+
* @param {object} exchange
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
function exchangeText(exchange) {
|
|
77
|
+
return `${exchange.user || ''} ${exchange.assistant || ''}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Detect whether a boundary exists between the current and previous exchange.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} exchange - Current exchange `{ user, assistant, timestamp }`
|
|
84
|
+
* @param {object|undefined} previousExchange - Previous exchange
|
|
85
|
+
* @returns {{ isBoundary: boolean, type: 'hard'|'soft'|'semantic'|null }}
|
|
86
|
+
*/
|
|
87
|
+
export function detectBoundary(exchange, previousExchange) {
|
|
88
|
+
// No boundary on first exchange
|
|
89
|
+
if (!previousExchange) {
|
|
90
|
+
return { isBoundary: false, type: null };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const userText = (exchange.user || '').trim();
|
|
94
|
+
|
|
95
|
+
// Hard boundary: TLC command invocation
|
|
96
|
+
if (TLC_COMMAND_PATTERN.test(userText)) {
|
|
97
|
+
return { isBoundary: true, type: 'hard' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Soft boundary: user transition signals
|
|
101
|
+
for (const pattern of SOFT_BOUNDARY_SIGNALS) {
|
|
102
|
+
if (pattern.test(userText)) {
|
|
103
|
+
return { isBoundary: true, type: 'soft' };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Semantic boundary: topic divergence via keyword overlap
|
|
108
|
+
// Skip semantic detection for very short exchanges (Q&A style)
|
|
109
|
+
const prevText = exchangeText(previousExchange);
|
|
110
|
+
const currText = exchangeText(exchange);
|
|
111
|
+
const prevKeywords = extractKeywords(prevText);
|
|
112
|
+
const currKeywords = extractKeywords(currText);
|
|
113
|
+
|
|
114
|
+
if (prevKeywords.size >= 5 && currKeywords.size >= 5) {
|
|
115
|
+
const overlap = keywordOverlap(prevText, currText);
|
|
116
|
+
if (overlap < SEMANTIC_SIMILARITY_THRESHOLD) {
|
|
117
|
+
return { isBoundary: true, type: 'semantic' };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { isBoundary: false, type: null };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Generate a deterministic hash-based ID for a chunk.
|
|
126
|
+
* @param {object[]} exchanges
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
function generateChunkId(exchanges) {
|
|
130
|
+
const content = exchanges.map((e) => `${e.timestamp}:${e.user}`).join('|');
|
|
131
|
+
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Generate a title from chunk exchanges. Extracts the most meaningful
|
|
136
|
+
* question or decision phrase from the first user message.
|
|
137
|
+
*
|
|
138
|
+
* @param {object[]} exchanges
|
|
139
|
+
* @returns {string}
|
|
140
|
+
*/
|
|
141
|
+
export function generateChunkTitle(exchanges) {
|
|
142
|
+
if (!exchanges || exchanges.length === 0) return 'Untitled';
|
|
143
|
+
|
|
144
|
+
const firstUser = (exchanges[0].user || '').trim();
|
|
145
|
+
|
|
146
|
+
// If it's a TLC command, use it as title
|
|
147
|
+
if (TLC_COMMAND_PATTERN.test(firstUser)) {
|
|
148
|
+
return firstUser;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// If it's a question, use it (truncated)
|
|
152
|
+
if (firstUser.includes('?')) {
|
|
153
|
+
const question = firstUser.split('?')[0] + '?';
|
|
154
|
+
return question.length > 80 ? question.slice(0, 77) + '...' : question;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Otherwise use the first meaningful sentence
|
|
158
|
+
const firstSentence = firstUser.split(/[.!?\n]/)[0].trim();
|
|
159
|
+
if (firstSentence.length > 0) {
|
|
160
|
+
return firstSentence.length > 80 ? firstSentence.slice(0, 77) + '...' : firstSentence;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return 'Untitled';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Generate a summary from chunk exchanges. Combines key points from
|
|
168
|
+
* user questions and assistant answers.
|
|
169
|
+
*
|
|
170
|
+
* @param {object[]} exchanges
|
|
171
|
+
* @returns {string}
|
|
172
|
+
*/
|
|
173
|
+
export function generateChunkSummary(exchanges) {
|
|
174
|
+
if (!exchanges || exchanges.length === 0) return '';
|
|
175
|
+
|
|
176
|
+
const parts = [];
|
|
177
|
+
|
|
178
|
+
// Summarize what was discussed
|
|
179
|
+
const firstUser = (exchanges[0].user || '').trim();
|
|
180
|
+
if (firstUser) {
|
|
181
|
+
parts.push(`Discussed: ${firstUser.length > 100 ? firstUser.slice(0, 97) + '...' : firstUser}.`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Summarize key response
|
|
185
|
+
const firstAssistant = (exchanges[0].assistant || '').trim();
|
|
186
|
+
if (firstAssistant) {
|
|
187
|
+
const firstSentence = firstAssistant.split(/[.!?]/)[0].trim();
|
|
188
|
+
if (firstSentence.length > 0) {
|
|
189
|
+
parts.push(`${firstSentence}.`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// If multiple exchanges, note the follow-up
|
|
194
|
+
if (exchanges.length > 1) {
|
|
195
|
+
parts.push(`${exchanges.length} exchanges in this topic.`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return parts.join(' ');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Extract metadata from chunk exchanges: file paths, project names,
|
|
203
|
+
* TLC commands used, and decisions made.
|
|
204
|
+
*
|
|
205
|
+
* @param {object[]} exchanges
|
|
206
|
+
* @returns {{ projects: string[], files: string[], commands: string[], decisions: string[] }}
|
|
207
|
+
*/
|
|
208
|
+
export function extractChunkMetadata(exchanges) {
|
|
209
|
+
const files = new Set();
|
|
210
|
+
const commands = new Set();
|
|
211
|
+
const decisions = [];
|
|
212
|
+
const projects = new Set();
|
|
213
|
+
|
|
214
|
+
for (const exchange of exchanges) {
|
|
215
|
+
const fullText = exchangeText(exchange);
|
|
216
|
+
const userText = (exchange.user || '');
|
|
217
|
+
|
|
218
|
+
// Extract file paths
|
|
219
|
+
let fileMatch;
|
|
220
|
+
const fileRegex = new RegExp(FILE_PATH_PATTERN.source, 'g');
|
|
221
|
+
while ((fileMatch = fileRegex.exec(fullText)) !== null) {
|
|
222
|
+
files.add(fileMatch[1]);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Extract TLC commands
|
|
226
|
+
const cmdMatch = userText.match(/\/?tlc:\S+/i);
|
|
227
|
+
if (cmdMatch) {
|
|
228
|
+
// Normalize to /tlc:command format (strip arguments)
|
|
229
|
+
const cmd = cmdMatch[0].startsWith('/') ? cmdMatch[0] : '/' + cmdMatch[0];
|
|
230
|
+
const cmdBase = cmd.split(/\s/)[0];
|
|
231
|
+
commands.add(cmdBase);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Extract decisions
|
|
235
|
+
for (const pattern of DECISION_PATTERNS) {
|
|
236
|
+
const regex = new RegExp(pattern.source, pattern.flags);
|
|
237
|
+
let decMatch;
|
|
238
|
+
while ((decMatch = regex.exec(fullText)) !== null) {
|
|
239
|
+
const decision = decMatch[0].trim();
|
|
240
|
+
if (decision.length > 5) {
|
|
241
|
+
decisions.push(decision);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Extract project names (heuristic: capitalized proper nouns that look like project names)
|
|
247
|
+
const projectPattern = /\b(?:the\s+)?([A-Z][a-zA-Z0-9-]+)(?:\s+(?:project|module|service|repo|package))/g;
|
|
248
|
+
let projMatch;
|
|
249
|
+
while ((projMatch = projectPattern.exec(fullText)) !== null) {
|
|
250
|
+
projects.add(projMatch[1]);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
files: [...files],
|
|
256
|
+
commands: [...commands],
|
|
257
|
+
decisions: [...new Set(decisions)],
|
|
258
|
+
projects: [...projects],
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Chunk a conversation into topic-coherent segments.
|
|
264
|
+
*
|
|
265
|
+
* @param {object[]} exchanges - Array of `{ user, assistant, timestamp }`
|
|
266
|
+
* @param {object} [options]
|
|
267
|
+
* @param {number} [options.minChunkSize=1] - Minimum exchanges per chunk
|
|
268
|
+
* @param {number} [options.maxChunkSize=8] - Maximum exchanges per chunk
|
|
269
|
+
* @returns {object[]} Array of chunks
|
|
270
|
+
*/
|
|
271
|
+
export function chunkConversation(exchanges, options = {}) {
|
|
272
|
+
if (!exchanges || exchanges.length === 0) return [];
|
|
273
|
+
|
|
274
|
+
const { minChunkSize = 1, maxChunkSize = 8 } = options;
|
|
275
|
+
|
|
276
|
+
const chunks = [];
|
|
277
|
+
let currentChunk = [];
|
|
278
|
+
|
|
279
|
+
for (let i = 0; i < exchanges.length; i++) {
|
|
280
|
+
const exchange = exchanges[i];
|
|
281
|
+
const prevExchange = i > 0 ? exchanges[i - 1] : undefined;
|
|
282
|
+
const boundary = detectBoundary(exchange, prevExchange);
|
|
283
|
+
|
|
284
|
+
// Check if we should split
|
|
285
|
+
const shouldSplit = boundary.isBoundary && currentChunk.length >= minChunkSize;
|
|
286
|
+
const exceedsMax = currentChunk.length >= maxChunkSize;
|
|
287
|
+
|
|
288
|
+
if ((shouldSplit || exceedsMax) && currentChunk.length > 0) {
|
|
289
|
+
chunks.push(buildChunk(currentChunk));
|
|
290
|
+
currentChunk = [];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
currentChunk.push(exchange);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Flush remaining
|
|
297
|
+
if (currentChunk.length > 0) {
|
|
298
|
+
chunks.push(buildChunk(currentChunk));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return chunks;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Build a chunk object from a set of exchanges.
|
|
306
|
+
* @param {object[]} exchanges
|
|
307
|
+
* @returns {object}
|
|
308
|
+
*/
|
|
309
|
+
function buildChunk(exchanges) {
|
|
310
|
+
return {
|
|
311
|
+
id: generateChunkId(exchanges),
|
|
312
|
+
title: generateChunkTitle(exchanges),
|
|
313
|
+
summary: generateChunkSummary(exchanges),
|
|
314
|
+
topic: generateChunkTitle(exchanges),
|
|
315
|
+
exchanges,
|
|
316
|
+
startTime: exchanges[0].timestamp,
|
|
317
|
+
endTime: exchanges[exchanges.length - 1].timestamp,
|
|
318
|
+
metadata: extractChunkMetadata(exchanges),
|
|
319
|
+
};
|
|
320
|
+
}
|