metame-cli 1.4.34 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +136 -94
  2. package/index.js +312 -57
  3. package/package.json +8 -4
  4. package/scripts/agent-layer.js +320 -0
  5. package/scripts/daemon-admin-commands.js +328 -28
  6. package/scripts/daemon-agent-commands.js +145 -6
  7. package/scripts/daemon-agent-tools.js +163 -7
  8. package/scripts/daemon-bridges.js +110 -20
  9. package/scripts/daemon-checkpoints.js +36 -7
  10. package/scripts/daemon-claude-engine.js +849 -358
  11. package/scripts/daemon-command-router.js +31 -10
  12. package/scripts/daemon-default.yaml +28 -4
  13. package/scripts/daemon-engine-runtime.js +328 -0
  14. package/scripts/daemon-exec-commands.js +15 -7
  15. package/scripts/daemon-notify.js +37 -1
  16. package/scripts/daemon-ops-commands.js +8 -6
  17. package/scripts/daemon-runtime-lifecycle.js +129 -5
  18. package/scripts/daemon-session-commands.js +60 -25
  19. package/scripts/daemon-session-store.js +121 -13
  20. package/scripts/daemon-task-scheduler.js +129 -49
  21. package/scripts/daemon-user-acl.js +35 -9
  22. package/scripts/daemon.js +268 -33
  23. package/scripts/distill.js +327 -18
  24. package/scripts/docs/agent-guide.md +12 -0
  25. package/scripts/docs/maintenance-manual.md +155 -0
  26. package/scripts/docs/pointer-map.md +110 -0
  27. package/scripts/feishu-adapter.js +42 -13
  28. package/scripts/hooks/stop-session-capture.js +243 -0
  29. package/scripts/memory-extract.js +105 -6
  30. package/scripts/memory-nightly-reflect.js +199 -11
  31. package/scripts/memory.js +134 -3
  32. package/scripts/mentor-engine.js +405 -0
  33. package/scripts/platform.js +24 -0
  34. package/scripts/providers.js +182 -22
  35. package/scripts/schema.js +12 -0
  36. package/scripts/session-analytics.js +245 -12
  37. package/scripts/skill-changelog.js +245 -0
  38. package/scripts/skill-evolution.js +288 -5
  39. package/scripts/telegram-adapter.js +12 -8
  40. package/scripts/usage-classifier.js +1 -1
  41. package/scripts/daemon-admin-commands.test.js +0 -333
  42. package/scripts/daemon-task-envelope.test.js +0 -59
  43. package/scripts/daemon-task-scheduler.test.js +0 -106
  44. package/scripts/reliability-core.test.js +0 -280
  45. package/scripts/skill-evolution.test.js +0 -113
  46. package/scripts/task-board.test.js +0 -83
  47. package/scripts/test_daemon.js +0 -1407
  48. package/scripts/utils.test.js +0 -192
@@ -21,12 +21,46 @@ const fs = require('fs');
21
21
  const path = require('path');
22
22
  const os = require('os');
23
23
 
24
- const HOME = os.homedir();
25
- const METAME_DIR = path.join(HOME, '.metame');
26
- const PROVIDERS_FILE = path.join(METAME_DIR, 'providers.yaml');
27
-
28
24
  const yaml = require('./resolve-yaml');
29
25
 
26
+ const DEFAULT_DISTILL_MODEL = 'haiku';
27
+ const DISTILL_MODEL_ALIASES = new Map([
28
+ ['5.1mini', 'gpt-5.1-codex-mini'],
29
+ ['gpt5.1mini', 'gpt-5.1-codex-mini'],
30
+ ['gpt-5.1-mini', 'gpt-5.1-codex-mini'],
31
+ ['gpt5.1-codex-mini', 'gpt-5.1-codex-mini'],
32
+ ['codex-mini', 'gpt-5.1-codex-mini'],
33
+ ['5mini', 'gpt-5-mini'],
34
+ ['gpt5mini', 'gpt-5-mini'],
35
+ ]);
36
+
37
+ function canonicalizeAliasKey(input) {
38
+ return String(input || '').trim().toLowerCase().replace(/[\s_]+/g, '').replace(/^gpt[-\s]?/i, 'gpt');
39
+ }
40
+
41
+ function normalizeDistillModel(model, { allowEmpty = false } = {}) {
42
+ const raw = String(model || '').trim();
43
+ if (!raw) {
44
+ if (allowEmpty) return null;
45
+ throw new Error('蒸馏模型不能为空。');
46
+ }
47
+ const alias = DISTILL_MODEL_ALIASES.get(canonicalizeAliasKey(raw));
48
+ const normalized = (alias || raw).trim();
49
+ if (!/^[a-zA-Z0-9._-]{2,80}$/.test(normalized)) {
50
+ throw new Error(`无效蒸馏模型: ${raw}`);
51
+ }
52
+ return normalized;
53
+ }
54
+
55
+ function resolveDistillModel(config, overrideModel) {
56
+ if (overrideModel !== undefined && overrideModel !== null && String(overrideModel).trim() !== '') {
57
+ return normalizeDistillModel(overrideModel);
58
+ }
59
+ const configured = config && config.distill_model ? String(config.distill_model).trim() : '';
60
+ if (configured) return normalizeDistillModel(configured);
61
+ return DEFAULT_DISTILL_MODEL;
62
+ }
63
+
30
64
  // ---------------------------------------------------------
31
65
  // DEFAULT CONFIG
32
66
  // ---------------------------------------------------------
@@ -38,6 +72,7 @@ function defaultConfig() {
38
72
  },
39
73
  distill_provider: null,
40
74
  daemon_provider: null,
75
+ distill_model: null,
41
76
  };
42
77
  }
43
78
 
@@ -45,13 +80,49 @@ function defaultConfig() {
45
80
  // LOAD / SAVE (cached — file rarely changes)
46
81
  // ---------------------------------------------------------
47
82
  let _providersCache = null;
83
+ let _providersCachePath = '';
84
+ let _providersCacheStamp = '';
48
85
 
49
- function loadProviders() {
50
- if (_providersCache) return _providersCache;
86
+ function getProvidersFilePath() {
87
+ const home = process.env.HOME || os.homedir();
88
+ return path.join(home, '.metame', 'providers.yaml');
89
+ }
90
+
91
+ function computeFileStamp(filePath) {
92
+ try {
93
+ if (!fs.existsSync(filePath)) return 'missing';
94
+ const st = fs.statSync(filePath);
95
+ return `${Math.trunc(st.mtimeMs)}:${st.size}`;
96
+ } catch {
97
+ return 'error';
98
+ }
99
+ }
100
+
101
+ function loadProviders(options = {}) {
102
+ const force = !!(options && options.force);
103
+ const providersFile = getProvidersFilePath();
104
+ const currentStamp = computeFileStamp(providersFile);
105
+ if (_providersCachePath && _providersCachePath !== providersFile) {
106
+ _providersCache = null;
107
+ _providersCacheStamp = '';
108
+ }
109
+ if (!force && _providersCache && _providersCachePath === providersFile && _providersCacheStamp === currentStamp) {
110
+ return _providersCache;
111
+ }
51
112
  try {
52
- if (!fs.existsSync(PROVIDERS_FILE)) { _providersCache = defaultConfig(); return _providersCache; }
53
- const data = yaml.load(fs.readFileSync(PROVIDERS_FILE, 'utf8'));
54
- if (!data || typeof data !== 'object') { _providersCache = defaultConfig(); return _providersCache; }
113
+ if (!fs.existsSync(providersFile)) {
114
+ _providersCachePath = providersFile;
115
+ _providersCacheStamp = currentStamp;
116
+ _providersCache = defaultConfig();
117
+ return _providersCache;
118
+ }
119
+ const data = yaml.load(fs.readFileSync(providersFile, 'utf8'));
120
+ if (!data || typeof data !== 'object') {
121
+ _providersCachePath = providersFile;
122
+ _providersCacheStamp = currentStamp;
123
+ _providersCache = defaultConfig();
124
+ return _providersCache;
125
+ }
55
126
  if (!data.providers) data.providers = {};
56
127
  if (!data.providers.anthropic) data.providers.anthropic = { label: 'Anthropic (Official)' };
57
128
  _providersCache = {
@@ -59,18 +130,29 @@ function loadProviders() {
59
130
  providers: data.providers,
60
131
  distill_provider: data.distill_provider || null,
61
132
  daemon_provider: data.daemon_provider || null,
133
+ distill_model: (() => {
134
+ try { return normalizeDistillModel(data.distill_model, { allowEmpty: true }); } catch { return null; }
135
+ })(),
62
136
  };
137
+ _providersCachePath = providersFile;
138
+ _providersCacheStamp = currentStamp;
63
139
  return _providersCache;
64
140
  } catch {
141
+ _providersCachePath = providersFile;
142
+ _providersCacheStamp = currentStamp;
65
143
  _providersCache = defaultConfig();
66
144
  return _providersCache;
67
145
  }
68
146
  }
69
147
 
70
148
  function saveProviders(config) {
71
- if (!fs.existsSync(METAME_DIR)) fs.mkdirSync(METAME_DIR, { recursive: true });
72
- fs.writeFileSync(PROVIDERS_FILE, yaml.dump(config, { lineWidth: -1 }), 'utf8');
73
- _providersCache = null; // invalidate on write
149
+ const providersFile = getProvidersFilePath();
150
+ const metameDir = path.dirname(providersFile);
151
+ if (!fs.existsSync(metameDir)) fs.mkdirSync(metameDir, { recursive: true });
152
+ fs.writeFileSync(providersFile, yaml.dump(config, { lineWidth: -1 }), 'utf8');
153
+ _providersCache = null;
154
+ _providersCachePath = providersFile;
155
+ _providersCacheStamp = '';
74
156
  }
75
157
 
76
158
  // ---------------------------------------------------------
@@ -182,6 +264,19 @@ function setRole(role, providerName) {
182
264
  saveProviders(config);
183
265
  }
184
266
 
267
+ function getDistillModel() {
268
+ const config = loadProviders();
269
+ return resolveDistillModel(config);
270
+ }
271
+
272
+ function setDistillModel(model) {
273
+ const config = loadProviders();
274
+ const normalized = normalizeDistillModel(model, { allowEmpty: true });
275
+ config.distill_model = normalized || null;
276
+ saveProviders(config);
277
+ return config.distill_model;
278
+ }
279
+
185
280
  // ---------------------------------------------------------
186
281
  // DISPLAY
187
282
  // ---------------------------------------------------------
@@ -204,6 +299,7 @@ function listFormatted() {
204
299
  if (d) lines.push(` Distill provider: ${d}`);
205
300
  if (dm) lines.push(` Daemon provider: ${dm}`);
206
301
  }
302
+ lines.push(` Distill model: ${resolveDistillModel(config)}`);
207
303
 
208
304
  return lines.join('\n');
209
305
  }
@@ -212,18 +308,45 @@ function listFormatted() {
212
308
  // Claude subprocess helper (shared by distill.js + skill-evolution.js)
213
309
  // ---------------------------------------------------------
214
310
  /**
215
- * Call `claude -p --model haiku` as a subprocess with extra env vars.
216
- * Deletes CLAUDECODE from env to prevent recursive session detection.
311
+ * Historical name: now this helper calls the configured distill model,
312
+ * not necessarily Haiku.
313
+ */
314
+ function callHaiku(input, extraEnv, timeout, options = {}) {
315
+ return callDistillModel(input, extraEnv, timeout, options);
316
+ }
317
+
318
+ /**
319
+ * Call distill model as a subprocess with extra env vars.
320
+ * Engine-aware: claude uses `claude -p --model`, codex uses `codex exec --json -m`.
217
321
  */
218
- function callHaiku(input, extraEnv, timeout) {
322
+ function callDistillModel(input, extraEnv, timeout, options = {}) {
219
323
  const { execFile } = require('child_process');
220
324
  const env = { ...process.env, ...extraEnv, METAME_INTERNAL_PROMPT: '1' };
221
325
  delete env.CLAUDECODE;
326
+ // Force refresh to pick up cross-process edits to providers.yaml immediately.
327
+ const config = loadProviders({ force: true });
328
+ const model = resolveDistillModel(config, options.model);
329
+ const engine = options.engine || _currentEngine;
330
+ const bin = engine === 'codex' ? 'codex' : 'claude';
331
+ const args = engine === 'codex'
332
+ ? ['exec', '--json', '-m', model, '--full-auto', '-']
333
+ : ['-p', '--model', model, '--no-session-persistence'];
334
+ // On Windows, bare binary names need shell:true to resolve .cmd wrappers.
335
+ // For codex, also sanitize CODEX_HOME if it points to a non-existent path.
336
+ const isWin = process.platform === 'win32';
337
+ if (isWin && engine === 'codex' && env.CODEX_HOME && !fs.existsSync(env.CODEX_HOME)) {
338
+ delete env.CODEX_HOME;
339
+ }
340
+ const spawnOpts = {
341
+ env,
342
+ timeout,
343
+ maxBuffer: 10 * 1024 * 1024,
344
+ ...(isWin ? { shell: process.env.COMSPEC || true, windowsHide: true } : {}),
345
+ };
222
346
  return new Promise((resolve, reject) => {
223
347
  const proc = execFile(
224
- 'claude',
225
- ['-p', '--model', 'haiku', '--no-session-persistence'],
226
- { env, timeout, maxBuffer: 10 * 1024 * 1024 },
348
+ bin, args,
349
+ spawnOpts,
227
350
  (err, stdout, stderr) => {
228
351
  if (err) {
229
352
  const detail = (stderr || stdout || '').trim().split('\n')[0];
@@ -231,7 +354,24 @@ function callHaiku(input, extraEnv, timeout) {
231
354
  err.stdout = stdout;
232
355
  err.stderr = stderr;
233
356
  reject(err);
234
- } else resolve(stdout.trim());
357
+ } else {
358
+ // codex --json outputs JSON lines; extract agent message text
359
+ if (engine === 'codex') {
360
+ try {
361
+ const lines = stdout.trim().split('\n');
362
+ for (let i = lines.length - 1; i >= 0; i--) {
363
+ const evt = JSON.parse(lines[i]);
364
+ if (evt.type === 'item.completed' && evt.item && evt.item.type === 'agent_message') {
365
+ // item.text (string) is the primary field; content[] is an alternative format
366
+ const text = evt.item.text
367
+ || (evt.item.content || []).filter(c => c.type === 'text').map(c => c.text).join('\n');
368
+ if (text && text.trim()) { resolve(text.trim()); return; }
369
+ }
370
+ }
371
+ } catch { /* fall through */ }
372
+ }
373
+ resolve(stdout.trim());
374
+ }
235
375
  },
236
376
  );
237
377
  proc.stdin.write(input);
@@ -240,9 +380,14 @@ function callHaiku(input, extraEnv, timeout) {
240
380
  }
241
381
 
242
382
  // ---------------------------------------------------------
243
- // EXPORTS
383
+ // ENGINE AWARENESS (set by daemon.js setDefaultEngine)
384
+ // ---------------------------------------------------------
385
+ let _currentEngine = process.env.METAME_ENGINE === 'codex' ? 'codex' : 'claude';
386
+ function setEngine(name) { _currentEngine = (name === 'codex') ? 'codex' : 'claude'; }
387
+ function getEngine() { return _currentEngine; }
388
+
244
389
  // ---------------------------------------------------------
245
- module.exports = {
390
+ const api = {
246
391
  loadProviders,
247
392
  saveProviders,
248
393
  buildEnv,
@@ -256,7 +401,22 @@ module.exports = {
256
401
  addProvider,
257
402
  removeProvider,
258
403
  setRole,
404
+ getDistillModel,
405
+ setDistillModel,
406
+ normalizeDistillModel,
259
407
  listFormatted,
408
+ callDistillModel,
260
409
  callHaiku,
261
- PROVIDERS_FILE,
410
+ getProvidersFilePath,
411
+ setEngine,
412
+ getEngine,
262
413
  };
414
+
415
+ Object.defineProperty(api, 'PROVIDERS_FILE', {
416
+ enumerable: true,
417
+ get: () => getProvidersFilePath(),
418
+ });
419
+
420
+ // EXPORTS
421
+ // ---------------------------------------------------------
422
+ module.exports = api;
package/scripts/schema.js CHANGED
@@ -109,6 +109,9 @@ const SCHEMA = {
109
109
  'growth.last_reflection': { tier: 'T5', type: 'string' },
110
110
  'growth.quiet_until': { tier: 'T5', type: 'string' },
111
111
  'growth.mirror_enabled': { tier: 'T5', type: 'boolean' },
112
+ 'growth.mentor_mode': { tier: 'T5', type: 'enum', values: ['off', 'gentle', 'active', 'intense'] },
113
+ 'growth.mentor_friction_level': { tier: 'T5', type: 'number', min: 0, max: 10 },
114
+ 'growth.weekly_report_last': { tier: 'T5', type: 'string' },
112
115
  };
113
116
 
114
117
  /**
@@ -181,6 +184,15 @@ function validate(key, value) {
181
184
  }
182
185
  }
183
186
 
187
+ if (def.type === 'number' && typeof value === 'number') {
188
+ if (Number.isFinite(def.min) && value < def.min) {
189
+ return { valid: false, reason: `Number must be >= ${def.min}` };
190
+ }
191
+ if (Number.isFinite(def.max) && value > def.max) {
192
+ return { valid: false, reason: `Number must be <= ${def.max}` };
193
+ }
194
+ }
195
+
184
196
  if (def.type === 'map') {
185
197
  if (typeof value !== 'object' || Array.isArray(value)) {
186
198
  return { valid: false, reason: `${key} must be an object (map)` };
@@ -23,6 +23,88 @@ let _stateDb = null;
23
23
  let _stmtIsProcessed = null;
24
24
  let _stmtMarkProcessed = null;
25
25
 
26
+ function normalizeTsMs(ts) {
27
+ if (!ts) return 0;
28
+ const n = new Date(ts).getTime();
29
+ return Number.isFinite(n) ? n : 0;
30
+ }
31
+
32
+ function extractUserText(content) {
33
+ if (typeof content === 'string') return content;
34
+ if (Array.isArray(content)) {
35
+ for (const item of content) {
36
+ if (item && item.type === 'text' && item.text) return item.text;
37
+ }
38
+ }
39
+ return '';
40
+ }
41
+
42
+ function extractToolResultText(content) {
43
+ if (typeof content === 'string') return content;
44
+ if (!Array.isArray(content)) return '';
45
+ return content.map(c => {
46
+ if (typeof c === 'string') return c;
47
+ if (c && typeof c.text === 'string') return c.text;
48
+ return '';
49
+ }).join(' ');
50
+ }
51
+
52
+ function tokenizeForRepetition(text) {
53
+ const input = String(text || '').toLowerCase();
54
+ if (!input) return new Set();
55
+
56
+ const out = new Set();
57
+ const ascii = input.match(/[a-z0-9_./-]{2,}/g) || [];
58
+ for (const t of ascii) out.add(t);
59
+
60
+ const hanRuns = input.match(/[\u4e00-\u9fff]{2,}/g) || [];
61
+ for (const run of hanRuns) {
62
+ if (run.length === 2) out.add(run);
63
+ else {
64
+ for (let i = 0; i < run.length - 1; i++) out.add(run.slice(i, i + 2));
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+
70
+ function overlapRate3(a, b, c) {
71
+ if (!a.size || !b.size || !c.size) return 0;
72
+ const union = new Set([...a, ...b, ...c]);
73
+ if (!union.size) return 0;
74
+ let common = 0;
75
+ for (const t of a) {
76
+ if (b.has(t) && c.has(t)) common++;
77
+ }
78
+ return common / union.size;
79
+ }
80
+
81
+ function parseGitDiffLines(text) {
82
+ const src = String(text || '');
83
+ if (!src) return 0;
84
+
85
+ let best = 0;
86
+ // Matches: "2 files changed, 40 insertions(+), 20 deletions(-)"
87
+ const shortstat = src.match(/(\d+)\s+insertions?\(\+\)(?:,\s*(\d+)\s+deletions?\(-\))?/i);
88
+ if (shortstat) {
89
+ const ins = Number(shortstat[1]) || 0;
90
+ const del = Number(shortstat[2]) || 0;
91
+ best = Math.max(best, ins + del);
92
+ }
93
+
94
+ // Matches numstat lines: "12\t3\tsrc/file.js"
95
+ const rows = src.split('\n');
96
+ let numstatTotal = 0;
97
+ for (const row of rows) {
98
+ const m = row.match(/^\s*(\d+|-)\s+(\d+|-)\s+.+$/);
99
+ if (!m) continue;
100
+ const ins = m[1] === '-' ? 0 : (Number(m[1]) || 0);
101
+ const del = m[2] === '-' ? 0 : (Number(m[2]) || 0);
102
+ numstatTotal += ins + del;
103
+ }
104
+ best = Math.max(best, numstatTotal);
105
+ return best;
106
+ }
107
+
26
108
  /**
27
109
  * Initialize analytics state DB.
28
110
  */
@@ -180,16 +262,88 @@ function extractSkeleton(jsonlPath) {
180
262
  branch: null,
181
263
  file_dirs: new Set(),
182
264
  intent: null,
265
+ inter_message_gaps: [],
266
+ tool_error_count: 0,
267
+ retry_sequences: 0,
268
+ longest_pause_sec: 0,
269
+ avg_pause_sec: 0,
270
+ semantic_repetition: 0,
271
+ file_churn: 0,
272
+ git_diff_lines: 0,
273
+ error_recovered: false,
274
+ };
275
+
276
+ const userTsMs = [];
277
+ const userTexts = [];
278
+ const toolUseById = new Map();
279
+ let lastToolName = null;
280
+ let seenToolError = false;
281
+ let seenToolSuccessAfterError = false;
282
+ const fileStates = new Map(); // 0/undefined: clean, 1: modified, 2: rolled back after modify
283
+
284
+ const markFileModified = (filePath) => {
285
+ if (!filePath || typeof filePath !== 'string') return;
286
+ const prev = fileStates.get(filePath) || 0;
287
+ if (prev === 2) skeleton.file_churn++;
288
+ fileStates.set(filePath, 1);
289
+ };
290
+
291
+ const markFileRolledBack = (filePath) => {
292
+ if (!filePath || typeof filePath !== 'string') return;
293
+ const prev = fileStates.get(filePath) || 0;
294
+ if (prev === 1) fileStates.set(filePath, 2);
295
+ };
296
+
297
+ const markAllRolledBack = () => {
298
+ for (const [k, v] of fileStates.entries()) {
299
+ if (v === 1) fileStates.set(k, 2);
300
+ }
301
+ };
302
+
303
+ const parseRollbackTargets = (cmd) => {
304
+ const out = [];
305
+ const text = String(cmd || '').trim();
306
+ if (!text) return out;
307
+
308
+ const checkout = text.match(/\bgit\s+checkout\s+--\s+(.+)$/i);
309
+ if (checkout && checkout[1]) {
310
+ for (const t of checkout[1].trim().split(/\s+/)) out.push(t.replace(/^['"]|['"]$/g, ''));
311
+ }
312
+ const restore = text.match(/\bgit\s+restore\b(?:\s+--\S+)*\s+(.+)$/i);
313
+ if (restore && restore[1]) {
314
+ for (const t of restore[1].trim().split(/\s+/)) out.push(t.replace(/^['"]|['"]$/g, ''));
315
+ }
316
+ return out.filter(Boolean);
317
+ };
318
+
319
+ const registerToolResult = (result, toolUseId = '') => {
320
+ if (!result || typeof result !== 'object') return;
321
+ const isErr = !!result.is_error;
322
+ if (isErr) {
323
+ skeleton.tool_error_count++;
324
+ seenToolError = true;
325
+ } else if (seenToolError) {
326
+ seenToolSuccessAfterError = true;
327
+ }
328
+
329
+ const text = extractToolResultText(result.content);
330
+ const toolMeta = toolUseById.get(String(toolUseId || ''));
331
+ const fromDiffCmd = !!(toolMeta && /\bgit\s+diff\b/i.test(String(toolMeta.command || '')));
332
+ if (fromDiffCmd || /\bfiles?\s+changed\b/i.test(text) || /^\s*(\d+|-)\s+(\d+|-)\s+.+$/m.test(text)) {
333
+ skeleton.git_diff_lines = Math.max(skeleton.git_diff_lines, parseGitDiffLines(text));
334
+ }
183
335
  };
184
336
 
185
337
  for (const line of lines) {
186
338
  if (!line.trim()) continue;
187
339
 
188
- // Fast pre-filter: only parse lines that look like user or assistant messages
340
+ // Fast pre-filter: parse user/assistant/tool_result entries only
189
341
  if (!line.includes('"type":"user"') &&
190
342
  !line.includes('"type":"assistant"') &&
343
+ !line.includes('"type":"tool_result"') &&
191
344
  !line.includes('"type": "user"') &&
192
- !line.includes('"type": "assistant"')) {
345
+ !line.includes('"type": "assistant"') &&
346
+ !line.includes('"type": "tool_result"')) {
193
347
  continue;
194
348
  }
195
349
 
@@ -222,18 +376,22 @@ function extractSkeleton(jsonlPath) {
222
376
 
223
377
  const content = msg.content;
224
378
  // Handle both string and array content
225
- let userText = '';
226
- if (typeof content === 'string') {
227
- userText = content;
228
- skeleton.user_snippets.push(content.slice(0, 100));
379
+ const userText = extractUserText(content);
380
+ if (userText) {
381
+ skeleton.user_snippets.push(userText.slice(0, 100));
229
382
  skeleton.message_count++;
230
- } else if (Array.isArray(content)) {
383
+ }
384
+ if (userText) userTexts.push(userText);
385
+ if (ts) {
386
+ const ms = normalizeTsMs(ts);
387
+ if (ms > 0) userTsMs.push(ms);
388
+ }
389
+
390
+ // Some transcripts embed tool_result inside user blocks.
391
+ if (Array.isArray(content)) {
231
392
  for (const item of content) {
232
- if (item.type === 'text' && item.text) {
233
- userText = item.text;
234
- skeleton.user_snippets.push(item.text.slice(0, 100));
235
- skeleton.message_count++;
236
- break; // One snippet per user message
393
+ if (item && item.type === 'tool_result') {
394
+ registerToolResult(item, item.tool_use_id || '');
237
395
  }
238
396
  }
239
397
  }
@@ -257,6 +415,14 @@ function extractSkeleton(jsonlPath) {
257
415
  const name = item.name || 'unknown';
258
416
  skeleton.tool_counts[name] = (skeleton.tool_counts[name] || 0) + 1;
259
417
  skeleton.total_tool_calls++;
418
+ if (name === lastToolName) skeleton.retry_sequences++;
419
+ lastToolName = name;
420
+ if (item.id) {
421
+ toolUseById.set(String(item.id), {
422
+ name,
423
+ command: item.input && typeof item.input.command === 'string' ? item.input.command : '',
424
+ });
425
+ }
260
426
 
261
427
  // Extract file directories from Read/Edit/Write operations
262
428
  if ((name === 'Read' || name === 'Edit' || name === 'Write') &&
@@ -265,6 +431,12 @@ function extractSkeleton(jsonlPath) {
265
431
  const segments = dirPath.split(path.sep).filter(Boolean);
266
432
  const shortDir = segments.slice(-2).join('/');
267
433
  if (shortDir) skeleton.file_dirs.add(shortDir);
434
+ if (name === 'Edit' || name === 'Write') {
435
+ markFileModified(item.input.file_path);
436
+ }
437
+ }
438
+ if (name === 'MultiEdit' && item.input && typeof item.input.file_path === 'string') {
439
+ markFileModified(item.input.file_path);
268
440
  }
269
441
 
270
442
  // Detect git commits from Bash tool calls
@@ -273,10 +445,22 @@ function extractSkeleton(jsonlPath) {
273
445
  if (cmd.includes('git commit') || cmd.includes('git push')) {
274
446
  skeleton.git_committed = true;
275
447
  }
448
+ if (/\bgit\s+reset\s+--hard\b/i.test(cmd) || /\bgit\s+checkout\s+--\s*\./i.test(cmd)) {
449
+ markAllRolledBack();
450
+ } else {
451
+ const targets = parseRollbackTargets(cmd);
452
+ for (const t of targets) {
453
+ if (t === '.' || t === '*') markAllRolledBack();
454
+ else markFileRolledBack(t);
455
+ }
456
+ }
276
457
  }
277
458
  }
278
459
  }
279
460
  }
461
+ } else if (type === 'tool_result') {
462
+ const result = entry.message || {};
463
+ registerToolResult(result, result.tool_use_id || '');
280
464
  }
281
465
  }
282
466
 
@@ -291,6 +475,30 @@ function extractSkeleton(jsonlPath) {
291
475
  skeleton.models = [...skeleton.models];
292
476
  skeleton.file_dirs = [...skeleton.file_dirs].slice(0, 5);
293
477
 
478
+ // Inter-message gaps (sec), filter long breaks (>2h)
479
+ for (let i = 1; i < userTsMs.length; i++) {
480
+ const sec = Math.round((userTsMs[i] - userTsMs[i - 1]) / 1000);
481
+ if (sec > 0 && sec <= 2 * 60 * 60) skeleton.inter_message_gaps.push(sec);
482
+ }
483
+ if (skeleton.inter_message_gaps.length > 0) {
484
+ const sum = skeleton.inter_message_gaps.reduce((a, b) => a + b, 0);
485
+ skeleton.longest_pause_sec = Math.max(...skeleton.inter_message_gaps);
486
+ skeleton.avg_pause_sec = Math.round(sum / skeleton.inter_message_gaps.length);
487
+ }
488
+
489
+ // Semantic repetition: max overlap across sliding windows of 3 user messages.
490
+ if (userTexts.length >= 3) {
491
+ let maxOverlap = 0;
492
+ for (let i = 2; i < userTexts.length; i++) {
493
+ const a = tokenizeForRepetition(userTexts[i - 2]);
494
+ const b = tokenizeForRepetition(userTexts[i - 1]);
495
+ const c = tokenizeForRepetition(userTexts[i]);
496
+ maxOverlap = Math.max(maxOverlap, overlapRate3(a, b, c));
497
+ }
498
+ skeleton.semantic_repetition = Number(maxOverlap.toFixed(3));
499
+ }
500
+ skeleton.error_recovered = !!(seenToolError && seenToolSuccessAfterError);
501
+
294
502
  // Cap user snippets at 10
295
503
  if (skeleton.user_snippets.length > 10) {
296
504
  skeleton.user_snippets = skeleton.user_snippets.slice(0, 10);
@@ -663,6 +871,30 @@ function summarizeSession(skeleton, jsonlPath) {
663
871
  };
664
872
  }
665
873
 
874
+ /**
875
+ * Decide whether a session should trigger postmortem generation.
876
+ * Purely numeric heuristics — no semantic inference.
877
+ */
878
+ function detectSignificantSession(skeleton) {
879
+ if (!skeleton || typeof skeleton !== 'object') {
880
+ return { significant: false, reasons: [] };
881
+ }
882
+ const reasons = [];
883
+ const diffLines = Number(skeleton.git_diff_lines || 0);
884
+ const toolErrors = Number(skeleton.tool_error_count || 0);
885
+ const retries = Number(skeleton.retry_sequences || 0);
886
+ const durationMin = Number(skeleton.duration_min || 0);
887
+ const recovered = !!skeleton.error_recovered;
888
+
889
+ if (diffLines > 50 && toolErrors > 0 && recovered) {
890
+ reasons.push('large_change_with_error_recovery');
891
+ }
892
+ if (durationMin > 60 && retries > 5) {
893
+ reasons.push('long_debug_retry_loop');
894
+ }
895
+ return { significant: reasons.length > 0, reasons };
896
+ }
897
+
666
898
  module.exports = {
667
899
  findLatestUnanalyzedSession,
668
900
  findSessionById,
@@ -673,6 +905,7 @@ module.exports = {
673
905
  formatForPrompt,
674
906
  formatGoalContext,
675
907
  summarizeSession,
908
+ detectSignificantSession,
676
909
  markAnalyzed,
677
910
  markFactsExtracted,
678
911
  };