metame-cli 1.4.33 → 1.5.0

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 (44) hide show
  1. package/README.md +187 -48
  2. package/index.js +148 -9
  3. package/package.json +6 -3
  4. package/scripts/daemon-admin-commands.js +254 -9
  5. package/scripts/daemon-agent-commands.js +64 -6
  6. package/scripts/daemon-agent-tools.js +26 -5
  7. package/scripts/daemon-bridges.js +110 -20
  8. package/scripts/daemon-claude-engine.js +704 -268
  9. package/scripts/daemon-command-router.js +24 -8
  10. package/scripts/daemon-default.yaml +28 -4
  11. package/scripts/daemon-engine-runtime.js +275 -0
  12. package/scripts/daemon-exec-commands.js +10 -4
  13. package/scripts/daemon-notify.js +37 -1
  14. package/scripts/daemon-runtime-lifecycle.js +2 -1
  15. package/scripts/daemon-session-commands.js +52 -4
  16. package/scripts/daemon-session-store.js +2 -1
  17. package/scripts/daemon-task-scheduler.js +87 -28
  18. package/scripts/daemon-user-acl.js +26 -9
  19. package/scripts/daemon.js +81 -17
  20. package/scripts/distill.js +323 -18
  21. package/scripts/docs/agent-guide.md +12 -0
  22. package/scripts/docs/maintenance-manual.md +119 -0
  23. package/scripts/docs/pointer-map.md +88 -0
  24. package/scripts/feishu-adapter.js +6 -1
  25. package/scripts/hooks/stop-session-capture.js +243 -0
  26. package/scripts/memory-extract.js +100 -5
  27. package/scripts/memory-nightly-reflect.js +196 -11
  28. package/scripts/memory.js +134 -3
  29. package/scripts/mentor-engine.js +405 -0
  30. package/scripts/platform.js +2 -0
  31. package/scripts/providers.js +169 -21
  32. package/scripts/schema.js +12 -0
  33. package/scripts/session-analytics.js +245 -12
  34. package/scripts/skill-changelog.js +245 -0
  35. package/scripts/skill-evolution.js +288 -5
  36. package/scripts/usage-classifier.js +1 -1
  37. package/scripts/daemon-admin-commands.test.js +0 -333
  38. package/scripts/daemon-task-envelope.test.js +0 -59
  39. package/scripts/daemon-task-scheduler.test.js +0 -106
  40. package/scripts/reliability-core.test.js +0 -280
  41. package/scripts/skill-evolution.test.js +0 -113
  42. package/scripts/task-board.test.js +0 -83
  43. package/scripts/test_daemon.js +0 -1407
  44. package/scripts/utils.test.js +0 -192
package/scripts/memory.js CHANGED
@@ -122,6 +122,17 @@ function getDb() {
122
122
  )
123
123
  `);
124
124
 
125
+ // Optional concept label side-table (non-invasive, no ALTER on facts schema)
126
+ _db.exec(`
127
+ CREATE TABLE IF NOT EXISTS fact_labels (
128
+ fact_id TEXT NOT NULL REFERENCES facts(id) ON DELETE CASCADE,
129
+ label TEXT NOT NULL,
130
+ domain TEXT,
131
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
132
+ PRIMARY KEY (fact_id, label)
133
+ )
134
+ `);
135
+
125
136
  // FTS5 index for facts (separate from sessions_fts, zero compatibility risk)
126
137
  try {
127
138
  _db.exec(`
@@ -159,6 +170,8 @@ function getDb() {
159
170
  try { _db.exec('CREATE INDEX IF NOT EXISTS idx_facts_entity ON facts(entity)'); } catch {}
160
171
  try { _db.exec('CREATE INDEX IF NOT EXISTS idx_facts_entity_relation ON facts(entity, relation)'); } catch {}
161
172
  try { _db.exec('CREATE INDEX IF NOT EXISTS idx_facts_project ON facts(project)'); } catch {}
173
+ try { _db.exec('CREATE INDEX IF NOT EXISTS idx_fact_labels_label ON fact_labels(label)'); } catch {}
174
+ try { _db.exec('CREATE INDEX IF NOT EXISTS idx_fact_labels_domain ON fact_labels(domain)'); } catch {}
162
175
 
163
176
  // Backward-compatible migration for old DBs without `scope`
164
177
  try { _db.exec('ALTER TABLE facts ADD COLUMN scope TEXT DEFAULT NULL'); } catch {}
@@ -369,7 +382,43 @@ function saveFacts(sessionId, project, facts, { scope = null } = {}) {
369
382
 
370
383
  if (conflicts > 0) log('WARN', `[MEMORY] ${conflicts} conflict(s) detected`);
371
384
 
372
- return { saved, skipped, superseded, conflicts };
385
+ return { saved, skipped, superseded, conflicts, savedFacts };
386
+ }
387
+
388
+ /**
389
+ * Save concept labels for facts (side-table).
390
+ *
391
+ * @param {Array<{fact_id:string,label:string,domain?:string}>} rows
392
+ * @returns {{ saved: number, skipped: number }}
393
+ */
394
+ function saveFactLabels(rows) {
395
+ if (!Array.isArray(rows) || rows.length === 0) return { saved: 0, skipped: 0 };
396
+ const db = getDb();
397
+ const upsert = db.prepare(`
398
+ INSERT INTO fact_labels (fact_id, label, domain)
399
+ VALUES (?, ?, ?)
400
+ ON CONFLICT(fact_id, label) DO UPDATE SET
401
+ domain = COALESCE(excluded.domain, fact_labels.domain)
402
+ `);
403
+
404
+ let saved = 0;
405
+ let skipped = 0;
406
+ for (const row of rows) {
407
+ const factId = String(row && row.fact_id ? row.fact_id : '').trim();
408
+ const label = String(row && row.label ? row.label : '').trim();
409
+ const domainRaw = row && row.domain != null ? String(row.domain).trim() : '';
410
+ const domain = domainRaw || null;
411
+ if (!factId || !label) { skipped++; continue; }
412
+ if (label.length > 60) { skipped++; continue; }
413
+ if (domain && domain.length > 60) { skipped++; continue; }
414
+ try {
415
+ upsert.run(factId, label, domain);
416
+ saved++;
417
+ } catch {
418
+ skipped++;
419
+ }
420
+ }
421
+ return { saved, skipped };
373
422
  }
374
423
 
375
424
  /**
@@ -613,12 +662,46 @@ function searchFacts(query, { limit = 5, project = null, scope = null } = {}) {
613
662
  }
614
663
  const ftsResults = db.prepare(sql).all(...params);
615
664
  if (ftsResults.length > 0) {
665
+ // Supplement with fact_labels matches (concepts written by memory-extract).
666
+ const ftsIds = new Set(ftsResults.map(r => r.id));
667
+ const remaining = limit - ftsResults.length;
668
+ if (remaining > 0) {
669
+ try {
670
+ const labelLike = '%' + query.trim() + '%';
671
+ let labelSql = `
672
+ SELECT DISTINCT f.id, f.entity, f.relation, f.value, f.confidence, f.project, f.scope, f.tags, f.created_at
673
+ FROM fact_labels fl JOIN facts f ON f.id = fl.fact_id
674
+ WHERE fl.label LIKE ?
675
+ AND f.superseded_by IS NULL
676
+ AND (f.conflict_status IS NULL OR f.conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))`;
677
+ const labelParams = [labelLike];
678
+ if (scope && project) {
679
+ labelSql += ` AND ((f.scope = ? OR f.scope = '*') OR (f.scope IS NULL AND (f.project = ? OR f.project = '*')))`;
680
+ labelParams.push(scope, project);
681
+ } else if (scope) {
682
+ labelSql += ` AND (f.scope = ? OR f.scope = '*')`;
683
+ labelParams.push(scope);
684
+ } else if (project) {
685
+ labelSql += ` AND (f.project = ? OR f.project = '*')`;
686
+ labelParams.push(project);
687
+ }
688
+ labelSql += ` LIMIT ?`;
689
+ labelParams.push(remaining + ftsResults.length);
690
+ const labelRows = db.prepare(labelSql).all(...labelParams);
691
+ for (const row of labelRows) {
692
+ if (!ftsIds.has(row.id) && ftsResults.length < limit) {
693
+ ftsIds.add(row.id);
694
+ ftsResults.push(row);
695
+ }
696
+ }
697
+ } catch { /* fact_labels table may not exist yet */ }
698
+ }
616
699
  _trackSearch(ftsResults.map(r => r.id));
617
700
  return ftsResults;
618
701
  }
619
702
  } catch { /* FTS error, fall through */ }
620
703
 
621
- // LIKE fallback
704
+ // LIKE fallback (also check fact_labels)
622
705
  const like = '%' + query.trim() + '%';
623
706
  const likeSql = scope && project
624
707
  ? `SELECT id, entity, relation, value, confidence, project, scope, tags, created_at
@@ -651,6 +734,39 @@ function searchFacts(query, { limit = 5, project = null, scope = null } = {}) {
651
734
  : project
652
735
  ? db.prepare(likeSql).all(like, like, like, project, limit)
653
736
  : db.prepare(likeSql).all(like, like, like, limit);
737
+ // Supplement LIKE results with fact_labels matches.
738
+ if (likeResults.length < limit) {
739
+ try {
740
+ const labelLike = '%' + query.trim() + '%';
741
+ const likeIds = new Set(likeResults.map(r => r.id));
742
+ let labelSql2 = `
743
+ SELECT DISTINCT f.id, f.entity, f.relation, f.value, f.confidence, f.project, f.scope, f.tags, f.created_at
744
+ FROM fact_labels fl JOIN facts f ON f.id = fl.fact_id
745
+ WHERE fl.label LIKE ?
746
+ AND f.superseded_by IS NULL
747
+ AND (f.conflict_status IS NULL OR f.conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))`;
748
+ const labelParams2 = [labelLike];
749
+ if (scope && project) {
750
+ labelSql2 += ` AND ((f.scope = ? OR f.scope = '*') OR (f.scope IS NULL AND (f.project = ? OR f.project = '*')))`;
751
+ labelParams2.push(scope, project);
752
+ } else if (scope) {
753
+ labelSql2 += ` AND (f.scope = ? OR f.scope = '*')`;
754
+ labelParams2.push(scope);
755
+ } else if (project) {
756
+ labelSql2 += ` AND (f.project = ? OR f.project = '*')`;
757
+ labelParams2.push(project);
758
+ }
759
+ labelSql2 += ` LIMIT ?`;
760
+ labelParams2.push(limit);
761
+ const labelRows = db.prepare(labelSql2).all(...labelParams2);
762
+ for (const row of labelRows) {
763
+ if (!likeIds.has(row.id) && likeResults.length < limit) {
764
+ likeIds.add(row.id);
765
+ likeResults.push(row);
766
+ }
767
+ }
768
+ } catch { /* fact_labels table may not exist yet */ }
769
+ }
654
770
  if (likeResults.length > 0) _trackSearch(likeResults.map(r => r.id));
655
771
  return likeResults;
656
772
  }
@@ -839,4 +955,19 @@ function forceClose() {
839
955
  if (_db) { _db.close(); _db = null; }
840
956
  }
841
957
 
842
- module.exports = { saveSession, saveFacts, searchFacts, searchFactsAsync, searchSessions, recentSessions, getSession, stats, acquire, release, close, forceClose, DB_PATH };
958
+ module.exports = {
959
+ saveSession,
960
+ saveFacts,
961
+ saveFactLabels,
962
+ searchFacts,
963
+ searchFactsAsync,
964
+ searchSessions,
965
+ recentSessions,
966
+ getSession,
967
+ stats,
968
+ acquire,
969
+ release,
970
+ close,
971
+ forceClose,
972
+ DB_PATH,
973
+ };
@@ -0,0 +1,405 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ const DEFAULT_COOLDOWN_MS = 30 * 60 * 1000;
8
+ const FATIGUE_COOLDOWN_MS = 60 * 60 * 1000;
9
+
10
+ function runtimeFilePath() {
11
+ const override = String(process.env.METAME_MENTOR_RUNTIME || '').trim();
12
+ if (override) return override;
13
+ return path.join(os.homedir(), '.metame', 'mentor_runtime.json');
14
+ }
15
+
16
+ function defaultRuntime() {
17
+ return {
18
+ emotion_breaker_until: null,
19
+ debts: [],
20
+ last_fatigue_alert: null,
21
+ last_pattern_check: null,
22
+ };
23
+ }
24
+
25
+ function safeNow(nowMs) {
26
+ return Number.isFinite(nowMs) ? nowMs : Date.now();
27
+ }
28
+
29
+ function ensureParentDir(file) {
30
+ const dir = path.dirname(file);
31
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
32
+ }
33
+
34
+ function loadRuntime() {
35
+ const file = runtimeFilePath();
36
+ try {
37
+ if (!fs.existsSync(file)) return defaultRuntime();
38
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
39
+ return {
40
+ ...defaultRuntime(),
41
+ ...(data && typeof data === 'object' ? data : {}),
42
+ debts: Array.isArray(data && data.debts) ? data.debts : [],
43
+ };
44
+ } catch {
45
+ return defaultRuntime();
46
+ }
47
+ }
48
+
49
+ function saveRuntime(runtime) {
50
+ const file = runtimeFilePath();
51
+ ensureParentDir(file);
52
+ const tmp = `${file}.tmp.${process.pid}.${Date.now()}`;
53
+ fs.writeFileSync(tmp, JSON.stringify(runtime, null, 2), 'utf8');
54
+ fs.renameSync(tmp, file);
55
+ }
56
+
57
+ function normalizeText(input) {
58
+ return String(input || '').trim();
59
+ }
60
+
61
+ function tokenize(text) {
62
+ const input = normalizeText(text).toLowerCase();
63
+ if (!input) return [];
64
+ const out = [];
65
+ const seen = new Set();
66
+ const push = (t) => {
67
+ const v = String(t || '').trim();
68
+ if (!v || seen.has(v)) return;
69
+ seen.add(v);
70
+ out.push(v);
71
+ };
72
+
73
+ const ascii = input.match(/[a-z0-9_./-]{2,}/g) || [];
74
+ for (const t of ascii) push(t);
75
+
76
+ const hanRuns = input.match(/[\u4e00-\u9fff]{2,}/g) || [];
77
+ for (const run of hanRuns) {
78
+ if (run.length === 2) push(run);
79
+ else {
80
+ for (let i = 0; i < run.length - 1; i++) push(run.slice(i, i + 2));
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+
86
+ function overlapRatio(a, b) {
87
+ const sa = new Set(Array.isArray(a) ? a : []);
88
+ const sb = new Set(Array.isArray(b) ? b : []);
89
+ if (!sa.size || !sb.size) return 0;
90
+ let common = 0;
91
+ for (const x of sa) if (sb.has(x)) common++;
92
+ const base = Math.min(sa.size, sb.size);
93
+ return base > 0 ? common / base : 0;
94
+ }
95
+
96
+ function endOfTodayMs(nowMs) {
97
+ const d = new Date(safeNow(nowMs));
98
+ d.setHours(23, 59, 59, 999);
99
+ return d.getTime();
100
+ }
101
+
102
+ function resolveMode(config = {}) {
103
+ const mode = String(config.mode || '').toLowerCase().trim();
104
+ if (mode === 'gentle' || mode === 'active' || mode === 'intense') return mode;
105
+ const level = Number(config.friction_level);
106
+ if (Number.isFinite(level)) {
107
+ if (level >= 8) return 'intense';
108
+ if (level >= 4) return 'active';
109
+ }
110
+ return 'gentle';
111
+ }
112
+
113
+ function checkEmotionBreaker(userMessage, config = {}, nowMs = Date.now()) {
114
+ const text = normalizeText(userMessage);
115
+ const runtime = loadRuntime();
116
+ const now = safeNow(nowMs);
117
+ const until = Number(runtime.emotion_breaker_until || 0);
118
+
119
+ if (until > now) {
120
+ return {
121
+ tripped: true,
122
+ reason: 'cooldown_active',
123
+ response: '已暂停导师模式,先专注把问题解决。',
124
+ remaining_ms: until - now,
125
+ };
126
+ }
127
+
128
+ const baseRe = /[操草靠妈tmd]|fuck|shit|wtf|!!{2,}|??{2,}|急|崩|炸|烦死/i;
129
+ const extras = Array.isArray(config.emotion_keywords_extra) ? config.emotion_keywords_extra : [];
130
+ const hitExtra = extras.find(k => k && text.toLowerCase().includes(String(k).toLowerCase()));
131
+ const hit = baseRe.test(text) || !!hitExtra;
132
+ if (!hit) return { tripped: false };
133
+
134
+ runtime.emotion_breaker_until = now + DEFAULT_COOLDOWN_MS;
135
+ saveRuntime(runtime);
136
+ return {
137
+ tripped: true,
138
+ reason: hitExtra ? `keyword:${hitExtra}` : 'emotion_keyword',
139
+ response: '已暂停导师模式,先专注把问题解决。',
140
+ remaining_ms: DEFAULT_COOLDOWN_MS,
141
+ };
142
+ }
143
+
144
+ function computeZone(skeleton = {}) {
145
+ const toolErrors = Number(skeleton.tool_error_count || 0);
146
+ const retries = Number(skeleton.retry_sequences || 0);
147
+ const repetition = Number(skeleton.semantic_repetition || 0);
148
+ const durationMin = Number(skeleton.duration_min || 0);
149
+ const toolCalls = Number(skeleton.total_tool_calls || 0);
150
+ const avgPause = Number(skeleton.avg_pause_sec || 0);
151
+ const recovered = !!skeleton.error_recovered;
152
+
153
+ let panicScore = 0;
154
+ if (toolErrors >= 3) panicScore++;
155
+ if (retries >= 6) panicScore++;
156
+ if (repetition >= 0.6) panicScore++;
157
+ if (durationMin >= 75 && toolErrors >= 1) panicScore++;
158
+ if (avgPause >= 180) panicScore++;
159
+
160
+ let comfortScore = 0;
161
+ if (toolErrors === 0) comfortScore++;
162
+ if (retries <= 2) comfortScore++;
163
+ if (repetition < 0.35) comfortScore++;
164
+ if (toolCalls >= 3 && durationMin <= 45) comfortScore++;
165
+ if (recovered && toolErrors <= 1) comfortScore++;
166
+
167
+ let zone = 'stretch';
168
+ let dominant = Math.max(panicScore, comfortScore);
169
+ if (panicScore >= 2) zone = 'panic';
170
+ else if (comfortScore >= 3 && panicScore === 0) zone = 'comfort';
171
+
172
+ const confidence = Math.min(0.95, 0.6 + dominant * 0.08);
173
+ return {
174
+ zone,
175
+ confidence: Number(confidence.toFixed(2)),
176
+ signals: {
177
+ tool_error_count: toolErrors,
178
+ retry_sequences: retries,
179
+ semantic_repetition: repetition,
180
+ duration_min: durationMin,
181
+ tool_calls: toolCalls,
182
+ avg_pause_sec: avgPause,
183
+ error_recovered: recovered,
184
+ },
185
+ };
186
+ }
187
+
188
+ function registerDebt(projectId, topic, codeLineCount, nowMs = Date.now()) {
189
+ const pid = normalizeText(projectId);
190
+ const lines = Number(codeLineCount || 0);
191
+ if (!pid || lines <= 30) return null;
192
+
193
+ const t = normalizeText(topic) || 'unknown-topic';
194
+ const now = safeNow(nowMs);
195
+ const runtime = loadRuntime();
196
+ const topicKeywords = tokenize(t).slice(0, 8);
197
+
198
+ const debt = {
199
+ project_id: pid,
200
+ topic: t,
201
+ topic_keywords: topicKeywords,
202
+ code_summary: `Generated ${lines} lines`,
203
+ recorded_at: now,
204
+ expires_at: endOfTodayMs(now),
205
+ };
206
+ runtime.debts.push(debt);
207
+ if (runtime.debts.length > 100) runtime.debts = runtime.debts.slice(-100);
208
+ saveRuntime(runtime);
209
+ return debt;
210
+ }
211
+
212
+ function collectDebt(projectId, currentTopic, nowMs = Date.now()) {
213
+ const pid = normalizeText(projectId);
214
+ if (!pid) return null;
215
+ const runtime = loadRuntime();
216
+ const now = safeNow(nowMs);
217
+
218
+ const valid = [];
219
+ let matched = null;
220
+ const currentKeywords = tokenize(currentTopic).slice(0, 12);
221
+
222
+ for (const debt of runtime.debts) {
223
+ if (!debt || typeof debt !== 'object') continue;
224
+ if (Number(debt.expires_at || 0) < now) continue;
225
+
226
+ if (!matched && debt.project_id === pid) {
227
+ const ratio = overlapRatio(currentKeywords, debt.topic_keywords || []);
228
+ if (ratio > 0.3) {
229
+ matched = {
230
+ ...debt,
231
+ overlap_ratio: Number(ratio.toFixed(2)),
232
+ prompt: `刚才那段 ${debt.topic} 的代码,核心逻辑是什么?`,
233
+ };
234
+ continue;
235
+ }
236
+ }
237
+ valid.push(debt);
238
+ }
239
+
240
+ runtime.debts = valid;
241
+ saveRuntime(runtime);
242
+ return matched;
243
+ }
244
+
245
+ function gcExpiredDebts(nowMs = Date.now()) {
246
+ const runtime = loadRuntime();
247
+ const now = safeNow(nowMs);
248
+ const before = runtime.debts.length;
249
+ runtime.debts = runtime.debts.filter(d => d && Number(d.expires_at || 0) >= now);
250
+ const removed = before - runtime.debts.length;
251
+ if (removed > 0) saveRuntime(runtime);
252
+ return { removed, remaining: runtime.debts.length };
253
+ }
254
+
255
+ function repetitionFromTexts(texts) {
256
+ if (!Array.isArray(texts) || texts.length < 3) return 0;
257
+ let maxOverlap = 0;
258
+ for (let i = 2; i < texts.length; i++) {
259
+ const a = new Set(tokenize(texts[i - 2]));
260
+ const b = new Set(tokenize(texts[i - 1]));
261
+ const c = new Set(tokenize(texts[i]));
262
+ const union = new Set([...a, ...b, ...c]);
263
+ if (!union.size) continue;
264
+ let common = 0;
265
+ for (const t of a) if (b.has(t) && c.has(t)) common++;
266
+ maxOverlap = Math.max(maxOverlap, common / union.size);
267
+ }
268
+ return Number(maxOverlap.toFixed(3));
269
+ }
270
+
271
+ function extractErrorClass(text) {
272
+ const src = normalizeText(text).toLowerCase();
273
+ if (!src) return '';
274
+ if (/(timeout|timed out|超时)/.test(src)) return 'timeout';
275
+ if (/(permission|eacces|denied|权限)/.test(src)) return 'permission';
276
+ if (/(not found|enoent|找不到)/.test(src)) return 'not_found';
277
+ if (/(typeerror|referenceerror|syntaxerror|报错|异常|error)/.test(src)) return 'runtime_error';
278
+ return '';
279
+ }
280
+
281
+ function detectPatterns(recentMessages, sessionStartTime, opts = {}) {
282
+ const now = safeNow(opts.nowMs);
283
+ const runtime = loadRuntime();
284
+ let dirty = false;
285
+ const prevPatternTs = Number(runtime.last_pattern_check || 0);
286
+ if (!prevPatternTs || (now - prevPatternTs) > 30000) {
287
+ runtime.last_pattern_check = now;
288
+ dirty = true;
289
+ }
290
+
291
+ const normalized = (Array.isArray(recentMessages) ? recentMessages : [])
292
+ .map(m => (typeof m === 'string' ? { text: m } : (m && typeof m === 'object' ? m : null)))
293
+ .filter(Boolean);
294
+
295
+ const texts = normalized.map(m => normalizeText(m.text || m.message || ''));
296
+ const shortCount = texts.filter(t => t && t.length < 20).length;
297
+ const toolCalls = normalized.reduce((acc, m) => acc + (Number(m.tool_calls || m.toolCalls || 0) || 0), 0);
298
+ const errorClasses = normalized.map(m => extractErrorClass(m.text || m.message || '')).filter(Boolean);
299
+ const classCount = new Map();
300
+ for (const c of errorClasses) classCount.set(c, (classCount.get(c) || 0) + 1);
301
+ const repeatedError = [...classCount.values()].some(v => v >= 3);
302
+ const semanticRepetition = repetitionFromTexts(texts);
303
+
304
+ const autopilot = shortCount >= 3 && toolCalls >= 3 && errorClasses.length === 0;
305
+ const stuck = repeatedError && semanticRepetition > 0.6;
306
+
307
+ const sessionMs = Math.max(0, now - safeNow(new Date(sessionStartTime).getTime()));
308
+ const isFatiguedRaw = sessionMs > 90 * 60 * 1000;
309
+ const lastFatigue = Number(runtime.last_fatigue_alert || 0);
310
+ const fatigued = isFatiguedRaw && (!lastFatigue || (now - lastFatigue) > FATIGUE_COOLDOWN_MS);
311
+ if (fatigued) {
312
+ runtime.last_fatigue_alert = now;
313
+ dirty = true;
314
+ }
315
+
316
+ let suggestion = '';
317
+ if (stuck) suggestion = '检测到你在反复遇到类似问题,建议先退一步梳理整体思路。';
318
+ else if (fatigued) suggestion = '你已连续工作较久,建议短暂休息后再继续。';
319
+ else if (autopilot) suggestion = '你在高效执行模式,建议确认一下当前方向是否仍然正确。';
320
+
321
+ if (dirty) saveRuntime(runtime);
322
+ return { autopilot, stuck, fatigued, suggestion, semantic_repetition: semanticRepetition };
323
+ }
324
+
325
+ function getRuntimeStatus(nowMs = Date.now()) {
326
+ const runtime = loadRuntime();
327
+ const now = safeNow(nowMs);
328
+ const until = Number(runtime.emotion_breaker_until || 0);
329
+ return {
330
+ debt_count: Array.isArray(runtime.debts) ? runtime.debts.length : 0,
331
+ cooldown_until: until || null,
332
+ cooldown_remaining_ms: until > now ? (until - now) : 0,
333
+ last_fatigue_alert: runtime.last_fatigue_alert || null,
334
+ last_pattern_check: runtime.last_pattern_check || null,
335
+ };
336
+ }
337
+
338
+ function shouldSkipByCompetence(profile, sessionState = {}) {
339
+ const map = profile && typeof profile.user_competence_map === 'object'
340
+ ? profile.user_competence_map
341
+ : null;
342
+ if (!map) return false;
343
+ const text = `${sessionState.topic || ''} ${sessionState.currentTopic || ''} ${sessionState.lastUserMessage || ''}`.toLowerCase();
344
+ if (!text) return false;
345
+ for (const [domain, level] of Object.entries(map)) {
346
+ if (String(level || '').toLowerCase() !== 'expert') continue;
347
+ if (text.includes(String(domain || '').toLowerCase())) return true;
348
+ }
349
+ return false;
350
+ }
351
+
352
+ function buildMentorPrompt(sessionState = {}, profile = {}, config = {}, nowMs = Date.now()) {
353
+ if (!config || config.enabled === false) return '';
354
+ const now = safeNow(nowMs);
355
+
356
+ const quietUntil = profile && profile.growth ? profile.growth.quiet_until : null;
357
+ const quietMs = quietUntil ? new Date(quietUntil).getTime() : 0;
358
+ if (quietMs && quietMs > now) return '';
359
+ if (shouldSkipByCompetence(profile, sessionState)) return '';
360
+
361
+ const mode = resolveMode(config);
362
+ const zone = sessionState.zone || computeZone(sessionState.skeleton || {}).zone;
363
+ const lines = [];
364
+ lines.push('[Mentor mode protocol - keep concise and practical:');
365
+ lines.push(`- mode=${mode}, zone=${zone}`);
366
+
367
+ if (mode === 'gentle') {
368
+ lines.push('- Before solution, optionally ask one short guiding question.');
369
+ } else if (mode === 'active') {
370
+ lines.push('- Ask user for their design idea first, then provide improvements.');
371
+ lines.push('- End with one-line "关键收获".');
372
+ } else {
373
+ lines.push('- Prefer scaffold/pseudocode first; avoid dumping full solution immediately.');
374
+ lines.push('- Use Socratic prompts to force active reasoning.');
375
+ lines.push('- Apply knowledge firewall: do not fill user logic gaps with unstated assumptions.');
376
+ }
377
+
378
+ if (zone === 'comfort') lines.push('- Increase challenge slightly (new method or stronger abstraction).');
379
+ if (zone === 'panic') lines.push('- Reduce friction: provide step-by-step scaffold and reassurance.');
380
+
381
+ const pattern = detectPatterns(sessionState.recentMessages || [], sessionState.sessionStartTime || Date.now(), { nowMs: now });
382
+ if (pattern.suggestion) lines.push(`- Pattern nudge: ${pattern.suggestion}`);
383
+
384
+ lines.push(']');
385
+ return lines.join('\n');
386
+ }
387
+
388
+ module.exports = {
389
+ checkEmotionBreaker,
390
+ buildMentorPrompt,
391
+ computeZone,
392
+ registerDebt,
393
+ collectDebt,
394
+ gcExpiredDebts,
395
+ detectPatterns,
396
+ getRuntimeStatus,
397
+ _private: {
398
+ runtimeFilePath,
399
+ loadRuntime,
400
+ saveRuntime,
401
+ tokenize,
402
+ overlapRatio,
403
+ repetitionFromTexts,
404
+ },
405
+ };
@@ -121,6 +121,7 @@ const _icons = IS_WIN ? {
121
121
  phone: '[TEL]',
122
122
  feishu: '[FS]',
123
123
  check: '[v]',
124
+ tool: '[T]',
124
125
  } : {
125
126
  ok: '\u2705', // ✅
126
127
  fail: '\u274C', // ❌
@@ -149,6 +150,7 @@ const _icons = IS_WIN ? {
149
150
  phone: '\uD83D\uDCF1', // 📱
150
151
  feishu: '\uD83D\uDCD8', // 📘
151
152
  check: '\u2714', // ✔
153
+ tool: '\uD83D\uDEE0\uFE0F', // 🛠️
152
154
  };
153
155
 
154
156
  /**