tlc-claude-code 2.4.3 → 2.4.5
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 +33 -13
- 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
- package/server/lib/task-router-config.js +22 -5
- package/server/lib/task-router-config.test.js +46 -13
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
function parseCodexEventLine(line) {
|
|
2
|
+
if (typeof line !== 'string' || line.trim() === '') {
|
|
3
|
+
return null;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(line);
|
|
8
|
+
} catch (_) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function extractThreadId(event) {
|
|
14
|
+
if (
|
|
15
|
+
event &&
|
|
16
|
+
event.type === 'thread.started' &&
|
|
17
|
+
typeof event.thread_id === 'string' &&
|
|
18
|
+
event.thread_id.trim() !== ''
|
|
19
|
+
) {
|
|
20
|
+
return event.thread_id;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function extractAgentMessages(event) {
|
|
27
|
+
if (!event || event.type !== 'agent_message') {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const items = Array.isArray(event.message && event.message.items)
|
|
32
|
+
? event.message.items
|
|
33
|
+
: Array.isArray(event.items)
|
|
34
|
+
? event.items
|
|
35
|
+
: [];
|
|
36
|
+
|
|
37
|
+
return items
|
|
38
|
+
.filter((item) => item && item.type === 'text' && typeof item.text === 'string' && item.text.trim() !== '')
|
|
39
|
+
.map((item) => item.text);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseCodexEvents(jsonStream) {
|
|
43
|
+
const lines = typeof jsonStream === 'string' ? jsonStream.split('\n') : [];
|
|
44
|
+
const events = [];
|
|
45
|
+
const agentMessages = [];
|
|
46
|
+
let threadId = null;
|
|
47
|
+
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
const event = parseCodexEventLine(line);
|
|
50
|
+
if (!event) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
events.push(event);
|
|
55
|
+
|
|
56
|
+
const nextThreadId = extractThreadId(event);
|
|
57
|
+
if (nextThreadId) {
|
|
58
|
+
threadId = nextThreadId;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
agentMessages.push(...extractAgentMessages(event));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
threadId,
|
|
66
|
+
agentMessages,
|
|
67
|
+
events,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = {
|
|
72
|
+
extractAgentMessages,
|
|
73
|
+
extractThreadId,
|
|
74
|
+
parseCodexEventLine,
|
|
75
|
+
parseCodexEvents,
|
|
76
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { parseCodexEvents } from './codex-event-parser.js';
|
|
4
|
+
|
|
5
|
+
describe('capture/codex-event-parser', () => {
|
|
6
|
+
it('extracts threadId from thread.started events', () => {
|
|
7
|
+
const result = parseCodexEvents([
|
|
8
|
+
'{"type":"status","message":"booting"}',
|
|
9
|
+
'{"type":"thread.started","thread_id":"thread_123"}',
|
|
10
|
+
].join('\n'));
|
|
11
|
+
|
|
12
|
+
expect(result.threadId).toBe('thread_123');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('extracts text from agent_message item arrays', () => {
|
|
16
|
+
const result = parseCodexEvents([
|
|
17
|
+
JSON.stringify({
|
|
18
|
+
type: 'agent_message',
|
|
19
|
+
message: {
|
|
20
|
+
items: [
|
|
21
|
+
{ type: 'text', text: 'First message' },
|
|
22
|
+
{ type: 'image', url: 'ignored' },
|
|
23
|
+
{ type: 'text', text: 'Second message' },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
].join('\n'));
|
|
28
|
+
|
|
29
|
+
expect(result.agentMessages).toEqual(['First message', 'Second message']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('preserves all parsed JSON events while skipping malformed lines', () => {
|
|
33
|
+
const result = parseCodexEvents([
|
|
34
|
+
'{"type":"status","message":"booting"}',
|
|
35
|
+
'not-json',
|
|
36
|
+
'{"type":"thread.started","thread_id":"thread_999"}',
|
|
37
|
+
].join('\n'));
|
|
38
|
+
|
|
39
|
+
expect(result.events).toEqual([
|
|
40
|
+
{ type: 'status', message: 'booting' },
|
|
41
|
+
{ type: 'thread.started', thread_id: 'thread_999' },
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns null threadId when no thread.started event exists', () => {
|
|
46
|
+
const result = parseCodexEvents('{"type":"status","message":"booting"}\n');
|
|
47
|
+
|
|
48
|
+
expect(result.threadId).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('ignores blank lines and non-agent_message events', () => {
|
|
52
|
+
const result = parseCodexEvents([
|
|
53
|
+
'',
|
|
54
|
+
'{"type":"status","message":"booting"}',
|
|
55
|
+
'',
|
|
56
|
+
'{"type":"message.delta","delta":"partial"}',
|
|
57
|
+
'',
|
|
58
|
+
].join('\n'));
|
|
59
|
+
|
|
60
|
+
expect(result.agentMessages).toEqual([]);
|
|
61
|
+
expect(result.events).toEqual([
|
|
62
|
+
{ type: 'status', message: 'booting' },
|
|
63
|
+
{ type: 'message.delta', delta: 'partial' },
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('handles missing or non-string agent message text safely', () => {
|
|
68
|
+
const result = parseCodexEvents([
|
|
69
|
+
JSON.stringify({
|
|
70
|
+
type: 'agent_message',
|
|
71
|
+
message: {
|
|
72
|
+
items: [
|
|
73
|
+
{ type: 'text', text: '' },
|
|
74
|
+
{ type: 'text', text: 42 },
|
|
75
|
+
{ type: 'text', text: 'usable' },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
].join('\n'));
|
|
80
|
+
|
|
81
|
+
expect(result.agentMessages).toEqual(['usable']);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
const REQUIRED_DIRECTORIES = [
|
|
4
|
+
path.join('.tlc', 'memory', 'team', 'decisions'),
|
|
5
|
+
path.join('.tlc', 'memory', 'team', 'gotchas'),
|
|
6
|
+
path.join('.tlc', 'memory', '.local', 'sessions'),
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const REQUIRED_GITKEEPS = [
|
|
10
|
+
path.join('.tlc', 'memory', 'team', 'decisions', '.gitkeep'),
|
|
11
|
+
path.join('.tlc', 'memory', 'team', 'gotchas', '.gitkeep'),
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function ensureDirectory(projectDir, relativePath, fs, created, existing) {
|
|
15
|
+
const fullPath = path.join(projectDir, relativePath);
|
|
16
|
+
|
|
17
|
+
if (fs.existsSync(fullPath)) {
|
|
18
|
+
existing.push(relativePath);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
fs.mkdirSync(fullPath, { recursive: true });
|
|
23
|
+
created.push(relativePath);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ensureFile(projectDir, relativePath, fs, created, existing) {
|
|
27
|
+
const fullPath = path.join(projectDir, relativePath);
|
|
28
|
+
|
|
29
|
+
if (fs.existsSync(fullPath)) {
|
|
30
|
+
existing.push(relativePath);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
35
|
+
fs.writeFileSync(fullPath, '');
|
|
36
|
+
created.push(relativePath);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ensureMemoryReady(projectDir, { fs = require('fs') } = {}) {
|
|
40
|
+
const created = [];
|
|
41
|
+
const existing = [];
|
|
42
|
+
|
|
43
|
+
for (const relativePath of REQUIRED_DIRECTORIES) {
|
|
44
|
+
ensureDirectory(projectDir, relativePath, fs, created, existing);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const relativePath of REQUIRED_GITKEEPS) {
|
|
48
|
+
ensureFile(projectDir, relativePath, fs, created, existing);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { created, existing };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = {
|
|
55
|
+
ensureMemoryReady,
|
|
56
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
const TARGETS = [
|
|
7
|
+
path.join('.tlc', 'memory', 'team', 'decisions'),
|
|
8
|
+
path.join('.tlc', 'memory', 'team', 'gotchas'),
|
|
9
|
+
path.join('.tlc', 'memory', '.local', 'sessions'),
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const GITKEEPS = [
|
|
13
|
+
path.join('.tlc', 'memory', 'team', 'decisions', '.gitkeep'),
|
|
14
|
+
path.join('.tlc', 'memory', 'team', 'gotchas', '.gitkeep'),
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
describe('ensureMemoryReady', () => {
|
|
18
|
+
let projectDir;
|
|
19
|
+
let ensureMemoryReady;
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-ensure-ready-test-'));
|
|
23
|
+
({ ensureMemoryReady } = await import('./ensure-ready.js'));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
vi.resetModules();
|
|
28
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('creates the canonical memory directories and team .gitkeep files', () => {
|
|
32
|
+
const result = ensureMemoryReady(projectDir);
|
|
33
|
+
|
|
34
|
+
expect(result.created.slice().sort()).toEqual([...TARGETS, ...GITKEEPS].sort());
|
|
35
|
+
expect(result.existing).toEqual([]);
|
|
36
|
+
|
|
37
|
+
for (const relativePath of TARGETS) {
|
|
38
|
+
expect(fs.existsSync(path.join(projectDir, relativePath))).toBe(true);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const relativePath of GITKEEPS) {
|
|
42
|
+
expect(fs.existsSync(path.join(projectDir, relativePath))).toBe(true);
|
|
43
|
+
expect(fs.readFileSync(path.join(projectDir, relativePath), 'utf8')).toBe('');
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('is idempotent across repeated calls', () => {
|
|
48
|
+
const first = ensureMemoryReady(projectDir);
|
|
49
|
+
const second = ensureMemoryReady(projectDir);
|
|
50
|
+
|
|
51
|
+
expect(first.created.slice().sort()).toEqual([...TARGETS, ...GITKEEPS].sort());
|
|
52
|
+
expect(second.created).toEqual([]);
|
|
53
|
+
expect(second.existing.slice().sort()).toEqual([...TARGETS, ...GITKEEPS].sort());
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('reports pre-existing directories and .gitkeep files separately from new ones', () => {
|
|
57
|
+
fs.mkdirSync(path.join(projectDir, '.tlc', 'memory', 'team', 'decisions'), { recursive: true });
|
|
58
|
+
fs.writeFileSync(path.join(projectDir, '.tlc', 'memory', 'team', 'decisions', '.gitkeep'), '');
|
|
59
|
+
fs.mkdirSync(path.join(projectDir, '.tlc', 'memory', '.local', 'sessions'), { recursive: true });
|
|
60
|
+
|
|
61
|
+
const result = ensureMemoryReady(projectDir);
|
|
62
|
+
|
|
63
|
+
expect(result.created.slice().sort()).toEqual([
|
|
64
|
+
path.join('.tlc', 'memory', 'team', 'gotchas'),
|
|
65
|
+
path.join('.tlc', 'memory', 'team', 'gotchas', '.gitkeep'),
|
|
66
|
+
].sort());
|
|
67
|
+
expect(result.existing.slice().sort()).toEqual([
|
|
68
|
+
path.join('.tlc', 'memory', 'team', 'decisions'),
|
|
69
|
+
path.join('.tlc', 'memory', 'team', 'decisions', '.gitkeep'),
|
|
70
|
+
path.join('.tlc', 'memory', '.local', 'sessions'),
|
|
71
|
+
].sort());
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('creates missing .gitkeep files when team directories already exist', () => {
|
|
75
|
+
fs.mkdirSync(path.join(projectDir, '.tlc', 'memory', 'team', 'decisions'), { recursive: true });
|
|
76
|
+
fs.mkdirSync(path.join(projectDir, '.tlc', 'memory', 'team', 'gotchas'), { recursive: true });
|
|
77
|
+
fs.mkdirSync(path.join(projectDir, '.tlc', 'memory', '.local', 'sessions'), { recursive: true });
|
|
78
|
+
|
|
79
|
+
const result = ensureMemoryReady(projectDir);
|
|
80
|
+
|
|
81
|
+
expect(result.created.slice().sort()).toEqual(GITKEEPS.slice().sort());
|
|
82
|
+
expect(result.existing.slice().sort()).toEqual(TARGETS.slice().sort());
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('does not overwrite existing .gitkeep content', () => {
|
|
86
|
+
const decisionsGitkeep = path.join(projectDir, '.tlc', 'memory', 'team', 'decisions', '.gitkeep');
|
|
87
|
+
const gotchasGitkeep = path.join(projectDir, '.tlc', 'memory', 'team', 'gotchas', '.gitkeep');
|
|
88
|
+
|
|
89
|
+
fs.mkdirSync(path.dirname(decisionsGitkeep), { recursive: true });
|
|
90
|
+
fs.mkdirSync(path.dirname(gotchasGitkeep), { recursive: true });
|
|
91
|
+
fs.mkdirSync(path.join(projectDir, '.tlc', 'memory', '.local', 'sessions'), { recursive: true });
|
|
92
|
+
fs.writeFileSync(decisionsGitkeep, 'keep me\n');
|
|
93
|
+
fs.writeFileSync(gotchasGitkeep, 'still here\n');
|
|
94
|
+
|
|
95
|
+
const result = ensureMemoryReady(projectDir);
|
|
96
|
+
|
|
97
|
+
expect(result.created).toEqual([]);
|
|
98
|
+
expect(result.existing.slice().sort()).toEqual([...TARGETS, ...GITKEEPS].sort());
|
|
99
|
+
expect(fs.readFileSync(decisionsGitkeep, 'utf8')).toBe('keep me\n');
|
|
100
|
+
expect(fs.readFileSync(gotchasGitkeep, 'utf8')).toBe('still here\n');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('creates parent directories recursively from an empty project root', () => {
|
|
104
|
+
ensureMemoryReady(projectDir);
|
|
105
|
+
|
|
106
|
+
expect(fs.existsSync(path.join(projectDir, '.tlc'))).toBe(true);
|
|
107
|
+
expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', 'team'))).toBe(true);
|
|
108
|
+
expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', '.local'))).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('supports injected fs implementations', () => {
|
|
112
|
+
const injectedFs = {
|
|
113
|
+
...fs,
|
|
114
|
+
mkdirSync: vi.fn(fs.mkdirSync),
|
|
115
|
+
writeFileSync: vi.fn(fs.writeFileSync),
|
|
116
|
+
existsSync: vi.fn(fs.existsSync),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const result = ensureMemoryReady(projectDir, { fs: injectedFs });
|
|
120
|
+
|
|
121
|
+
expect(result.created.slice().sort()).toEqual([...TARGETS, ...GITKEEPS].sort());
|
|
122
|
+
expect(injectedFs.mkdirSync).toHaveBeenCalled();
|
|
123
|
+
expect(injectedFs.writeFileSync).toHaveBeenCalledTimes(2);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('handles partially initialized local memory trees', () => {
|
|
127
|
+
fs.mkdirSync(path.join(projectDir, '.tlc', 'memory', '.local'), { recursive: true });
|
|
128
|
+
|
|
129
|
+
const result = ensureMemoryReady(projectDir);
|
|
130
|
+
|
|
131
|
+
expect(result.created.slice().sort()).toEqual([...TARGETS, ...GITKEEPS].sort());
|
|
132
|
+
expect(result.existing).toEqual([]);
|
|
133
|
+
expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', '.local', 'sessions'))).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
function normalizeMessage(value) {
|
|
4
|
+
if (typeof value !== 'string') {
|
|
5
|
+
return '';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
return value.trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function createEnvelope({
|
|
12
|
+
provider,
|
|
13
|
+
source,
|
|
14
|
+
taskName,
|
|
15
|
+
threadId,
|
|
16
|
+
userMessage,
|
|
17
|
+
assistantMessage,
|
|
18
|
+
timestamp,
|
|
19
|
+
}) {
|
|
20
|
+
const normalizedUserMessage = normalizeMessage(userMessage);
|
|
21
|
+
const normalizedAssistantMessage = normalizeMessage(assistantMessage);
|
|
22
|
+
const parts = [normalizedUserMessage, normalizedAssistantMessage].filter(Boolean);
|
|
23
|
+
|
|
24
|
+
if (parts.length === 0) {
|
|
25
|
+
throw new Error('createEnvelope requires at least one non-empty message');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
provider,
|
|
30
|
+
source,
|
|
31
|
+
taskName,
|
|
32
|
+
threadId,
|
|
33
|
+
text: parts.join('\n\n'),
|
|
34
|
+
timestamp: typeof timestamp === 'string' ? timestamp : new Date().toISOString(),
|
|
35
|
+
metadata: {
|
|
36
|
+
userMessage,
|
|
37
|
+
assistantMessage,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validateEnvelope(obj) {
|
|
43
|
+
const errors = [];
|
|
44
|
+
const value = obj || {};
|
|
45
|
+
|
|
46
|
+
if (typeof value.provider !== 'string') {
|
|
47
|
+
errors.push('provider must be a string');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (typeof value.text !== 'string' || value.text.trim() === '') {
|
|
51
|
+
errors.push('text must be a non-empty string');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (typeof value.timestamp !== 'string') {
|
|
55
|
+
errors.push('timestamp must be a string');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
valid: errors.length === 0,
|
|
60
|
+
errors,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function idempotencyKey(envelope) {
|
|
65
|
+
const provider = typeof envelope.provider === 'string' ? envelope.provider : '';
|
|
66
|
+
const timestamp = typeof envelope.timestamp === 'string' ? envelope.timestamp : '';
|
|
67
|
+
const text = typeof envelope.text === 'string' ? envelope.text : '';
|
|
68
|
+
const payload = provider + timestamp + text.slice(0, 100);
|
|
69
|
+
|
|
70
|
+
return crypto.createHash('sha256').update(payload).digest('hex');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = {
|
|
74
|
+
createEnvelope,
|
|
75
|
+
validateEnvelope,
|
|
76
|
+
idempotencyKey,
|
|
77
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
|
|
4
|
+
describe('capture/envelope', () => {
|
|
5
|
+
let envelopeModule;
|
|
6
|
+
|
|
7
|
+
beforeEach(async () => {
|
|
8
|
+
vi.useFakeTimers();
|
|
9
|
+
vi.setSystemTime(new Date('2026-03-28T10:11:12.000Z'));
|
|
10
|
+
envelopeModule = await import('./envelope.js');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.useRealTimers();
|
|
15
|
+
vi.resetModules();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('creates a normalized envelope with combined text and metadata', () => {
|
|
19
|
+
const envelope = envelopeModule.createEnvelope({
|
|
20
|
+
provider: 'openai',
|
|
21
|
+
source: 'chat',
|
|
22
|
+
taskName: 'capture',
|
|
23
|
+
threadId: 'thread-1',
|
|
24
|
+
userMessage: 'User asks a question',
|
|
25
|
+
assistantMessage: 'Assistant answers it',
|
|
26
|
+
timestamp: '2026-03-27T00:00:00.000Z',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
expect(envelope).toEqual({
|
|
30
|
+
provider: 'openai',
|
|
31
|
+
source: 'chat',
|
|
32
|
+
taskName: 'capture',
|
|
33
|
+
threadId: 'thread-1',
|
|
34
|
+
text: 'User asks a question\n\nAssistant answers it',
|
|
35
|
+
timestamp: '2026-03-27T00:00:00.000Z',
|
|
36
|
+
metadata: {
|
|
37
|
+
userMessage: 'User asks a question',
|
|
38
|
+
assistantMessage: 'Assistant answers it',
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('trims both messages before combining them', () => {
|
|
44
|
+
const envelope = envelopeModule.createEnvelope({
|
|
45
|
+
provider: 'openai',
|
|
46
|
+
userMessage: ' hello ',
|
|
47
|
+
assistantMessage: '\n world \n',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(envelope.text).toBe('hello\n\nworld');
|
|
51
|
+
expect(envelope.metadata).toEqual({
|
|
52
|
+
userMessage: ' hello ',
|
|
53
|
+
assistantMessage: '\n world \n',
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('supports an envelope with only a user message', () => {
|
|
58
|
+
const envelope = envelopeModule.createEnvelope({
|
|
59
|
+
provider: 'openai',
|
|
60
|
+
userMessage: 'Only user content',
|
|
61
|
+
assistantMessage: ' ',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(envelope.text).toBe('Only user content');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('supports an envelope with only an assistant message', () => {
|
|
68
|
+
const envelope = envelopeModule.createEnvelope({
|
|
69
|
+
provider: 'openai',
|
|
70
|
+
userMessage: '',
|
|
71
|
+
assistantMessage: 'Only assistant content',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(envelope.text).toBe('Only assistant content');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('defaults timestamp to the current ISO string', () => {
|
|
78
|
+
const envelope = envelopeModule.createEnvelope({
|
|
79
|
+
provider: 'openai',
|
|
80
|
+
userMessage: 'hello',
|
|
81
|
+
assistantMessage: 'world',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(envelope.timestamp).toBe('2026-03-28T10:11:12.000Z');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('throws when both messages are empty after trimming', () => {
|
|
88
|
+
expect(() => envelopeModule.createEnvelope({
|
|
89
|
+
provider: 'openai',
|
|
90
|
+
userMessage: ' ',
|
|
91
|
+
assistantMessage: '\n\t',
|
|
92
|
+
})).toThrow(/at least one/i);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('validateEnvelope returns valid for a complete minimal envelope', () => {
|
|
96
|
+
expect(envelopeModule.validateEnvelope({
|
|
97
|
+
provider: 'openai',
|
|
98
|
+
text: 'combined text',
|
|
99
|
+
timestamp: '2026-03-28T10:11:12.000Z',
|
|
100
|
+
})).toEqual({
|
|
101
|
+
valid: true,
|
|
102
|
+
errors: [],
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('validateEnvelope reports missing required fields', () => {
|
|
107
|
+
const result = envelopeModule.validateEnvelope({
|
|
108
|
+
provider: 42,
|
|
109
|
+
text: ' ',
|
|
110
|
+
timestamp: null,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(result.valid).toBe(false);
|
|
114
|
+
expect(result.errors).toContain('provider must be a string');
|
|
115
|
+
expect(result.errors).toContain('text must be a non-empty string');
|
|
116
|
+
expect(result.errors).toContain('timestamp must be a string');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('validateEnvelope allows optional fields and metadata', () => {
|
|
120
|
+
const result = envelopeModule.validateEnvelope({
|
|
121
|
+
provider: 'openai',
|
|
122
|
+
source: 'cli',
|
|
123
|
+
taskName: 'summarize',
|
|
124
|
+
threadId: 'thread-9',
|
|
125
|
+
text: 'combined text',
|
|
126
|
+
timestamp: '2026-03-28T10:11:12.000Z',
|
|
127
|
+
metadata: {
|
|
128
|
+
userMessage: 'u',
|
|
129
|
+
assistantMessage: 'a',
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(result).toEqual({
|
|
134
|
+
valid: true,
|
|
135
|
+
errors: [],
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('creates a stable idempotency key from provider timestamp and truncated text', () => {
|
|
140
|
+
const envelope = {
|
|
141
|
+
provider: 'openai',
|
|
142
|
+
timestamp: '2026-03-28T10:11:12.000Z',
|
|
143
|
+
text: 'x'.repeat(120),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const expected = crypto
|
|
147
|
+
.createHash('sha256')
|
|
148
|
+
.update(`openai2026-03-28T10:11:12.000Z${'x'.repeat(100)}`)
|
|
149
|
+
.digest('hex');
|
|
150
|
+
|
|
151
|
+
expect(envelopeModule.idempotencyKey(envelope)).toBe(expected);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('idempotency key ignores text beyond the first 100 characters', () => {
|
|
155
|
+
const prefix = 'p'.repeat(100);
|
|
156
|
+
const first = envelopeModule.idempotencyKey({
|
|
157
|
+
provider: 'openai',
|
|
158
|
+
timestamp: '2026-03-28T10:11:12.000Z',
|
|
159
|
+
text: `${prefix}AAA`,
|
|
160
|
+
});
|
|
161
|
+
const second = envelopeModule.idempotencyKey({
|
|
162
|
+
provider: 'openai',
|
|
163
|
+
timestamp: '2026-03-28T10:11:12.000Z',
|
|
164
|
+
text: `${prefix}BBB`,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(first).toBe(second);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const { classify } = require('./classifier.js');
|
|
2
|
+
|
|
3
|
+
function splitSentences(text) {
|
|
4
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
5
|
+
return [];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
return text
|
|
9
|
+
.match(/[^.!?\n]+[.!?]?/g)
|
|
10
|
+
.map((sentence) => sentence.trim())
|
|
11
|
+
.filter(Boolean);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function extractDecisions(text, { role = 'unknown' } = {}) {
|
|
15
|
+
const sentences = splitSentences(text);
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
const results = [];
|
|
18
|
+
|
|
19
|
+
for (const sentence of sentences) {
|
|
20
|
+
const classification = classify(sentence);
|
|
21
|
+
|
|
22
|
+
if (classification.type === 'session') {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const statement = sentence;
|
|
27
|
+
|
|
28
|
+
if (seen.has(statement)) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
seen.add(statement);
|
|
33
|
+
|
|
34
|
+
const confidence = role === 'assistant'
|
|
35
|
+
? classification.confidence * 1.5
|
|
36
|
+
: classification.confidence;
|
|
37
|
+
|
|
38
|
+
results.push({
|
|
39
|
+
statement,
|
|
40
|
+
context: sentence,
|
|
41
|
+
confidence,
|
|
42
|
+
role,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return results;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = {
|
|
50
|
+
extractDecisions,
|
|
51
|
+
};
|