tlc-claude-code 2.4.3 → 2.4.4
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/build.md +7 -5
- package/.claude/commands/tlc/recall.md +59 -87
- package/.claude/commands/tlc/remember.md +76 -71
- package/.claude/commands/tlc/review.md +76 -21
- package/.claude/hooks/tlc-capture-exchange.sh +50 -21
- package/.claude/hooks/tlc-session-init.sh +30 -0
- package/bin/init.js +12 -3
- package/package.json +1 -1
- package/server/lib/capture/classifier.js +71 -0
- package/server/lib/capture/classifier.test.js +71 -0
- package/server/lib/capture/claude-capture.js +140 -0
- package/server/lib/capture/claude-capture.test.js +152 -0
- package/server/lib/capture/codex-capture.js +79 -0
- package/server/lib/capture/codex-capture.test.js +161 -0
- package/server/lib/capture/codex-event-parser.js +76 -0
- package/server/lib/capture/codex-event-parser.test.js +83 -0
- package/server/lib/capture/ensure-ready.js +56 -0
- package/server/lib/capture/ensure-ready.test.js +135 -0
- package/server/lib/capture/envelope.js +77 -0
- package/server/lib/capture/envelope.test.js +169 -0
- package/server/lib/capture/extractor.js +51 -0
- package/server/lib/capture/extractor.test.js +92 -0
- package/server/lib/capture/generic-capture.js +96 -0
- package/server/lib/capture/generic-capture.test.js +171 -0
- package/server/lib/capture/index.js +117 -0
- package/server/lib/capture/index.test.js +263 -0
- package/server/lib/capture/redactor.js +68 -0
- package/server/lib/capture/redactor.test.js +93 -0
- package/server/lib/capture/spool-processor.js +155 -0
- package/server/lib/capture/spool-processor.test.js +278 -0
- package/server/lib/health-check.js +255 -0
- package/server/lib/health-check.test.js +243 -0
- package/server/lib/orchestration/cli-dispatch.js +200 -0
- package/server/lib/orchestration/cli-dispatch.test.js +242 -0
- package/server/lib/orchestration/prompt-builder.js +118 -0
- package/server/lib/orchestration/prompt-builder.test.js +200 -0
- package/server/lib/orchestration/standalone-compat.js +39 -0
- package/server/lib/orchestration/standalone-compat.test.js +144 -0
- package/server/lib/orchestration/worktree-manager.js +43 -0
- package/server/lib/orchestration/worktree-manager.test.js +50 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const PATTERN_GROUPS = [
|
|
2
|
+
{
|
|
3
|
+
type: 'gotcha',
|
|
4
|
+
scope: 'team',
|
|
5
|
+
patterns: [
|
|
6
|
+
{ regex: /\bdon't do\b/i, confidence: 0.85 },
|
|
7
|
+
{ regex: /\bwatch out\b/i, confidence: 0.85 },
|
|
8
|
+
{ regex: /\bbreaks when\b/i, confidence: 0.9 },
|
|
9
|
+
{ regex: /\bcareful with\b/i, confidence: 0.8 },
|
|
10
|
+
{ regex: /\bnever use\b/i, confidence: 0.9 },
|
|
11
|
+
{ regex: /\bgotcha\b/i, confidence: 0.75 },
|
|
12
|
+
{ regex: /\bdon't use\b/i, confidence: 0.9 },
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
type: 'preference',
|
|
17
|
+
scope: 'personal',
|
|
18
|
+
patterns: [
|
|
19
|
+
{ regex: /\bi prefer\b/i, confidence: 0.8 },
|
|
20
|
+
{ regex: /\bi like\b/i, confidence: 0.7 },
|
|
21
|
+
{ regex: /\bfor me\b/i, confidence: 0.65 },
|
|
22
|
+
{ regex: /\bmy workflow\b/i, confidence: 0.75 },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
type: 'decision',
|
|
27
|
+
scope: 'team',
|
|
28
|
+
patterns: [
|
|
29
|
+
{ regex: /\bwe decided\b/i, confidence: 0.9 },
|
|
30
|
+
{ regex: /\bchose\b/i, confidence: 0.75 },
|
|
31
|
+
{ regex: /\bgoing with\b/i, confidence: 0.8 },
|
|
32
|
+
{ regex: /\bthe approach is\b/i, confidence: 0.8 },
|
|
33
|
+
{ regex: /\busing\b.+\bfor\b/i, confidence: 0.7 },
|
|
34
|
+
{ regex: /\bsplit into\b/i, confidence: 0.75 },
|
|
35
|
+
{ regex: /\bcreated\b/i, confidence: 0.7 },
|
|
36
|
+
{ regex: /\bimplemented\b/i, confidence: 0.7 },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
function classify(statement) {
|
|
42
|
+
const text = typeof statement === 'string' ? statement.trim() : '';
|
|
43
|
+
|
|
44
|
+
if (!text) {
|
|
45
|
+
return { type: 'session', scope: 'personal', confidence: 0.1 };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let bestMatch = null;
|
|
49
|
+
|
|
50
|
+
for (const group of PATTERN_GROUPS) {
|
|
51
|
+
for (const pattern of group.patterns) {
|
|
52
|
+
if (!pattern.regex.test(text)) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!bestMatch || pattern.confidence > bestMatch.confidence) {
|
|
57
|
+
bestMatch = {
|
|
58
|
+
type: group.type,
|
|
59
|
+
scope: group.scope,
|
|
60
|
+
confidence: pattern.confidence,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return bestMatch || { type: 'session', scope: 'personal', confidence: 0.1 };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = {
|
|
70
|
+
classify,
|
|
71
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { classify } = require('./classifier.js');
|
|
4
|
+
|
|
5
|
+
describe('capture/classifier', () => {
|
|
6
|
+
it('classifies explicit decision statements as decision/team', () => {
|
|
7
|
+
expect(classify('We decided to use Postgres')).toEqual({
|
|
8
|
+
type: 'decision',
|
|
9
|
+
scope: 'team',
|
|
10
|
+
confidence: expect.any(Number),
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('classifies gotcha statements as gotcha/team', () => {
|
|
15
|
+
const result = classify("Don't use synchronous fs calls");
|
|
16
|
+
|
|
17
|
+
expect(result.type).toBe('gotcha');
|
|
18
|
+
expect(result.scope).toBe('team');
|
|
19
|
+
expect(result.confidence).toBeGreaterThan(0.1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('classifies preference statements as preference/personal', () => {
|
|
23
|
+
const result = classify('I prefer dark theme');
|
|
24
|
+
|
|
25
|
+
expect(result).toEqual({
|
|
26
|
+
type: 'preference',
|
|
27
|
+
scope: 'personal',
|
|
28
|
+
confidence: expect.any(Number),
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns session/personal with low confidence for unknown text', () => {
|
|
33
|
+
expect(classify('Hello there')).toEqual({
|
|
34
|
+
type: 'session',
|
|
35
|
+
scope: 'personal',
|
|
36
|
+
confidence: 0.1,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('treats code-change language as a team decision', () => {
|
|
41
|
+
const result = classify('Created 3 modules');
|
|
42
|
+
|
|
43
|
+
expect(result.type).toBe('decision');
|
|
44
|
+
expect(result.scope).toBe('team');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('classifies using X for Y phrasing as a decision', () => {
|
|
48
|
+
const result = classify('Using Redis for rate limiting');
|
|
49
|
+
|
|
50
|
+
expect(result.type).toBe('decision');
|
|
51
|
+
expect(result.scope).toBe('team');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('classifies watch out phrasing as a gotcha', () => {
|
|
55
|
+
const result = classify('Watch out for stale closures in async loops');
|
|
56
|
+
|
|
57
|
+
expect(result.type).toBe('gotcha');
|
|
58
|
+
expect(result.scope).toBe('team');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('classifies workflow phrasing as a personal preference', () => {
|
|
62
|
+
const result = classify('My workflow is to review diffs before running tests');
|
|
63
|
+
|
|
64
|
+
expect(result.type).toBe('preference');
|
|
65
|
+
expect(result.scope).toBe('personal');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('keeps decision confidence above the default fallback confidence', () => {
|
|
69
|
+
expect(classify('Implemented request tracing').confidence).toBeGreaterThan(0.1);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
const { createEnvelope } = require('./envelope');
|
|
4
|
+
const { redact } = require('./redactor');
|
|
5
|
+
const { ensureMemoryReady } = require('./ensure-ready');
|
|
6
|
+
|
|
7
|
+
const SPOOL_PATH = path.join('.tlc', 'memory', '.spool.jsonl');
|
|
8
|
+
|
|
9
|
+
function parseHookInput(hookInput) {
|
|
10
|
+
if (typeof hookInput !== 'string') {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(hookInput);
|
|
16
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeContent(content) {
|
|
23
|
+
if (typeof content === 'string') {
|
|
24
|
+
return content;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (Array.isArray(content)) {
|
|
28
|
+
return content
|
|
29
|
+
.map((item) => {
|
|
30
|
+
if (!item || typeof item !== 'object') {
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (typeof item.text === 'string') {
|
|
35
|
+
return item.text;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (typeof item.content === 'string') {
|
|
39
|
+
return item.content;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return '';
|
|
43
|
+
})
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.join('\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function extractUserMessage(transcriptPath, fs) {
|
|
52
|
+
if (typeof transcriptPath !== 'string' || transcriptPath.trim() === '') {
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const content = fs.readFileSync(transcriptPath, 'utf8').trim();
|
|
58
|
+
|
|
59
|
+
if (!content) {
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const lines = content.split('\n');
|
|
64
|
+
let lastUserMessage = '';
|
|
65
|
+
|
|
66
|
+
for (const line of lines) {
|
|
67
|
+
if (!line.trim()) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const entry = JSON.parse(line);
|
|
73
|
+
|
|
74
|
+
if (entry.role !== 'user') {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const message = normalizeContent(entry.content || entry.message);
|
|
79
|
+
|
|
80
|
+
if (message.trim()) {
|
|
81
|
+
lastUserMessage = message;
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// Skip malformed transcript lines.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return lastUserMessage;
|
|
89
|
+
} catch {
|
|
90
|
+
return '';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function redactEnvelope(envelope) {
|
|
95
|
+
const userMessage = redact(envelope.metadata && envelope.metadata.userMessage ? envelope.metadata.userMessage : '');
|
|
96
|
+
const assistantMessage = redact(envelope.metadata && envelope.metadata.assistantMessage ? envelope.metadata.assistantMessage : '');
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
...envelope,
|
|
100
|
+
text: redact(envelope.text),
|
|
101
|
+
metadata: {
|
|
102
|
+
...envelope.metadata,
|
|
103
|
+
userMessage,
|
|
104
|
+
assistantMessage,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function captureClaudeExchange({ hookInput, projectDir, fs = require('fs') }) {
|
|
110
|
+
const parsed = parseHookInput(hookInput);
|
|
111
|
+
|
|
112
|
+
if (!parsed) {
|
|
113
|
+
return { captured: 0, skipped: 1 };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (typeof parsed.assistant_message !== 'string' || parsed.assistant_message.trim() === '') {
|
|
117
|
+
return { captured: 0, skipped: 0 };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const userMessage = extractUserMessage(parsed.transcript_path, fs);
|
|
121
|
+
const envelope = redactEnvelope(createEnvelope({
|
|
122
|
+
provider: 'claude',
|
|
123
|
+
source: 'stop-hook',
|
|
124
|
+
threadId: typeof parsed.session_id === 'string' ? parsed.session_id : undefined,
|
|
125
|
+
userMessage,
|
|
126
|
+
assistantMessage: parsed.assistant_message,
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
ensureMemoryReady(projectDir, { fs });
|
|
130
|
+
|
|
131
|
+
const spoolPath = path.join(projectDir, SPOOL_PATH);
|
|
132
|
+
fs.mkdirSync(path.dirname(spoolPath), { recursive: true });
|
|
133
|
+
fs.appendFileSync(spoolPath, `${JSON.stringify(envelope)}\n`);
|
|
134
|
+
|
|
135
|
+
return { captured: 1, skipped: 0 };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = {
|
|
139
|
+
captureClaudeExchange,
|
|
140
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
describe('capture/claude-capture', () => {
|
|
7
|
+
let projectDir;
|
|
8
|
+
let captureClaudeExchange;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
vi.useFakeTimers();
|
|
12
|
+
vi.setSystemTime(new Date('2026-03-28T12:34:56.000Z'));
|
|
13
|
+
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-claude-capture-test-'));
|
|
14
|
+
({ captureClaudeExchange } = await import('./claude-capture.js'));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.useRealTimers();
|
|
19
|
+
vi.resetModules();
|
|
20
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('returns skipped for malformed hook input', () => {
|
|
24
|
+
const result = captureClaudeExchange({
|
|
25
|
+
hookInput: '{"session_id"',
|
|
26
|
+
projectDir,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
expect(result).toEqual({ captured: 0, skipped: 1 });
|
|
30
|
+
expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', '.spool.jsonl'))).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns no-op when assistant_message is missing', () => {
|
|
34
|
+
const result = captureClaudeExchange({
|
|
35
|
+
hookInput: JSON.stringify({
|
|
36
|
+
session_id: 'sess-1',
|
|
37
|
+
transcript_path: path.join(projectDir, 'transcript.jsonl'),
|
|
38
|
+
}),
|
|
39
|
+
projectDir,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(result).toEqual({ captured: 0, skipped: 0 });
|
|
43
|
+
expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', '.spool.jsonl'))).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('captures the transcript user message and assistant message into the spool', () => {
|
|
47
|
+
const transcriptPath = path.join(projectDir, 'transcript.jsonl');
|
|
48
|
+
|
|
49
|
+
fs.writeFileSync(transcriptPath, [
|
|
50
|
+
JSON.stringify({ role: 'user', content: 'First prompt' }),
|
|
51
|
+
JSON.stringify({ role: 'assistant', content: 'First reply' }),
|
|
52
|
+
JSON.stringify({ role: 'user', content: 'Use PostgreSQL with JSONB' }),
|
|
53
|
+
].join('\n') + '\n');
|
|
54
|
+
|
|
55
|
+
const result = captureClaudeExchange({
|
|
56
|
+
hookInput: JSON.stringify({
|
|
57
|
+
session_id: 'sess-2',
|
|
58
|
+
cwd: projectDir,
|
|
59
|
+
transcript_path: transcriptPath,
|
|
60
|
+
assistant_message: 'We should use PostgreSQL with JSONB indexes.',
|
|
61
|
+
}),
|
|
62
|
+
projectDir,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(result).toEqual({ captured: 1, skipped: 0 });
|
|
66
|
+
|
|
67
|
+
const spoolPath = path.join(projectDir, '.tlc', 'memory', '.spool.jsonl');
|
|
68
|
+
const envelope = JSON.parse(fs.readFileSync(spoolPath, 'utf8').trim());
|
|
69
|
+
|
|
70
|
+
expect(envelope).toMatchObject({
|
|
71
|
+
provider: 'claude',
|
|
72
|
+
source: 'stop-hook',
|
|
73
|
+
threadId: 'sess-2',
|
|
74
|
+
text: 'Use PostgreSQL with JSONB\n\nWe should use PostgreSQL with JSONB indexes.',
|
|
75
|
+
timestamp: '2026-03-28T12:34:56.000Z',
|
|
76
|
+
metadata: {
|
|
77
|
+
userMessage: 'Use PostgreSQL with JSONB',
|
|
78
|
+
assistantMessage: 'We should use PostgreSQL with JSONB indexes.',
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('redacts sensitive values before appending the envelope', () => {
|
|
84
|
+
const transcriptPath = path.join(projectDir, 'transcript.jsonl');
|
|
85
|
+
|
|
86
|
+
fs.writeFileSync(transcriptPath, `${JSON.stringify({
|
|
87
|
+
role: 'user',
|
|
88
|
+
content: 'token="sk-secret123" path=/Users/jurgen/private/app.js',
|
|
89
|
+
})}\n`);
|
|
90
|
+
|
|
91
|
+
captureClaudeExchange({
|
|
92
|
+
hookInput: JSON.stringify({
|
|
93
|
+
session_id: 'sess-3',
|
|
94
|
+
transcript_path: transcriptPath,
|
|
95
|
+
assistant_message: 'password=hunter2 Bearer abc.def.ghi',
|
|
96
|
+
}),
|
|
97
|
+
projectDir,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const spoolPath = path.join(projectDir, '.tlc', 'memory', '.spool.jsonl');
|
|
101
|
+
const envelope = JSON.parse(fs.readFileSync(spoolPath, 'utf8').trim());
|
|
102
|
+
|
|
103
|
+
expect(envelope.text).toContain('[TOKEN_REDACTED]');
|
|
104
|
+
expect(envelope.text).toContain('[REDACTED]');
|
|
105
|
+
expect(envelope.text).toContain('Bearer [TOKEN_REDACTED]');
|
|
106
|
+
expect(envelope.text).toContain('~/private/app.js');
|
|
107
|
+
expect(envelope.text).not.toContain('hunter2');
|
|
108
|
+
expect(envelope.text).not.toContain('/Users/jurgen');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('appends new envelopes instead of overwriting the spool file', () => {
|
|
112
|
+
const spoolPath = path.join(projectDir, '.tlc', 'memory', '.spool.jsonl');
|
|
113
|
+
fs.mkdirSync(path.dirname(spoolPath), { recursive: true });
|
|
114
|
+
fs.writeFileSync(spoolPath, `${JSON.stringify({ existing: true })}\n`);
|
|
115
|
+
|
|
116
|
+
captureClaudeExchange({
|
|
117
|
+
hookInput: JSON.stringify({
|
|
118
|
+
session_id: 'sess-4',
|
|
119
|
+
assistant_message: 'Assistant only message',
|
|
120
|
+
}),
|
|
121
|
+
projectDir,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const lines = fs.readFileSync(spoolPath, 'utf8').trim().split('\n');
|
|
125
|
+
|
|
126
|
+
expect(lines).toHaveLength(2);
|
|
127
|
+
expect(JSON.parse(lines[0])).toEqual({ existing: true });
|
|
128
|
+
expect(JSON.parse(lines[1])).toMatchObject({
|
|
129
|
+
provider: 'claude',
|
|
130
|
+
source: 'stop-hook',
|
|
131
|
+
threadId: 'sess-4',
|
|
132
|
+
text: 'Assistant only message',
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('initializes the memory tree before writing the spool file', () => {
|
|
137
|
+
captureClaudeExchange({
|
|
138
|
+
hookInput: JSON.stringify({
|
|
139
|
+
session_id: 'sess-5',
|
|
140
|
+
assistant_message: 'Create the memory tree first',
|
|
141
|
+
}),
|
|
142
|
+
projectDir,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', '.team'))).toBe(false);
|
|
146
|
+
expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', 'team', 'decisions'))).toBe(true);
|
|
147
|
+
expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', 'team', 'gotchas'))).toBe(true);
|
|
148
|
+
expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', '.local', 'sessions'))).toBe(true);
|
|
149
|
+
expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', 'team', 'decisions', '.gitkeep'))).toBe(true);
|
|
150
|
+
expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', 'team', 'gotchas', '.gitkeep'))).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
const { createEnvelope } = require('./envelope');
|
|
4
|
+
const { redact } = require('./redactor');
|
|
5
|
+
const { ensureMemoryReady } = require('./ensure-ready');
|
|
6
|
+
const { parseCodexEvents } = require('./codex-event-parser');
|
|
7
|
+
|
|
8
|
+
const SPOOL_FILENAME = '.spool.jsonl';
|
|
9
|
+
|
|
10
|
+
function resolveCaptureText({ lastMessageFile, jsonStream, stdout, fs }) {
|
|
11
|
+
if (lastMessageFile && fs.existsSync(lastMessageFile)) {
|
|
12
|
+
return {
|
|
13
|
+
source: 'last-message-file',
|
|
14
|
+
text: fs.readFileSync(lastMessageFile, 'utf8'),
|
|
15
|
+
parsedThreadId: null,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (typeof jsonStream === 'string') {
|
|
20
|
+
const parsed = parseCodexEvents(jsonStream);
|
|
21
|
+
const joinedMessages = parsed.agentMessages.join('\n\n');
|
|
22
|
+
|
|
23
|
+
if (joinedMessages.trim() !== '') {
|
|
24
|
+
return {
|
|
25
|
+
source: 'json-stream',
|
|
26
|
+
text: joinedMessages,
|
|
27
|
+
parsedThreadId: parsed.threadId,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
source: 'stdout',
|
|
34
|
+
text: typeof stdout === 'string' ? stdout : '',
|
|
35
|
+
parsedThreadId: null,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function captureCodexOutput({
|
|
40
|
+
lastMessageFile,
|
|
41
|
+
jsonStream,
|
|
42
|
+
stdout,
|
|
43
|
+
taskName,
|
|
44
|
+
threadId,
|
|
45
|
+
projectDir,
|
|
46
|
+
fs = require('fs'),
|
|
47
|
+
}) {
|
|
48
|
+
const capture = resolveCaptureText({ lastMessageFile, jsonStream, stdout, fs });
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const envelope = createEnvelope({
|
|
52
|
+
provider: 'codex',
|
|
53
|
+
source: capture.source,
|
|
54
|
+
taskName,
|
|
55
|
+
threadId: threadId || capture.parsedThreadId,
|
|
56
|
+
assistantMessage: capture.text,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
envelope.text = redact(envelope.text);
|
|
60
|
+
if (envelope.metadata && typeof envelope.metadata.assistantMessage === 'string') {
|
|
61
|
+
envelope.metadata.assistantMessage = redact(envelope.metadata.assistantMessage);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
ensureMemoryReady(projectDir, { fs });
|
|
65
|
+
|
|
66
|
+
const spoolPath = path.join(projectDir, '.tlc', 'memory', SPOOL_FILENAME);
|
|
67
|
+
fs.appendFileSync(spoolPath, JSON.stringify(envelope) + '\n', 'utf8');
|
|
68
|
+
|
|
69
|
+
return { captured: 1, skipped: 0 };
|
|
70
|
+
} catch (_) {
|
|
71
|
+
return { captured: 0, skipped: 1 };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = {
|
|
76
|
+
captureCodexOutput,
|
|
77
|
+
resolveCaptureText,
|
|
78
|
+
SPOOL_FILENAME,
|
|
79
|
+
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
import { captureCodexOutput } from './codex-capture.js';
|
|
7
|
+
|
|
8
|
+
const SPOOL_PATH = ['.tlc', 'memory', '.spool.jsonl'];
|
|
9
|
+
|
|
10
|
+
describe('capture/codex-capture', () => {
|
|
11
|
+
let projectDir;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-codex-capture-'));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.restoreAllMocks();
|
|
19
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function readSpoolEntries() {
|
|
23
|
+
const spoolPath = path.join(projectDir, ...SPOOL_PATH);
|
|
24
|
+
const content = fs.readFileSync(spoolPath, 'utf8').trim();
|
|
25
|
+
return content.split('\n').filter(Boolean).map((line) => JSON.parse(line));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
it('prefers lastMessageFile when it exists', () => {
|
|
29
|
+
const lastMessageFile = path.join(projectDir, 'last-message.txt');
|
|
30
|
+
fs.writeFileSync(lastMessageFile, 'Message from file');
|
|
31
|
+
|
|
32
|
+
const result = captureCodexOutput({
|
|
33
|
+
lastMessageFile,
|
|
34
|
+
jsonStream: '{"type":"agent_message","message":{"items":[{"type":"text","text":"json"}]}}',
|
|
35
|
+
stdout: 'raw stdout',
|
|
36
|
+
taskName: 'capture',
|
|
37
|
+
threadId: 'thread-from-arg',
|
|
38
|
+
projectDir,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const [entry] = readSpoolEntries();
|
|
42
|
+
|
|
43
|
+
expect(result).toEqual({ captured: 1, skipped: 0 });
|
|
44
|
+
expect(entry.provider).toBe('codex');
|
|
45
|
+
expect(entry.source).toBe('last-message-file');
|
|
46
|
+
expect(entry.text).toBe('Message from file');
|
|
47
|
+
expect(entry.threadId).toBe('thread-from-arg');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('falls back to agent messages from jsonStream when no lastMessageFile is available', () => {
|
|
51
|
+
const jsonStream = [
|
|
52
|
+
'{"type":"thread.started","thread_id":"thread-from-json"}',
|
|
53
|
+
JSON.stringify({
|
|
54
|
+
type: 'agent_message',
|
|
55
|
+
message: {
|
|
56
|
+
items: [
|
|
57
|
+
{ type: 'text', text: 'First agent message' },
|
|
58
|
+
{ type: 'text', text: 'Second agent message' },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
].join('\n');
|
|
63
|
+
|
|
64
|
+
const result = captureCodexOutput({
|
|
65
|
+
lastMessageFile: path.join(projectDir, 'missing.txt'),
|
|
66
|
+
jsonStream,
|
|
67
|
+
stdout: 'raw stdout',
|
|
68
|
+
taskName: 'capture',
|
|
69
|
+
threadId: null,
|
|
70
|
+
projectDir,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const [entry] = readSpoolEntries();
|
|
74
|
+
|
|
75
|
+
expect(result).toEqual({ captured: 1, skipped: 0 });
|
|
76
|
+
expect(entry.source).toBe('json-stream');
|
|
77
|
+
expect(entry.threadId).toBe('thread-from-json');
|
|
78
|
+
expect(entry.text).toBe('First agent message\n\nSecond agent message');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('falls back to raw stdout when jsonStream has no agent messages', () => {
|
|
82
|
+
const result = captureCodexOutput({
|
|
83
|
+
jsonStream: '{"type":"status","message":"booting"}\n',
|
|
84
|
+
stdout: 'stdout fallback text',
|
|
85
|
+
taskName: 'capture',
|
|
86
|
+
threadId: 'thread-stdout',
|
|
87
|
+
projectDir,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const [entry] = readSpoolEntries();
|
|
91
|
+
|
|
92
|
+
expect(result).toEqual({ captured: 1, skipped: 0 });
|
|
93
|
+
expect(entry.source).toBe('stdout');
|
|
94
|
+
expect(entry.text).toBe('stdout fallback text');
|
|
95
|
+
expect(entry.threadId).toBe('thread-stdout');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('redacts sensitive values before appending to spool', () => {
|
|
99
|
+
captureCodexOutput({
|
|
100
|
+
stdout: 'token=abc123 sk-secret-key /Users/jurgen/private/file.js',
|
|
101
|
+
taskName: 'capture',
|
|
102
|
+
threadId: 'thread-redact',
|
|
103
|
+
projectDir,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const [entry] = readSpoolEntries();
|
|
107
|
+
|
|
108
|
+
expect(entry.text).toContain('[TOKEN_REDACTED]');
|
|
109
|
+
expect(entry.text).toContain('[API_KEY_REDACTED]');
|
|
110
|
+
expect(entry.text).toContain('~/private/file.js');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('creates the memory spool path before writing', () => {
|
|
114
|
+
captureCodexOutput({
|
|
115
|
+
stdout: 'create spool dirs',
|
|
116
|
+
taskName: 'capture',
|
|
117
|
+
threadId: 'thread-dirs',
|
|
118
|
+
projectDir,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory'))).toBe(true);
|
|
122
|
+
expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', '.spool.jsonl'))).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('skips capture when all candidate sources are empty', () => {
|
|
126
|
+
const result = captureCodexOutput({
|
|
127
|
+
stdout: ' ',
|
|
128
|
+
jsonStream: 'not-json\n',
|
|
129
|
+
taskName: 'capture',
|
|
130
|
+
threadId: 'thread-empty',
|
|
131
|
+
projectDir,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const spoolPath = path.join(projectDir, ...SPOOL_PATH);
|
|
135
|
+
|
|
136
|
+
expect(result).toEqual({ captured: 0, skipped: 1 });
|
|
137
|
+
expect(fs.existsSync(spoolPath)).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('supports injected fs implementations', () => {
|
|
141
|
+
const injectedFs = {
|
|
142
|
+
...fs,
|
|
143
|
+
appendFileSync: vi.fn(fs.appendFileSync),
|
|
144
|
+
existsSync: vi.fn(fs.existsSync),
|
|
145
|
+
mkdirSync: vi.fn(fs.mkdirSync),
|
|
146
|
+
readFileSync: vi.fn(fs.readFileSync),
|
|
147
|
+
writeFileSync: vi.fn(fs.writeFileSync),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const result = captureCodexOutput({
|
|
151
|
+
stdout: 'injected fs',
|
|
152
|
+
taskName: 'capture',
|
|
153
|
+
threadId: 'thread-injected',
|
|
154
|
+
projectDir,
|
|
155
|
+
fs: injectedFs,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(result).toEqual({ captured: 1, skipped: 0 });
|
|
159
|
+
expect(injectedFs.appendFileSync).toHaveBeenCalledTimes(1);
|
|
160
|
+
});
|
|
161
|
+
});
|