metame-cli 1.4.15 → 1.4.18
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/README.md +9 -6
- package/index.js +12 -5
- package/package.json +2 -2
- package/scripts/check-macos-control-capabilities.sh +77 -0
- package/scripts/daemon-admin-commands.js +441 -12
- package/scripts/daemon-admin-commands.test.js +333 -0
- package/scripts/daemon-claude-engine.js +71 -22
- package/scripts/daemon-command-router.js +242 -3
- package/scripts/daemon-default.yaml +10 -3
- package/scripts/daemon-exec-commands.js +248 -13
- package/scripts/daemon-task-envelope.js +143 -0
- package/scripts/daemon-task-envelope.test.js +59 -0
- package/scripts/daemon-task-scheduler.js +216 -24
- package/scripts/daemon-task-scheduler.test.js +106 -0
- package/scripts/daemon.js +374 -26
- package/scripts/distill.js +184 -34
- package/scripts/memory-extract.js +13 -5
- package/scripts/memory.js +239 -60
- package/scripts/providers.js +1 -1
- package/scripts/reliability-core.test.js +268 -0
- package/scripts/session-analytics.js +123 -35
- package/scripts/signal-capture.js +171 -11
- package/scripts/skill-evolution.js +288 -38
- package/scripts/skill-evolution.test.js +107 -0
- package/scripts/task-board.js +398 -0
- package/scripts/task-board.test.js +83 -0
- package/scripts/usage-classifier.js +139 -0
- package/scripts/utils.js +107 -0
- package/scripts/utils.test.js +61 -1
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { execFileSync, spawn } = require('child_process');
|
|
7
|
+
|
|
8
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
9
|
+
|
|
10
|
+
function mkHome(prefix = 'metame-reliability-') {
|
|
11
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
12
|
+
fs.mkdirSync(path.join(home, '.metame'), { recursive: true });
|
|
13
|
+
return home;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function runNode(home, code, extraEnv = {}) {
|
|
17
|
+
return execFileSync(process.execPath, ['-e', code], {
|
|
18
|
+
cwd: ROOT,
|
|
19
|
+
env: { ...process.env, HOME: home, ...extraEnv },
|
|
20
|
+
encoding: 'utf8',
|
|
21
|
+
timeout: 30000,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function installFakeClaude(home, body) {
|
|
26
|
+
const bin = path.join(home, 'bin');
|
|
27
|
+
fs.mkdirSync(bin, { recursive: true });
|
|
28
|
+
const cli = path.join(bin, 'claude');
|
|
29
|
+
fs.writeFileSync(cli, `#!/bin/sh\n${body}\n`, 'utf8');
|
|
30
|
+
fs.chmodSync(cli, 0o755);
|
|
31
|
+
return { PATH: `${bin}:${process.env.PATH}` };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sendSignal(home, prompt, extraEnv = {}) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const child = spawn(process.execPath, [path.join(ROOT, 'scripts', 'signal-capture.js')], {
|
|
37
|
+
cwd: ROOT,
|
|
38
|
+
env: { ...process.env, HOME: home, ...extraEnv },
|
|
39
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
40
|
+
});
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
43
|
+
reject(new Error('signal-capture timed out'));
|
|
44
|
+
}, 10000);
|
|
45
|
+
child.on('error', reject);
|
|
46
|
+
child.on('close', (code) => {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
if (code === 0) resolve();
|
|
49
|
+
else reject(new Error(`signal-capture exited with ${code}`));
|
|
50
|
+
});
|
|
51
|
+
child.stdin.end(JSON.stringify({
|
|
52
|
+
prompt,
|
|
53
|
+
session_id: `s-${Math.random().toString(36).slice(2, 10)}`,
|
|
54
|
+
cwd: '/tmp',
|
|
55
|
+
}));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test('signal-capture preserves all entries under concurrent writes', async () => {
|
|
60
|
+
const home = mkHome();
|
|
61
|
+
const count = 40;
|
|
62
|
+
await Promise.all(
|
|
63
|
+
Array.from({ length: count }, (_, i) => sendSignal(home, `请记住以后规则${i}`))
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const buffer = path.join(home, '.metame', 'raw_signals.jsonl');
|
|
67
|
+
const lines = fs.readFileSync(buffer, 'utf8').split('\n').filter(Boolean);
|
|
68
|
+
assert.equal(lines.length, count);
|
|
69
|
+
const prompts = new Set(lines.map((l) => JSON.parse(l).prompt));
|
|
70
|
+
assert.equal(prompts.size, count);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('distill keeps raw_signals when model returns malformed output', () => {
|
|
74
|
+
const home = mkHome();
|
|
75
|
+
const env = installFakeClaude(home, 'echo "MALFORMED_OUTPUT"');
|
|
76
|
+
const buffer = path.join(home, '.metame', 'raw_signals.jsonl');
|
|
77
|
+
|
|
78
|
+
fs.writeFileSync(
|
|
79
|
+
buffer,
|
|
80
|
+
JSON.stringify({
|
|
81
|
+
ts: new Date().toISOString(),
|
|
82
|
+
prompt: '请记住以后都用中文并保持简洁',
|
|
83
|
+
confidence: 'high',
|
|
84
|
+
type: 'directive',
|
|
85
|
+
session: 'sess-1',
|
|
86
|
+
cwd: '/tmp',
|
|
87
|
+
}) + '\n',
|
|
88
|
+
'utf8'
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
runNode(home, `
|
|
92
|
+
const { distill } = require('./scripts/distill');
|
|
93
|
+
(async () => {
|
|
94
|
+
const r = await distill();
|
|
95
|
+
console.log(JSON.stringify(r));
|
|
96
|
+
})().then(() => process.exit(0)).catch(() => process.exit(1));
|
|
97
|
+
`, env);
|
|
98
|
+
|
|
99
|
+
const lines = fs.readFileSync(buffer, 'utf8').split('\n').filter(Boolean);
|
|
100
|
+
assert.equal(lines.length, 1);
|
|
101
|
+
assert.match(JSON.parse(lines[0]).prompt, /以后都用中文/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('memory-extract does not mark session extracted when extraction fails', () => {
|
|
105
|
+
const home = mkHome();
|
|
106
|
+
const env = installFakeClaude(home, 'echo "downstream failure" 1>&2; exit 1');
|
|
107
|
+
const projDir = path.join(home, '.claude', 'projects', 'demo');
|
|
108
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
109
|
+
const sessionId = 'session-retry-me';
|
|
110
|
+
const sessionPath = path.join(projDir, `${sessionId}.jsonl`);
|
|
111
|
+
|
|
112
|
+
const rows = [];
|
|
113
|
+
for (let i = 0; i < 24; i++) {
|
|
114
|
+
rows.push(JSON.stringify({
|
|
115
|
+
type: 'user',
|
|
116
|
+
timestamp: new Date(Date.now() + i * 1000).toISOString(),
|
|
117
|
+
cwd: '/tmp/demo',
|
|
118
|
+
message: { content: `这是一条用于memory extract回归测试的用户消息 ${i},需要保留重试能力。` },
|
|
119
|
+
}));
|
|
120
|
+
rows.push(JSON.stringify({
|
|
121
|
+
type: 'assistant',
|
|
122
|
+
timestamp: new Date(Date.now() + i * 1000 + 500).toISOString(),
|
|
123
|
+
message: { content: [{ type: 'text', text: 'ack' }] },
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
fs.writeFileSync(sessionPath, rows.join('\n') + '\n', 'utf8');
|
|
127
|
+
|
|
128
|
+
runNode(home, `
|
|
129
|
+
const me = require('./scripts/memory-extract');
|
|
130
|
+
(async () => {
|
|
131
|
+
await me.run();
|
|
132
|
+
const sa = require('./scripts/session-analytics');
|
|
133
|
+
const remain = sa.findAllUnextractedSessions(50).map(s => s.session_id);
|
|
134
|
+
console.log(JSON.stringify(remain));
|
|
135
|
+
})().then(() => process.exit(0)).catch(() => process.exit(1));
|
|
136
|
+
`, env);
|
|
137
|
+
|
|
138
|
+
const remain = JSON.parse(
|
|
139
|
+
runNode(home, `
|
|
140
|
+
const sa = require('./scripts/session-analytics');
|
|
141
|
+
console.log(JSON.stringify(sa.findAllUnextractedSessions(50).map(s => s.session_id)));
|
|
142
|
+
`).trim()
|
|
143
|
+
);
|
|
144
|
+
assert.ok(remain.includes(sessionId));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('skill-evolution keeps signals when haiku output is malformed', () => {
|
|
148
|
+
const home = mkHome();
|
|
149
|
+
const env = installFakeClaude(home, 'echo "NOT_JSON_BLOCK"');
|
|
150
|
+
const skillDir = path.join(home, '.claude', 'skills', 'demo-skill');
|
|
151
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
152
|
+
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Demo Skill\n\nA sample skill.', 'utf8');
|
|
153
|
+
|
|
154
|
+
const sigFile = path.join(home, '.metame', 'skill_signals.jsonl');
|
|
155
|
+
const signals = [
|
|
156
|
+
{ ts: new Date().toISOString(), prompt: '请求1', outcome: 'error', skills_invoked: ['demo-skill'], has_tool_failure: true, error: 'x' },
|
|
157
|
+
{ ts: new Date().toISOString(), prompt: '请求2', outcome: 'error', skills_invoked: ['demo-skill'], has_tool_failure: true, error: 'y' },
|
|
158
|
+
{ ts: new Date().toISOString(), prompt: '请求3', outcome: 'error', skills_invoked: ['demo-skill'], has_tool_failure: true, error: 'z' },
|
|
159
|
+
];
|
|
160
|
+
fs.writeFileSync(sigFile, signals.map(s => JSON.stringify(s)).join('\n') + '\n', 'utf8');
|
|
161
|
+
|
|
162
|
+
runNode(home, `
|
|
163
|
+
const se = require('./scripts/skill-evolution');
|
|
164
|
+
(async () => {
|
|
165
|
+
await se.distillSkills();
|
|
166
|
+
})().then(() => process.exit(0)).catch(() => process.exit(1));
|
|
167
|
+
`, env);
|
|
168
|
+
|
|
169
|
+
const lines = fs.readFileSync(sigFile, 'utf8').split('\n').filter(Boolean);
|
|
170
|
+
assert.equal(lines.length, 3);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('signal-capture: overflow entries are drained and merged on next lock acquisition', async () => {
|
|
174
|
+
const home = mkHome();
|
|
175
|
+
const bufferFile = path.join(home, '.metame', 'raw_signals.jsonl');
|
|
176
|
+
const overflowFile = path.join(home, '.metame', 'raw_signals.overflow.jsonl');
|
|
177
|
+
|
|
178
|
+
const makeEntry = (i) => JSON.stringify({
|
|
179
|
+
ts: new Date().toISOString(), prompt: `以后记住规则${i}`, confidence: 'high',
|
|
180
|
+
type: 'directive', session: null, cwd: '/tmp',
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Pre-populate main buffer (3 entries) and overflow (2 entries)
|
|
184
|
+
fs.writeFileSync(bufferFile, [0, 1, 2].map(makeEntry).join('\n') + '\n', 'utf8');
|
|
185
|
+
fs.writeFileSync(overflowFile, [100, 101].map(makeEntry).join('\n') + '\n', 'utf8');
|
|
186
|
+
|
|
187
|
+
// One normal signal — should drain overflow inside the lock
|
|
188
|
+
await sendSignal(home, '以后回复请保持简洁风格');
|
|
189
|
+
|
|
190
|
+
const lines = fs.readFileSync(bufferFile, 'utf8').split('\n').filter(Boolean);
|
|
191
|
+
// 3 existing + 2 overflow + 1 new = 6
|
|
192
|
+
assert.equal(lines.length, 6);
|
|
193
|
+
assert.equal(fs.existsSync(overflowFile), false, 'overflow file should be removed after drain');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('signal-capture: overflow drain respects MAX_BUFFER_LINES cap', async () => {
|
|
197
|
+
const home = mkHome();
|
|
198
|
+
const MAX = 300;
|
|
199
|
+
const bufferFile = path.join(home, '.metame', 'raw_signals.jsonl');
|
|
200
|
+
const overflowFile = path.join(home, '.metame', 'raw_signals.overflow.jsonl');
|
|
201
|
+
|
|
202
|
+
const makeEntry = (i) => JSON.stringify({
|
|
203
|
+
ts: new Date().toISOString(), prompt: `以后偏好配置${i}`, confidence: 'normal',
|
|
204
|
+
type: 'implicit', session: null, cwd: '/tmp',
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Fill main buffer to 297, overflow to 5 — combined 302 + 1 new → must cap to 300
|
|
208
|
+
fs.writeFileSync(bufferFile, Array.from({ length: 297 }, (_, i) => makeEntry(i)).join('\n') + '\n', 'utf8');
|
|
209
|
+
fs.writeFileSync(overflowFile, Array.from({ length: 5 }, (_, i) => makeEntry(i + 1000)).join('\n') + '\n', 'utf8');
|
|
210
|
+
|
|
211
|
+
await sendSignal(home, '以后总是用英文写注释');
|
|
212
|
+
|
|
213
|
+
const lines = fs.readFileSync(bufferFile, 'utf8').split('\n').filter(Boolean);
|
|
214
|
+
assert.ok(lines.length <= MAX, `buffer must not exceed MAX_BUFFER_LINES (got ${lines.length})`);
|
|
215
|
+
assert.equal(lines.length, MAX);
|
|
216
|
+
assert.equal(fs.existsSync(overflowFile), false, 'overflow file should be removed after drain');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('skill-evolution: overflow entries are drained and merged on next appendSkillSignal', () => {
|
|
220
|
+
const home = mkHome();
|
|
221
|
+
const sigFile = path.join(home, '.metame', 'skill_signals.jsonl');
|
|
222
|
+
const overflowFile = path.join(home, '.metame', 'skill_signals.overflow.jsonl');
|
|
223
|
+
|
|
224
|
+
const makeSignal = (i) => JSON.stringify({
|
|
225
|
+
ts: new Date().toISOString(), prompt: `prompt-${i}`, outcome: 'success',
|
|
226
|
+
skills_invoked: ['demo-skill'], has_tool_failure: false, error: null,
|
|
227
|
+
output_excerpt: '', tools_used: [], files_modified: [], cwd: '/tmp',
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
fs.writeFileSync(sigFile, [0, 1, 2].map(makeSignal).join('\n') + '\n', 'utf8');
|
|
231
|
+
fs.writeFileSync(overflowFile, [100, 101].map(makeSignal).join('\n') + '\n', 'utf8');
|
|
232
|
+
|
|
233
|
+
runNode(home, `
|
|
234
|
+
const se = require('./scripts/skill-evolution');
|
|
235
|
+
const sig = {
|
|
236
|
+
ts: new Date().toISOString(), prompt: 'new-signal', outcome: 'success',
|
|
237
|
+
skills_invoked: ['demo-skill'], has_tool_failure: false, error: null,
|
|
238
|
+
output_excerpt: '', tools_used: [], files_modified: [], cwd: '/tmp',
|
|
239
|
+
};
|
|
240
|
+
se.appendSkillSignal(sig);
|
|
241
|
+
process.exit(0);
|
|
242
|
+
`);
|
|
243
|
+
|
|
244
|
+
const lines = fs.readFileSync(sigFile, 'utf8').split('\n').filter(Boolean);
|
|
245
|
+
// 3 existing + 2 overflow + 1 new = 6
|
|
246
|
+
assert.equal(lines.length, 6);
|
|
247
|
+
assert.equal(fs.existsSync(overflowFile), false, 'overflow file should be removed after drain');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('writeBrainFileSafe throws when lock cannot be acquired', () => {
|
|
251
|
+
const home = mkHome();
|
|
252
|
+
fs.writeFileSync(path.join(home, '.metame', 'brain.lock'), '99999', 'utf8');
|
|
253
|
+
|
|
254
|
+
const out = runNode(home, `
|
|
255
|
+
const { writeBrainFileSafe } = require('./scripts/utils');
|
|
256
|
+
(async () => {
|
|
257
|
+
try {
|
|
258
|
+
await writeBrainFileSafe('x: 1\\n', process.env.HOME + '/profile.yaml');
|
|
259
|
+
console.log('WROTE');
|
|
260
|
+
} catch (e) {
|
|
261
|
+
console.log('THREW');
|
|
262
|
+
}
|
|
263
|
+
})().then(() => process.exit(0)).catch(() => process.exit(1));
|
|
264
|
+
`).trim();
|
|
265
|
+
|
|
266
|
+
assert.equal(out, 'THREW');
|
|
267
|
+
assert.equal(fs.existsSync(path.join(home, 'profile.yaml')), false);
|
|
268
|
+
});
|
|
@@ -11,43 +11,105 @@
|
|
|
11
11
|
const fs = require('fs');
|
|
12
12
|
const path = require('path');
|
|
13
13
|
const os = require('os');
|
|
14
|
+
const { deriveProjectInfo } = require('./utils');
|
|
14
15
|
|
|
15
16
|
const HOME = os.homedir();
|
|
16
17
|
const PROJECTS_ROOT = path.join(HOME, '.claude', 'projects');
|
|
17
18
|
const STATE_FILE = path.join(HOME, '.metame', 'analytics_state.json');
|
|
18
|
-
const
|
|
19
|
+
const STATE_DB = path.join(HOME, '.metame', 'analytics_state.db');
|
|
19
20
|
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
|
|
20
21
|
const MIN_FILE_SIZE = 1024; // 1KB
|
|
22
|
+
let _stateDb = null;
|
|
23
|
+
let _stmtIsProcessed = null;
|
|
24
|
+
let _stmtMarkProcessed = null;
|
|
21
25
|
|
|
22
26
|
/**
|
|
23
|
-
*
|
|
27
|
+
* Initialize analytics state DB.
|
|
24
28
|
*/
|
|
25
|
-
function
|
|
29
|
+
function getStateDb() {
|
|
30
|
+
if (_stateDb) return _stateDb;
|
|
31
|
+
const dir = path.dirname(STATE_DB);
|
|
32
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
33
|
+
|
|
34
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
35
|
+
_stateDb = new DatabaseSync(STATE_DB);
|
|
36
|
+
_stateDb.exec('PRAGMA journal_mode = WAL');
|
|
37
|
+
_stateDb.exec('PRAGMA busy_timeout = 3000');
|
|
38
|
+
_stateDb.exec(`
|
|
39
|
+
CREATE TABLE IF NOT EXISTS processed_sessions (
|
|
40
|
+
kind TEXT NOT NULL,
|
|
41
|
+
session_id TEXT NOT NULL,
|
|
42
|
+
processed_at INTEGER NOT NULL,
|
|
43
|
+
PRIMARY KEY (kind, session_id)
|
|
44
|
+
)
|
|
45
|
+
`);
|
|
46
|
+
_stateDb.exec('CREATE INDEX IF NOT EXISTS idx_processed_kind_ts ON processed_sessions(kind, processed_at)');
|
|
47
|
+
_stateDb.exec('CREATE TABLE IF NOT EXISTS state_meta (key TEXT PRIMARY KEY, value TEXT)');
|
|
48
|
+
migrateLegacyStateOnce(_stateDb);
|
|
49
|
+
return _stateDb;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One-time migration from legacy JSON state file.
|
|
54
|
+
*/
|
|
55
|
+
function migrateLegacyStateOnce(db) {
|
|
26
56
|
try {
|
|
57
|
+
const migrated = db.prepare("SELECT value FROM state_meta WHERE key = 'legacy_json_migrated'").get();
|
|
58
|
+
if (migrated && migrated.value === '1') return;
|
|
59
|
+
|
|
27
60
|
if (fs.existsSync(STATE_FILE)) {
|
|
28
|
-
|
|
61
|
+
let raw = null;
|
|
62
|
+
try { raw = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch { raw = null; }
|
|
63
|
+
if (raw && typeof raw === 'object') {
|
|
64
|
+
const insert = db.prepare(`
|
|
65
|
+
INSERT INTO processed_sessions (kind, session_id, processed_at)
|
|
66
|
+
VALUES (?, ?, ?)
|
|
67
|
+
ON CONFLICT(kind, session_id) DO UPDATE SET processed_at = excluded.processed_at
|
|
68
|
+
`);
|
|
69
|
+
const tx = db.transaction(() => {
|
|
70
|
+
for (const [sid, ts] of Object.entries(raw.analyzed || {})) {
|
|
71
|
+
insert.run('analyzed', sid, Number(ts) || Date.now());
|
|
72
|
+
}
|
|
73
|
+
for (const [sid, ts] of Object.entries(raw.facts_analyzed || {})) {
|
|
74
|
+
insert.run('facts_analyzed', sid, Number(ts) || Date.now());
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
tx();
|
|
78
|
+
}
|
|
29
79
|
}
|
|
30
|
-
|
|
31
|
-
|
|
80
|
+
|
|
81
|
+
db.prepare("INSERT OR REPLACE INTO state_meta (key, value) VALUES ('legacy_json_migrated', '1')").run();
|
|
82
|
+
} catch {
|
|
83
|
+
// non-fatal
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isProcessed(kind, sessionId) {
|
|
88
|
+
if (!kind || !sessionId) return false;
|
|
89
|
+
const db = getStateDb();
|
|
90
|
+
if (!_stmtIsProcessed) {
|
|
91
|
+
_stmtIsProcessed = db.prepare(
|
|
92
|
+
'SELECT 1 AS ok FROM processed_sessions WHERE kind = ? AND session_id = ? LIMIT 1'
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const row = _stmtIsProcessed.get(kind, sessionId);
|
|
96
|
+
return !!(row && row.ok === 1);
|
|
32
97
|
}
|
|
33
98
|
|
|
34
99
|
/**
|
|
35
|
-
*
|
|
100
|
+
* Mark a session as processed in DB.
|
|
36
101
|
*/
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
102
|
+
function markProcessed(kind, sessionId) {
|
|
103
|
+
if (!sessionId) return;
|
|
104
|
+
const db = getStateDb();
|
|
105
|
+
if (!_stmtMarkProcessed) {
|
|
106
|
+
_stmtMarkProcessed = db.prepare(`
|
|
107
|
+
INSERT INTO processed_sessions (kind, session_id, processed_at)
|
|
108
|
+
VALUES (?, ?, ?)
|
|
109
|
+
ON CONFLICT(kind, session_id) DO UPDATE SET processed_at = excluded.processed_at
|
|
110
|
+
`);
|
|
47
111
|
}
|
|
48
|
-
|
|
49
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
50
|
-
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf8');
|
|
112
|
+
_stmtMarkProcessed.run(kind, sessionId, Date.now());
|
|
51
113
|
}
|
|
52
114
|
|
|
53
115
|
/**
|
|
@@ -55,7 +117,6 @@ function saveState(state) {
|
|
|
55
117
|
* Returns { path, session_id, mtime } or null.
|
|
56
118
|
*/
|
|
57
119
|
function findLatestUnanalyzedSession() {
|
|
58
|
-
const state = loadState();
|
|
59
120
|
let best = null;
|
|
60
121
|
|
|
61
122
|
try {
|
|
@@ -72,7 +133,7 @@ function findLatestUnanalyzedSession() {
|
|
|
72
133
|
for (const file of files) {
|
|
73
134
|
if (!file.endsWith('.jsonl')) continue;
|
|
74
135
|
const sessionId = file.replace('.jsonl', '');
|
|
75
|
-
if (
|
|
136
|
+
if (isProcessed('analyzed', sessionId)) continue;
|
|
76
137
|
|
|
77
138
|
const fullPath = path.join(fullDir, file);
|
|
78
139
|
let fstat;
|
|
@@ -114,6 +175,8 @@ function extractSkeleton(jsonlPath) {
|
|
|
114
175
|
message_count: 0,
|
|
115
176
|
duration_min: 0,
|
|
116
177
|
project: null,
|
|
178
|
+
project_id: null,
|
|
179
|
+
project_path: null,
|
|
117
180
|
branch: null,
|
|
118
181
|
file_dirs: new Set(),
|
|
119
182
|
intent: null,
|
|
@@ -145,7 +208,10 @@ function extractSkeleton(jsonlPath) {
|
|
|
145
208
|
if (type === 'user') {
|
|
146
209
|
// Extract project and branch from first occurrence
|
|
147
210
|
if (!skeleton.project && entry.cwd) {
|
|
148
|
-
|
|
211
|
+
const info = deriveProjectInfo(entry.cwd);
|
|
212
|
+
skeleton.project = info.project;
|
|
213
|
+
skeleton.project_id = info.project_id;
|
|
214
|
+
skeleton.project_path = info.project_path;
|
|
149
215
|
}
|
|
150
216
|
if (!skeleton.branch && entry.gitBranch) {
|
|
151
217
|
skeleton.branch = entry.gitBranch;
|
|
@@ -387,7 +453,6 @@ function formatForPrompt(skeleton) {
|
|
|
387
453
|
* Returns array of { path, session_id, mtime }. Capped at `limit`.
|
|
388
454
|
*/
|
|
389
455
|
function findAllUnanalyzedSessions(limit = 30) {
|
|
390
|
-
const state = loadState();
|
|
391
456
|
const results = [];
|
|
392
457
|
|
|
393
458
|
try {
|
|
@@ -404,7 +469,7 @@ function findAllUnanalyzedSessions(limit = 30) {
|
|
|
404
469
|
for (const file of files) {
|
|
405
470
|
if (!file.endsWith('.jsonl')) continue;
|
|
406
471
|
const sessionId = file.replace('.jsonl', '');
|
|
407
|
-
if (
|
|
472
|
+
if (isProcessed('analyzed', sessionId)) continue;
|
|
408
473
|
|
|
409
474
|
const fullPath = path.join(fullDir, file);
|
|
410
475
|
let fstat;
|
|
@@ -428,9 +493,7 @@ function findAllUnanalyzedSessions(limit = 30) {
|
|
|
428
493
|
* Mark a session as analyzed (cognitive distill / pattern detection).
|
|
429
494
|
*/
|
|
430
495
|
function markAnalyzed(sessionId) {
|
|
431
|
-
|
|
432
|
-
state.analyzed[sessionId] = Date.now();
|
|
433
|
-
saveState(state);
|
|
496
|
+
markProcessed('analyzed', sessionId);
|
|
434
497
|
}
|
|
435
498
|
|
|
436
499
|
/**
|
|
@@ -438,8 +501,6 @@ function markAnalyzed(sessionId) {
|
|
|
438
501
|
* Uses a separate `facts_analyzed` key so distill and memory-extract don't interfere.
|
|
439
502
|
*/
|
|
440
503
|
function findAllUnextractedSessions(limit = 30) {
|
|
441
|
-
const state = loadState();
|
|
442
|
-
const factsAnalyzed = state.facts_analyzed || {};
|
|
443
504
|
const results = [];
|
|
444
505
|
|
|
445
506
|
try {
|
|
@@ -456,7 +517,7 @@ function findAllUnextractedSessions(limit = 30) {
|
|
|
456
517
|
for (const file of files) {
|
|
457
518
|
if (!file.endsWith('.jsonl')) continue;
|
|
458
519
|
const sessionId = file.replace('.jsonl', '');
|
|
459
|
-
if (
|
|
520
|
+
if (isProcessed('facts_analyzed', sessionId)) continue;
|
|
460
521
|
|
|
461
522
|
const fullPath = path.join(fullDir, file);
|
|
462
523
|
let fstat;
|
|
@@ -479,10 +540,36 @@ function findAllUnextractedSessions(limit = 30) {
|
|
|
479
540
|
* Mark a session as facts-extracted (used by memory-extract, independent of markAnalyzed).
|
|
480
541
|
*/
|
|
481
542
|
function markFactsExtracted(sessionId) {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
543
|
+
markProcessed('facts_analyzed', sessionId);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Find a session jsonl by its session id.
|
|
548
|
+
* Returns { path, session_id, mtime } or null.
|
|
549
|
+
*/
|
|
550
|
+
function findSessionById(sessionId) {
|
|
551
|
+
const sid = String(sessionId || '').trim();
|
|
552
|
+
if (!sid) return null;
|
|
553
|
+
|
|
554
|
+
try {
|
|
555
|
+
const projectDirs = fs.readdirSync(PROJECTS_ROOT);
|
|
556
|
+
for (const dir of projectDirs) {
|
|
557
|
+
const fullDir = path.join(PROJECTS_ROOT, dir);
|
|
558
|
+
let stat;
|
|
559
|
+
try { stat = fs.statSync(fullDir); } catch { continue; }
|
|
560
|
+
if (!stat.isDirectory()) continue;
|
|
561
|
+
|
|
562
|
+
const fullPath = path.join(fullDir, `${sid}.jsonl`);
|
|
563
|
+
let fstat;
|
|
564
|
+
try { fstat = fs.statSync(fullPath); } catch { continue; }
|
|
565
|
+
if (fstat.size > MAX_FILE_SIZE || fstat.size < MIN_FILE_SIZE) continue;
|
|
566
|
+
return { path: fullPath, session_id: sid, mtime: fstat.mtimeMs };
|
|
567
|
+
}
|
|
568
|
+
} catch {
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return null;
|
|
486
573
|
}
|
|
487
574
|
|
|
488
575
|
/**
|
|
@@ -579,6 +666,7 @@ function summarizeSession(skeleton, jsonlPath) {
|
|
|
579
666
|
|
|
580
667
|
module.exports = {
|
|
581
668
|
findLatestUnanalyzedSession,
|
|
669
|
+
findSessionById,
|
|
582
670
|
findAllUnanalyzedSessions,
|
|
583
671
|
findAllUnextractedSessions,
|
|
584
672
|
extractSkeleton,
|