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.
- package/README.md +136 -94
- package/index.js +312 -57
- package/package.json +8 -4
- package/scripts/agent-layer.js +320 -0
- package/scripts/daemon-admin-commands.js +328 -28
- package/scripts/daemon-agent-commands.js +145 -6
- package/scripts/daemon-agent-tools.js +163 -7
- package/scripts/daemon-bridges.js +110 -20
- package/scripts/daemon-checkpoints.js +36 -7
- package/scripts/daemon-claude-engine.js +849 -358
- package/scripts/daemon-command-router.js +31 -10
- package/scripts/daemon-default.yaml +28 -4
- package/scripts/daemon-engine-runtime.js +328 -0
- package/scripts/daemon-exec-commands.js +15 -7
- package/scripts/daemon-notify.js +37 -1
- package/scripts/daemon-ops-commands.js +8 -6
- package/scripts/daemon-runtime-lifecycle.js +129 -5
- package/scripts/daemon-session-commands.js +60 -25
- package/scripts/daemon-session-store.js +121 -13
- package/scripts/daemon-task-scheduler.js +129 -49
- package/scripts/daemon-user-acl.js +35 -9
- package/scripts/daemon.js +268 -33
- package/scripts/distill.js +327 -18
- package/scripts/docs/agent-guide.md +12 -0
- package/scripts/docs/maintenance-manual.md +155 -0
- package/scripts/docs/pointer-map.md +110 -0
- package/scripts/feishu-adapter.js +42 -13
- package/scripts/hooks/stop-session-capture.js +243 -0
- package/scripts/memory-extract.js +105 -6
- package/scripts/memory-nightly-reflect.js +199 -11
- package/scripts/memory.js +134 -3
- package/scripts/mentor-engine.js +405 -0
- package/scripts/platform.js +24 -0
- package/scripts/providers.js +182 -22
- package/scripts/schema.js +12 -0
- package/scripts/session-analytics.js +245 -12
- package/scripts/skill-changelog.js +245 -0
- package/scripts/skill-evolution.js +288 -5
- package/scripts/telegram-adapter.js +12 -8
- package/scripts/usage-classifier.js +1 -1
- package/scripts/daemon-admin-commands.test.js +0 -333
- package/scripts/daemon-task-envelope.test.js +0 -59
- package/scripts/daemon-task-scheduler.test.js +0 -106
- package/scripts/reliability-core.test.js +0 -280
- package/scripts/skill-evolution.test.js +0 -113
- package/scripts/task-board.test.js +0 -83
- package/scripts/test_daemon.js +0 -1407
- package/scripts/utils.test.js +0 -192
package/scripts/providers.js
CHANGED
|
@@ -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
|
|
50
|
-
|
|
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(
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
*
|
|
216
|
-
*
|
|
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
|
|
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
|
-
|
|
225
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
226
|
-
if (
|
|
227
|
-
userText
|
|
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
|
-
}
|
|
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 === '
|
|
233
|
-
|
|
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
|
};
|