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.
@@ -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 MAX_STATE_ENTRIES = 200;
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
- * Load analytics state (set of already-analyzed session IDs).
27
+ * Initialize analytics state DB.
24
28
  */
25
- function loadState() {
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
- return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
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
- } catch { /* corrupt state — start fresh */ }
31
- return { analyzed: {} };
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
- * Save analytics state.
100
+ * Mark a session as processed in DB.
36
101
  */
37
- function saveState(state) {
38
- // Cap entries for both tracking keys
39
- for (const key of ['analyzed', 'facts_analyzed']) {
40
- if (!state[key]) continue;
41
- const keys = Object.keys(state[key]);
42
- if (keys.length > MAX_STATE_ENTRIES) {
43
- const sorted = keys.sort((a, b) => (state[key][a] || 0) - (state[key][b] || 0));
44
- const toRemove = sorted.slice(0, keys.length - MAX_STATE_ENTRIES);
45
- for (const k of toRemove) delete state[key][k];
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
- const dir = path.dirname(STATE_FILE);
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 (state.analyzed[sessionId]) continue;
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
- skeleton.project = path.basename(entry.cwd);
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 (state.analyzed[sessionId]) continue;
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
- const state = loadState();
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 (factsAnalyzed[sessionId]) continue;
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
- const state = loadState();
483
- if (!state.facts_analyzed) state.facts_analyzed = {};
484
- state.facts_analyzed[sessionId] = Date.now();
485
- saveState(state); // saveState() caps both analyzed and facts_analyzed
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,