metame-cli 1.5.21 → 1.5.22

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.
@@ -15,7 +15,12 @@ const os = require('os');
15
15
 
16
16
  const MISSIONS_FILE = 'workspace/missions.md';
17
17
  const SECTIONS = ['pending', 'active', 'completed', 'abandoned'];
18
- const METAME_DIR = path.join(os.homedir(), '.metame');
18
+ const RECENT_LOG_LINES = 500;
19
+ const ERROR_THRESHOLD = 3;
20
+
21
+ function getMetameDir() {
22
+ return process.env.METAME_DIR || path.join(os.homedir(), '.metame');
23
+ }
19
24
 
20
25
  // ── Mission file parser (same format as topic-pool) ──────────
21
26
 
@@ -86,6 +91,90 @@ function findMission(sections, id) {
86
91
  return null;
87
92
  }
88
93
 
94
+ function normalizeLogLine(line) {
95
+ return String(line || '')
96
+ .replace(/\d{4}-\d{2}-\d{2}T[\d:.]+Z?/g, '<TS>')
97
+ .replace(/\d{10,}/g, '<ID>')
98
+ .replace(/\/tmp\/[^\s]+/g, '<TMP>')
99
+ .trim();
100
+ }
101
+
102
+ function collectRecurringErrors() {
103
+ const counts = {};
104
+ const logPath = path.join(getMetameDir(), 'daemon.log');
105
+ if (!fs.existsSync(logPath)) return counts;
106
+
107
+ try {
108
+ const content = fs.readFileSync(logPath, 'utf8');
109
+ const recent = content.split('\n').slice(-RECENT_LOG_LINES);
110
+ for (const line of recent) {
111
+ if (!/\bERR\b|\bWARN\b|\bError\b|\bfailed\b/i.test(line)) continue;
112
+ const normalized = normalizeLogLine(line);
113
+ if (normalized.length < 20) continue;
114
+ const key = normalized.slice(0, 120);
115
+ counts[key] = (counts[key] || 0) + 1;
116
+ }
117
+ } catch { /* ignore unreadable log */ }
118
+
119
+ return counts;
120
+ }
121
+
122
+ function parseRecurringErrorTitle(title) {
123
+ const m = String(title || '').match(/^Fix recurring error(?:\s*\(\d+x\))?:\s*(.+)$/i);
124
+ return m ? m[1].trim() : '';
125
+ }
126
+
127
+ function shouldKeepMission(title, cwd, recurringErrors, now) {
128
+ const recurringPattern = parseRecurringErrorTitle(title);
129
+ if (recurringPattern) {
130
+ return (recurringErrors[recurringPattern] || 0) >= ERROR_THRESHOLD;
131
+ }
132
+
133
+ const testMatch = String(title || '').match(/^Fix failing tests in (.+)$/i);
134
+ if (testMatch) {
135
+ const testName = testMatch[1].trim();
136
+ const testPath = path.join(cwd, 'scripts', testName);
137
+ if (!fs.existsSync(testPath)) return false;
138
+ try {
139
+ execSyncSafe(`node --test scripts/${testName}`, cwd, 30000);
140
+ return false;
141
+ } catch {
142
+ return true;
143
+ }
144
+ }
145
+
146
+ const staleMatch = String(title || '').match(/^Investigate stale project:\s*(.+)$/i);
147
+ if (staleMatch) {
148
+ const projectKey = staleMatch[1].trim();
149
+ const fp = path.join(getMetameDir(), 'events', `${projectKey}.jsonl`);
150
+ if (!fs.existsSync(fp)) return false;
151
+ try {
152
+ const staleHours = (now - fs.statSync(fp).mtimeMs) / (60 * 60 * 1000);
153
+ return staleHours > 48;
154
+ } catch {
155
+ return true;
156
+ }
157
+ }
158
+
159
+ return true;
160
+ }
161
+
162
+ function pruneObsoleteMissions(cwd) {
163
+ const sections = readSections(cwd);
164
+ const recurringErrors = collectRecurringErrors();
165
+ const now = Date.now();
166
+ const pruned = [];
167
+
168
+ sections.pending = sections.pending.filter((mission) => {
169
+ const keep = shouldKeepMission(mission.title, cwd, recurringErrors, now);
170
+ if (!keep) pruned.push(mission);
171
+ return keep;
172
+ });
173
+
174
+ if (pruned.length > 0) writeSections(cwd, sections);
175
+ return { success: true, pruned: pruned.length, pruned_ids: pruned.map(m => m.id) };
176
+ }
177
+
89
178
  // ── Standard queue commands ──────────────────────────────────
90
179
 
91
180
  function nextMission(cwd) {
@@ -134,6 +223,7 @@ function listMissions(cwd) {
134
223
  // ── Log scanner: generates missions from error patterns ──────
135
224
 
136
225
  function scanLogs(cwd) {
226
+ const pruneResult = pruneObsoleteMissions(cwd);
137
227
  const sections = readSections(cwd);
138
228
  const existingTitles = new Set(
139
229
  [...sections.pending, ...sections.active, ...sections.completed, ...sections.abandoned].map(t => t.title)
@@ -141,44 +231,18 @@ function scanLogs(cwd) {
141
231
 
142
232
  const newMissions = [];
143
233
  const now = Date.now();
144
- const ONE_DAY = 24 * 60 * 60 * 1000;
234
+ const recurringErrors = collectRecurringErrors();
145
235
 
146
236
  // 1. Scan daemon log for repeated errors
147
- const logPath = path.join(METAME_DIR, 'daemon.log');
148
- if (fs.existsSync(logPath)) {
149
- try {
150
- const content = fs.readFileSync(logPath, 'utf8');
151
- const lines = content.split('\n');
152
- // Only look at recent lines (last 500)
153
- const recent = lines.slice(-500);
154
- const errorCounts = {};
155
- for (const line of recent) {
156
- if (!/\bERR\b|\bWARN\b|\bError\b|\bfailed\b/i.test(line)) continue;
157
- // Normalize: strip timestamps and variable data
158
- const normalized = line
159
- .replace(/\d{4}-\d{2}-\d{2}T[\d:.]+Z?/g, '<TS>')
160
- .replace(/\d{10,}/g, '<ID>')
161
- .replace(/\/tmp\/[^\s]+/g, '<TMP>')
162
- .trim();
163
- if (normalized.length < 20) continue;
164
- const key = normalized.slice(0, 120);
165
- errorCounts[key] = (errorCounts[key] || 0) + 1;
166
- }
167
-
168
- // Generate missions for errors occurring 3+ times
169
- // Dedup key is the pattern alone (not the count, which changes every scan)
170
- for (const [pattern, count] of Object.entries(errorCounts)) {
171
- if (count < 3) continue;
172
- const dedupKey = `Fix recurring error: ${pattern.slice(0, 80)}`;
173
- if (existingTitles.has(dedupKey)) continue;
174
- // Use stable dedup key as title (count excluded to prevent duplicates)
175
- newMissions.push({ title: dedupKey, priority: Math.max(1, 10 - count) });
176
- }
177
- } catch { /* skip unreadable log */ }
237
+ for (const [pattern, count] of Object.entries(recurringErrors)) {
238
+ if (count < ERROR_THRESHOLD) continue;
239
+ const dedupKey = `Fix recurring error: ${pattern.slice(0, 80)}`;
240
+ if (existingTitles.has(dedupKey)) continue;
241
+ newMissions.push({ title: dedupKey, priority: Math.max(1, 10 - count) });
178
242
  }
179
243
 
180
244
  // 2. Scan event logs for stuck/stale projects
181
- const eventsDir = path.join(METAME_DIR, 'events');
245
+ const eventsDir = path.join(getMetameDir(), 'events');
182
246
  if (fs.existsSync(eventsDir)) {
183
247
  try {
184
248
  const evFiles = fs.readdirSync(eventsDir).filter(f => f.endsWith('.jsonl'));
@@ -248,11 +312,12 @@ if (require.main === module) {
248
312
  case 'activate': result = args[0] ? activateMission(cwd, args[0]) : { success: false, message: 'usage: activate <id>' }; break;
249
313
  case 'complete': result = args[0] ? completeMission(cwd, args[0]) : { success: false, message: 'usage: complete <id>' }; break;
250
314
  case 'list': result = listMissions(cwd); break;
315
+ case 'prune': result = pruneObsoleteMissions(cwd); break;
251
316
  case 'scan': result = scanLogs(cwd); break;
252
- default: result = { success: false, message: `unknown: ${command}. Available: next, activate, complete, list, scan` };
317
+ default: result = { success: false, message: `unknown: ${command}. Available: next, activate, complete, list, prune, scan` };
253
318
  }
254
319
 
255
320
  process.stdout.write(JSON.stringify(result) + '\n');
256
321
  }
257
322
 
258
- module.exports = { nextMission, activateMission, completeMission, listMissions, scanLogs };
323
+ module.exports = { nextMission, activateMission, completeMission, listMissions, pruneObsoleteMissions, scanLogs };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { execFileSync } = require('child_process');
7
+
8
+ const yaml = require('./resolve-yaml');
9
+ const { pruneObsoleteMissions, scanLogs } = require('./ops-mission-queue');
10
+ const { bootstrapReactiveProject } = require('./daemon-reactive-lifecycle');
11
+
12
+ const HOME = os.homedir();
13
+ const METAME_DIR = path.join(HOME, '.metame');
14
+ const CONFIG_PATH = path.join(METAME_DIR, 'daemon.yaml');
15
+ const STATE_PATH = path.join(METAME_DIR, 'daemon_state.json');
16
+ const PROJECT_KEY = 'metame_ops';
17
+
18
+ function loadConfig() {
19
+ return yaml.load(fs.readFileSync(CONFIG_PATH, 'utf8')) || {};
20
+ }
21
+
22
+ function loadState() {
23
+ try {
24
+ return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
25
+ } catch {
26
+ return { reactive: {}, budget: { tokens_used: 0 } };
27
+ }
28
+ }
29
+
30
+ function saveState(state) {
31
+ fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
32
+ }
33
+
34
+ function checkBudget(config, state) {
35
+ const budget = config?.daemon?.daily_token_budget;
36
+ const used = Number(state?.budget?.tokens_used || 0);
37
+ if (!Number.isFinite(budget) || budget <= 0) return true;
38
+ return used < budget;
39
+ }
40
+
41
+ function dispatchReactiveItem(item) {
42
+ const dispatchBin = path.join(METAME_DIR, 'bin', 'dispatch_to');
43
+ const args = [dispatchBin];
44
+ if (item.new_session) args.push('--new');
45
+ if (item.from) args.push('--from', item.from);
46
+ args.push(item.target, item.prompt);
47
+ execFileSync(args[0], args.slice(1), {
48
+ encoding: 'utf8',
49
+ timeout: 30000,
50
+ env: process.env,
51
+ });
52
+ return { success: true };
53
+ }
54
+
55
+ function main() {
56
+ const config = loadConfig();
57
+ const project = config?.projects?.[PROJECT_KEY];
58
+ if (!project || !project.cwd) {
59
+ process.stdout.write(JSON.stringify({ success: false, skipped: true, reason: 'project_missing' }) + '\n');
60
+ return;
61
+ }
62
+
63
+ const cwd = project.cwd.replace(/^~/, HOME);
64
+ const pruned = pruneObsoleteMissions(cwd);
65
+ const scanned = scanLogs(cwd);
66
+
67
+ const result = bootstrapReactiveProject(PROJECT_KEY, config, {
68
+ metameDir: METAME_DIR,
69
+ loadState,
70
+ saveState,
71
+ checkBudget,
72
+ handleDispatchItem: dispatchReactiveItem,
73
+ log: () => {},
74
+ notifyUser: () => {},
75
+ });
76
+
77
+ process.stdout.write(JSON.stringify({
78
+ success: true,
79
+ pruned: pruned.pruned || 0,
80
+ new_missions: scanned.new_missions || 0,
81
+ total_pending: scanned.total_pending || 0,
82
+ bootstrap: result,
83
+ }) + '\n');
84
+ }
85
+
86
+ if (require.main === module) main();
@@ -1,75 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * MetaMe Intent Engine — Unified UserPromptSubmit Hook
5
- *
6
- * Config-driven intent dispatcher. Replaces the standalone team-context.js hook.
7
- * Each intent module detects a specific pattern and returns a hint string or null.
8
- * Only injects an additionalSystemPrompt when at least one intent fires.
9
- *
10
- * Enabled intents are controlled via daemon.yaml `hooks:` section:
11
- *
12
- * hooks:
13
- * team_dispatch: true # team member communication hints (default: on)
14
- * ops_assist: true # /undo /restart /logs etc. hints (default: on)
15
- * task_create: true # task scheduling hints (default: on)
16
- *
17
- * Set any key to false to disable that intent module.
18
- */
19
-
20
- 'use strict';
21
-
22
- // Global safety net: hooks must NEVER crash or exit non-zero
23
- process.on('uncaughtException', () => process.exit(0));
24
- process.on('unhandledRejection', () => process.exit(0));
25
-
26
- const fs = require('fs');
27
- const path = require('path');
28
- const os = require('os');
29
-
30
- const METAME_DIR = path.join(os.homedir(), '.metame');
31
- const { sanitizePrompt, isInternalPrompt } = require('./hook-utils');
32
- const { buildIntentHintBlock } = require('../intent-registry');
33
-
34
- function exit() { process.exit(0); }
35
-
36
- let raw = '';
37
- process.stdin.setEncoding('utf8');
38
- process.stdin.on('data', c => { raw += c; });
39
- process.stdin.on('end', () => {
40
- try { run(JSON.parse(raw)); } catch { exit(); }
41
- });
42
-
43
- function run(data) {
44
- // Internal daemon subprocesses set this env flag — never inject hints into them
45
- if (process.env.METAME_INTERNAL_PROMPT === '1') return exit();
46
-
47
- const projectKey = process.env.METAME_PROJECT || '';
48
- const rawPrompt = (data.prompt || data.user_prompt || '').trim();
49
- if (!rawPrompt) return exit();
50
-
51
- // Strip daemon-injected blocks, then bail if this is a system prompt
52
- if (isInternalPrompt(rawPrompt)) return exit();
53
- const prompt = sanitizePrompt(rawPrompt);
54
- if (!prompt) return exit();
55
-
56
- // Load daemon.yaml config (graceful: intents that don't need config still run)
57
- let config = {};
58
- try {
59
- const yaml = require('../resolve-yaml');
60
- config = yaml.load(fs.readFileSync(path.join(METAME_DIR, 'daemon.yaml'), 'utf8')) || {};
61
- } catch { /* proceed with defaults */ }
62
-
63
- let intentBlock = '';
64
- try {
65
- intentBlock = buildIntentHintBlock(prompt, config, projectKey);
66
- } catch {
67
- return exit();
68
- }
69
- if (!intentBlock) return exit();
70
-
71
- process.stdout.write(JSON.stringify({
72
- hookSpecificOutput: { additionalContext: intentBlock },
73
- }));
74
- exit();
75
- }
@@ -1,143 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * MetaMe Team Context Hook — UserPromptSubmit
4
- *
5
- * Detects communication intent towards team members in the prompt.
6
- * If found, injects targeted dispatch_to hint(s) for only those members.
7
- * Zero injection when no communication intent detected → zero wasted tokens.
8
- *
9
- * Triggers when the prompt contains:
10
- * - A communication verb (告诉/让/发给/和...讨论/...) near a member nickname
11
- * - Or a member nickname in a communication context
12
- */
13
- 'use strict';
14
-
15
- const fs = require('fs');
16
- const path = require('path');
17
- const os = require('os');
18
-
19
- const METAME_DIR = path.join(os.homedir(), '.metame');
20
-
21
- // ── Communication intent patterns ────────────────────────────────────────────
22
- // Three structural patterns:
23
- // A) verb → name: 告诉工匠 / 发给builder / 通知乙
24
- // B) name → verb: 工匠你来 / builder帮我 / 乙去做
25
- // C) prep + name: 和工匠讨论 / 跟builder说 / 与乙沟通 (name between prep and verb)
26
-
27
- const BEFORE_NAME_ZH = ['告诉', '通知', '让', '叫', '派', '交给', '转给', '发给', '联系', '找', '请', '问', '发消息给'];
28
- const BEFORE_NAME_EN = ['tell', 'ask', 'notify', 'send to', 'assign to', 'delegate to', 'message', 'ping', 'contact'];
29
- const AFTER_NAME_ZH = ['你来', '来做', '去做', '帮我', '帮忙', '负责', '处理', '跟进'];
30
- const AFTER_NAME_EN = ['help', 'do this', 'handle', 'take care', 'follow up'];
31
- // Prepositions that introduce a discussion partner (name follows immediately)
32
- const PREP_ZH = ['和', '跟', '与'];
33
- const DISC_EN = ['discuss with', 'talk to', 'chat with', 'coordinate with', 'sync with', 'work with'];
34
-
35
- function hasCommIntent(text, nickname) {
36
- const t = text.toLowerCase();
37
- const n = nickname.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
38
-
39
- // A) verb → name (within 15 chars)
40
- for (const v of BEFORE_NAME_ZH) {
41
- if (new RegExp(`${v}.{0,15}${n}`, 'u').test(t)) return true;
42
- }
43
- for (const v of BEFORE_NAME_EN) {
44
- if (new RegExp(`${v}.{0,20}${n}`, 'i').test(t)) return true;
45
- }
46
-
47
- // B) name → verb (within 8 chars)
48
- for (const v of AFTER_NAME_ZH) {
49
- if (new RegExp(`${n}.{0,8}${v}`, 'u').test(t)) return true;
50
- }
51
- for (const v of AFTER_NAME_EN) {
52
- if (new RegExp(`${n}.{0,10}${v}`, 'i').test(t)) return true;
53
- }
54
-
55
- // C) prep + name (和工匠/跟builder — prep within 3 chars before name)
56
- for (const p of PREP_ZH) {
57
- if (new RegExp(`${p}.{0,3}${n}`, 'u').test(t)) return true;
58
- }
59
- for (const v of DISC_EN) {
60
- if (new RegExp(`${v}.{0,20}${n}`, 'i').test(t)) return true;
61
- }
62
-
63
- return false;
64
- }
65
-
66
- // ── Main ──────────────────────────────────────────────────────────────────────
67
- function exit() { process.exit(0); }
68
-
69
- let raw = '';
70
- process.stdin.setEncoding('utf8');
71
- process.stdin.on('data', c => { raw += c; });
72
- process.stdin.on('end', () => {
73
- try { run(JSON.parse(raw)); } catch { exit(); }
74
- });
75
-
76
- function run(data) {
77
- const projectKey = process.env.METAME_PROJECT;
78
- if (!projectKey || process.env.METAME_INTERNAL_PROMPT === '1') return exit();
79
-
80
- const prompt = (data.prompt || data.user_prompt || '').trim();
81
- if (!prompt) return exit();
82
-
83
- // Load config
84
- let config;
85
- try {
86
- const yaml = require('../resolve-yaml');
87
- config = yaml.load(fs.readFileSync(path.join(METAME_DIR, 'daemon.yaml'), 'utf8'));
88
- } catch { return exit(); }
89
-
90
- if (!config || !config.projects) return exit();
91
-
92
- // Collect all team members across all projects (caller may be top-level or member)
93
- const allMembers = [];
94
- for (const [parentKey, parent] of Object.entries(config.projects)) {
95
- if (!Array.isArray(parent.team)) continue;
96
- for (const member of parent.team) {
97
- if (member.key === projectKey) continue; // skip self
98
- allMembers.push({ member, parentKey, parent });
99
- }
100
- // Also allow dispatching to parent project itself (escalation)
101
- if (parent.team.some(m => m.key === projectKey)) {
102
- allMembers.push({
103
- member: { key: parentKey, name: parent.name, nicknames: parent.nicknames },
104
- parentKey,
105
- parent,
106
- isParent: true,
107
- });
108
- }
109
- }
110
-
111
- if (allMembers.length === 0) return exit();
112
-
113
- const dispatchBin = path.join(METAME_DIR, 'bin', 'dispatch_to');
114
-
115
- // Find members with communication intent in this prompt
116
- const hits = [];
117
- for (const { member, isParent } of allMembers) {
118
- const nicks = [member.key, member.name, ...(Array.isArray(member.nicknames) ? member.nicknames : [])].filter(Boolean);
119
- const matched = nicks.some(n => hasCommIntent(prompt, n));
120
- if (matched) hits.push({ member, isParent });
121
- }
122
-
123
- if (hits.length === 0) return exit();
124
-
125
- // Build targeted hint for matched members only
126
- const lines = hits.map(({ member, isParent }) => {
127
- const target = member.peer ? `${member.peer}:${member.key}` : member.key;
128
- const location = member.peer ? ` [远端:${member.peer}]` : '';
129
- const label = isParent ? `${member.key}(${member.name || member.key}, 向上汇报)` : `${member.key}(${member.name || member.key}${location})`;
130
- return `- ${label}: \`${dispatchBin} --from ${projectKey} ${target} "消息"\``;
131
- });
132
-
133
- const hint = [
134
- `[团队联络提示]`,
135
- `以下成员可通过 dispatch_to 联络:`,
136
- ...lines,
137
- ].join('\n');
138
-
139
- process.stdout.write(JSON.stringify({
140
- hookSpecificOutput: { additionalSystemPrompt: hint },
141
- }));
142
- exit();
143
- }