metame-cli 1.3.22 → 1.4.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.
@@ -12,7 +12,7 @@
12
12
  const fs = require('fs');
13
13
  const path = require('path');
14
14
  const os = require('os');
15
- const { execSync } = require('child_process');
15
+ const { callHaiku, buildDistillEnv } = require('./providers');
16
16
 
17
17
  const HOME = os.homedir();
18
18
  const BUFFER_FILE = path.join(HOME, '.metame', 'raw_signals.jsonl');
@@ -31,15 +31,14 @@ try {
31
31
  // Provider env for distillation (cheap relay for background tasks)
32
32
  let distillEnv = {};
33
33
  try {
34
- const { buildDistillEnv } = require('./providers');
35
34
  distillEnv = buildDistillEnv();
36
- } catch { /* providers.js not available — use defaults */ }
35
+ } catch { /* providers not configured — use defaults */ }
37
36
 
38
37
  /**
39
38
  * Main distillation process.
40
39
  * Returns { updated: boolean, summary: string }
41
40
  */
42
- function distill() {
41
+ async function distill() {
43
42
  // 1. Check if buffer exists and has content
44
43
  if (!fs.existsSync(BUFFER_FILE)) {
45
44
  return { updated: false, behavior: null, summary: 'No signals to process.' };
@@ -55,16 +54,32 @@ function distill() {
55
54
  return { updated: false, behavior: null, summary: 'No signals to process.' };
56
55
  }
57
56
 
58
- // 2. Prevent concurrent distillation
59
- if (fs.existsSync(LOCK_FILE)) {
60
- const lockAge = Date.now() - fs.statSync(LOCK_FILE).mtimeMs;
61
- if (lockAge < 120000) { // 2 min timeout
62
- return { updated: false, behavior: null, summary: 'Distillation already in progress.' };
57
+ // 2. Prevent concurrent distillation (atomic lock via O_EXCL)
58
+ let lockFd;
59
+ try {
60
+ lockFd = fs.openSync(LOCK_FILE, 'wx');
61
+ fs.writeSync(lockFd, process.pid.toString());
62
+ fs.closeSync(lockFd);
63
+ } catch (e) {
64
+ if (e.code === 'EEXIST') {
65
+ // Another process holds the lock — check if stale
66
+ try {
67
+ const lockAge = Date.now() - fs.statSync(LOCK_FILE).mtimeMs;
68
+ if (lockAge < 120000) {
69
+ return { updated: false, behavior: null, summary: 'Distillation already in progress.' };
70
+ }
71
+ fs.unlinkSync(LOCK_FILE);
72
+ // Retry once after removing stale lock
73
+ lockFd = fs.openSync(LOCK_FILE, 'wx');
74
+ fs.writeSync(lockFd, process.pid.toString());
75
+ fs.closeSync(lockFd);
76
+ } catch {
77
+ return { updated: false, behavior: null, summary: 'Distillation already in progress.' };
78
+ }
79
+ } else {
80
+ throw e;
63
81
  }
64
- // Stale lock, remove it
65
- fs.unlinkSync(LOCK_FILE);
66
82
  }
67
- fs.writeFileSync(LOCK_FILE, process.pid.toString());
68
83
 
69
84
  try {
70
85
  // 3. Parse signals (preserve confidence from signal-capture)
@@ -134,7 +149,7 @@ function distill() {
134
149
  // Goal context section (~11 tokens when present)
135
150
  let goalContext = '';
136
151
  if (sessionAnalytics) {
137
- try { goalContext = sessionAnalytics.formatGoalContext(BRAIN_FILE); } catch {}
152
+ try { goalContext = sessionAnalytics.formatGoalContext(BRAIN_FILE); } catch { }
138
153
  }
139
154
  const goalSection = goalContext ? `\n${goalContext}\n` : '';
140
155
 
@@ -156,8 +171,7 @@ RULES:
156
171
  2. IGNORE task-specific messages. Only extract what persists across ALL sessions.
157
172
  3. Only output fields from WRITABLE FIELDS. Any other key will be rejected.
158
173
  4. For enum fields, use one of the listed values.
159
- 5. Episodic exceptions: context.anti_patterns (max 5, cross-project lessons only), context.milestones (max 3).
160
- 6. Strong directives (以后一律/always/never/from now on) → _confidence: high. Otherwise: normal.
174
+ 5. Strong directives (以后一律/always/never/from now on) _confidence: high. Otherwise: normal.
161
175
  7. Add _confidence and _source blocks mapping field keys to confidence level and triggering quote.
162
176
  8. NEVER extract agent identity or role definitions. Messages like "你是贾维斯/你的角色是.../you are Jarvis" define the AGENT, not the USER. The profile is about the USER's cognition only.
163
177
 
@@ -184,19 +198,10 @@ Do NOT repeat existing unchanged values.`;
184
198
  // 6. Call Claude in print mode with haiku (+ provider env for relay support)
185
199
  let result;
186
200
  try {
187
- result = execSync(
188
- `claude -p --model haiku --no-session-persistence`,
189
- {
190
- input: distillPrompt,
191
- encoding: 'utf8',
192
- timeout: 60000, // 60s — runs in background, no rush
193
- stdio: ['pipe', 'pipe', 'pipe'],
194
- env: { ...process.env, ...distillEnv },
195
- }
196
- ).trim();
201
+ result = await callHaiku(distillPrompt, distillEnv, 60000);
197
202
  } catch (err) {
198
203
  // Don't cleanup buffer on API failure — retry next launch
199
- try { fs.unlinkSync(LOCK_FILE); } catch {}
204
+ try { fs.unlinkSync(LOCK_FILE); } catch { }
200
205
  const isTimeout = err.killed || (err.signal === 'SIGTERM');
201
206
  if (isTimeout) {
202
207
  return { updated: false, behavior: null, summary: 'Skipped — API too slow. Will retry next launch.' };
@@ -247,16 +252,13 @@ Do NOT repeat existing unchanged values.`;
247
252
  if (Object.keys(filtered).length === 0 && behavior) {
248
253
  cleanup();
249
254
  if (skeleton && sessionAnalytics) {
250
- try { sessionAnalytics.markAnalyzed(skeleton.session_id); } catch {}
255
+ try { sessionAnalytics.markAnalyzed(skeleton.session_id); } catch { }
251
256
  }
252
257
  return { updated: false, behavior, skeleton, signalCount: signals.length, summary: `Analyzed ${signals.length} messages — behavior logged, no profile changes.` };
253
258
  }
254
259
 
255
260
  const profile = yaml.load(fs.readFileSync(BRAIN_FILE, 'utf8')) || {};
256
261
 
257
- // Auto-expire anti_patterns older than 60 days
258
- expireAntiPatterns(profile);
259
-
260
262
  // Read raw content to find locked lines and comments
261
263
  const rawProfile = fs.readFileSync(BRAIN_FILE, 'utf8');
262
264
  const lockedKeys = extractLockedKeys(rawProfile);
@@ -314,7 +316,7 @@ Do NOT repeat existing unchanged values.`;
314
316
 
315
317
  // Mark session as analyzed after successful distill
316
318
  if (skeleton && sessionAnalytics) {
317
- try { sessionAnalytics.markAnalyzed(skeleton.session_id); } catch {}
319
+ try { sessionAnalytics.markAnalyzed(skeleton.session_id); } catch { }
318
320
  }
319
321
 
320
322
  cleanup();
@@ -446,18 +448,8 @@ function strategicMerge(profile, updates, lockedKeys, pendingTraits, confidenceM
446
448
  }
447
449
 
448
450
  case 'T4':
449
- // Stamp added date on anti_pattern entries for auto-expiry
450
- if (key === 'context.anti_patterns' && Array.isArray(value)) {
451
- const today = new Date().toISOString().slice(0, 10);
452
- const existing = getNested(result, key) || [];
453
- const existingTexts = new Set(existing.map(e => typeof e === 'string' ? e : e.text));
454
- const stamped = value
455
- .filter(v => !existingTexts.has(typeof v === 'string' ? v : v.text))
456
- .map(v => typeof v === 'string' ? { text: v, added: today } : v);
457
- setNested(result, key, [...existing, ...stamped].slice(-5));
458
- } else {
459
- setNested(result, key, value);
460
- }
451
+ setNested(result, key, value);
452
+
461
453
  // Auto-set focus_since when focus changes
462
454
  if (key === 'context.focus') {
463
455
  setNested(result, 'context.focus_since', new Date().toISOString().slice(0, 10));
@@ -563,30 +555,14 @@ function truncateArrays(obj) {
563
555
  }
564
556
  }
565
557
 
566
- /**
567
- * Auto-expire anti_patterns older than 60 days.
568
- * Each entry is stored as { text: "...", added: "2026-01-15" } internally.
569
- * If legacy string entries exist, they are kept (no added date = never expire).
570
- */
571
- function expireAntiPatterns(profile) {
572
- if (!profile.context || !Array.isArray(profile.context.anti_patterns)) return;
573
- const now = Date.now();
574
- const SIXTY_DAYS = 60 * 24 * 60 * 60 * 1000;
575
- profile.context.anti_patterns = profile.context.anti_patterns.filter(entry => {
576
- if (typeof entry === 'string') return true; // legacy, keep
577
- if (entry.added) {
578
- return (now - new Date(entry.added).getTime()) < SIXTY_DAYS;
579
- }
580
- return true;
581
- });
582
- }
558
+
583
559
 
584
560
  /**
585
561
  * Clean up: remove buffer and lock
586
562
  */
587
563
  function cleanup() {
588
- try { fs.unlinkSync(BUFFER_FILE); } catch {}
589
- try { fs.unlinkSync(LOCK_FILE); } catch {}
564
+ try { fs.unlinkSync(BUFFER_FILE); } catch { }
565
+ try { fs.unlinkSync(LOCK_FILE); } catch { }
590
566
  }
591
567
 
592
568
  // ---------------------------------------------------------
@@ -785,7 +761,7 @@ function bootstrapSessionLog() {
785
761
  * Also force-runs after bootstrap (regardless of distill_count).
786
762
  * Writes results to profile growth.patterns (max 3).
787
763
  */
788
- function detectPatterns(forceRun) {
764
+ async function detectPatterns(forceRun) {
789
765
  const yaml = require('js-yaml');
790
766
 
791
767
  // Read session log
@@ -822,7 +798,7 @@ function detectPatterns(forceRun) {
822
798
  // Read declared goals for pattern context
823
799
  let declaredGoals = '';
824
800
  if (sessionAnalytics) {
825
- try { declaredGoals = sessionAnalytics.formatGoalContext(BRAIN_FILE); } catch {}
801
+ try { declaredGoals = sessionAnalytics.formatGoalContext(BRAIN_FILE); } catch { }
826
802
  }
827
803
  const goalLine = declaredGoals ? `\nUSER'S ${declaredGoals}\n` : '';
828
804
 
@@ -856,16 +832,7 @@ patterns:
856
832
  If no clear patterns found: respond with exactly NO_PATTERNS`;
857
833
 
858
834
  try {
859
- const result = execSync(
860
- `claude -p --model haiku --no-session-persistence`,
861
- {
862
- input: patternPrompt,
863
- encoding: 'utf8',
864
- timeout: 30000,
865
- stdio: ['pipe', 'pipe', 'pipe'],
866
- env: { ...process.env, ...distillEnv },
867
- }
868
- ).trim();
835
+ const result = await callHaiku(patternPrompt, distillEnv, 30000);
869
836
 
870
837
  if (!result || result.includes('NO_PATTERNS')) return;
871
838
 
@@ -917,39 +884,28 @@ module.exports = { distill, writeSessionLog, bootstrapSessionLog, detectPatterns
917
884
 
918
885
  // Also allow direct execution
919
886
  if (require.main === module) {
920
- // Bootstrap: if session_log is thin, batch-fill from history
921
- const bootstrapped = bootstrapSessionLog();
922
- if (bootstrapped > 0) {
923
- console.log(`📊 MetaMe: Bootstrapped ${bootstrapped} historical sessions.`);
924
- // Force pattern detection immediately after bootstrap
925
- detectPatterns(true);
926
- }
927
-
928
- const result = distill();
929
- // Write session log if behavior was detected
930
- if (result.behavior) {
931
- writeSessionLog(result.behavior, result.signalCount || 0, result.skeleton || null, result.sessionSummary || null);
932
- }
933
- // Run pattern detection (only triggers every 5th distill)
934
- if (!bootstrapped) detectPatterns();
935
-
936
- // Skill evolution: cold path — Haiku-powered batch analysis
937
- try {
938
- const skillEvo = require('./skill-evolution');
939
- const evoResult = skillEvo.distillSkills();
940
- if (evoResult && (evoResult.updates.length > 0 || evoResult.missing_skills.length > 0)) {
941
- console.log(`🧬 Skill evolution: ${evoResult.updates.length} update(s), ${evoResult.missing_skills.length} gap(s) detected.`);
887
+ (async () => {
888
+ // Bootstrap: if session_log is thin, batch-fill from history
889
+ const bootstrapped = bootstrapSessionLog();
890
+ if (bootstrapped > 0) {
891
+ console.log(`📊 MetaMe: Bootstrapped ${bootstrapped} historical sessions.`);
892
+ // Force pattern detection immediately after bootstrap
893
+ await detectPatterns(true);
942
894
  }
943
- } catch (e) {
944
- // Non-fatal: skill evolution is optional
945
- if (e.code !== 'MODULE_NOT_FOUND') {
946
- console.log(`⚠️ Skill evolution skipped: ${e.message}`);
895
+
896
+ const result = await distill();
897
+ // Write session log if behavior was detected
898
+ if (result.behavior) {
899
+ writeSessionLog(result.behavior, result.signalCount || 0, result.skeleton || null, result.sessionSummary || null);
947
900
  }
948
- }
949
901
 
950
- if (result.updated) {
951
- console.log(`🧠 ${result.summary}`);
952
- } else {
953
- console.log(`💤 ${result.summary}`);
954
- }
902
+ // Run pattern detection (only triggers every 5th distill)
903
+ if (!bootstrapped) await detectPatterns();
904
+
905
+ if (result.updated) {
906
+ console.log(`🧠 ${result.summary}`);
907
+ } else {
908
+ console.log(`💤 ${result.summary}`);
909
+ }
910
+ })();
955
911
  }
@@ -34,6 +34,29 @@ function withTimeout(promise, ms = 10000) {
34
34
  ]);
35
35
  }
36
36
 
37
+ // Max chars per lark_md element (Feishu limit ~4000)
38
+ const MAX_CHUNK = 3800;
39
+
40
+ /**
41
+ * Convert standard markdown to lark_md and split into chunks.
42
+ * Shared by sendMarkdown and sendCard.
43
+ */
44
+ function toMdChunks(text) {
45
+ const content = text
46
+ .replace(/^(#{1,3})\s+(.+)$/gm, '**$2**') // headers → bold
47
+ .replace(/^---+$/gm, '─────────────────────'); // hr → unicode line
48
+ if (content.length <= MAX_CHUNK) return [content];
49
+ const paragraphs = content.split(/\n\n/);
50
+ const chunks = [];
51
+ let buf = '';
52
+ for (const p of paragraphs) {
53
+ if (buf.length + p.length + 2 > MAX_CHUNK && buf) { chunks.push(buf); buf = p; }
54
+ else { buf = buf ? buf + '\n\n' + p : p; }
55
+ }
56
+ if (buf) chunks.push(buf);
57
+ return chunks;
58
+ }
59
+
37
60
  function createBot(config) {
38
61
  const { app_id, app_secret } = config;
39
62
  if (!app_id || !app_secret) throw new Error('app_id and app_secret are required');
@@ -44,6 +67,17 @@ function createBot(config) {
44
67
  appSecret: app_secret,
45
68
  });
46
69
 
70
+ // Private: send an interactive card JSON; returns { message_id } or null.
71
+ // All card functions funnel through here to avoid repeating the SDK call.
72
+ async function _sendInteractive(chatId, card) {
73
+ const res = await withTimeout(client.im.message.create({
74
+ params: { receive_id_type: 'chat_id' },
75
+ data: { receive_id: chatId, msg_type: 'interactive', content: JSON.stringify(card) },
76
+ }));
77
+ const msgId = res?.data?.message_id;
78
+ return msgId ? { message_id: msgId } : null;
79
+ }
80
+
47
81
  return {
48
82
  /**
49
83
  * Send a plain text message
@@ -84,119 +118,22 @@ function createBot(config) {
84
118
  * Send markdown as Feishu interactive card (lark_md renders bold, lists, code, links)
85
119
  */
86
120
  async sendMarkdown(chatId, markdown) {
87
- // Convert standard markdown lark_md compatible format
88
- let content = markdown
89
- .replace(/^(#{1,3})\s+(.+)$/gm, '**$2**') // headers → bold
90
- .replace(/^---+$/gm, '─────────────────────'); // hr → unicode line
91
-
92
- // Split into chunks if too long (element limit ~4000 chars)
93
- const MAX_CHUNK = 3800;
94
- const chunks = [];
95
- if (content.length <= MAX_CHUNK) {
96
- chunks.push(content);
97
- } else {
98
- const paragraphs = content.split(/\n\n/);
99
- let buf = '';
100
- for (const p of paragraphs) {
101
- if (buf.length + p.length + 2 > MAX_CHUNK && buf) {
102
- chunks.push(buf);
103
- buf = p;
104
- } else {
105
- buf = buf ? buf + '\n\n' + p : p;
106
- }
107
- }
108
- if (buf) chunks.push(buf);
109
- }
110
-
111
- // V2 schema: markdown element with normal text size
112
- const elements = chunks.map(c => ({
113
- tag: 'markdown',
114
- content: c,
115
- text_size: 'x-large',
116
- }));
117
-
118
- const card = {
119
- schema: '2.0',
120
- body: { elements },
121
- };
122
-
123
- const res = await withTimeout(client.im.message.create({
124
- params: { receive_id_type: 'chat_id' },
125
- data: {
126
- receive_id: chatId,
127
- msg_type: 'interactive',
128
- content: JSON.stringify(card),
129
- },
130
- }));
131
- const msgId = res?.data?.message_id;
132
- return msgId ? { message_id: msgId } : null;
121
+ const elements = toMdChunks(markdown).map(c => ({ tag: 'markdown', content: c }));
122
+ return _sendInteractive(chatId, { schema: '2.0', body: { elements } });
133
123
  },
134
124
 
135
125
  /**
136
- * Send a colored interactive card (for project-tagged notifications)
126
+ * Send a colored interactive card with optional markdown body (V2 schema)
137
127
  * @param {string} chatId
138
- * @param {string} title - card header text
139
- * @param {string} body - card body (lark markdown)
140
- * @param {string} color - header color: blue|orange|green|red|grey|purple|turquoise
128
+ * @param {object} opts
129
+ * @param {string} opts.title - card header text
130
+ * @param {string} [opts.body] - card body (standard markdown)
131
+ * @param {string} [opts.color='blue'] - header color: blue|orange|green|red|grey|purple|turquoise
141
132
  */
142
133
  async sendCard(chatId, { title, body, color = 'blue' }) {
143
- // Use card schema V2 for better text sizing
144
- if (!body) {
145
- const card = {
146
- schema: '2.0',
147
- header: { title: { tag: 'plain_text', content: title }, template: color },
148
- body: { elements: [] },
149
- };
150
- const res = await withTimeout(client.im.message.create({
151
- params: { receive_id_type: 'chat_id' },
152
- data: { receive_id: chatId, msg_type: 'interactive', content: JSON.stringify(card) },
153
- }));
154
- const msgId = res?.data?.message_id;
155
- return msgId ? { message_id: msgId } : null;
156
- }
157
-
158
- // Convert standard markdown → lark_md
159
- let content = body
160
- .replace(/^(#{1,3})\s+(.+)$/gm, '**$2**')
161
- .replace(/^---+$/gm, '─────────────────────');
162
-
163
- // Split into chunks (lark_md element limit ~4000 chars)
164
- const MAX_CHUNK = 3800;
165
- const chunks = [];
166
- if (content.length <= MAX_CHUNK) {
167
- chunks.push(content);
168
- } else {
169
- const paragraphs = content.split(/\n\n/);
170
- let buf = '';
171
- for (const p of paragraphs) {
172
- if (buf.length + p.length + 2 > MAX_CHUNK && buf) {
173
- chunks.push(buf);
174
- buf = p;
175
- } else {
176
- buf = buf ? buf + '\n\n' + p : p;
177
- }
178
- }
179
- if (buf) chunks.push(buf);
180
- }
181
-
182
- // V2: use markdown element with text_size for readable font
183
- const elements = chunks.map(c => ({
184
- tag: 'markdown',
185
- content: c,
186
- text_size: 'x-large',
187
- }));
188
-
189
- const card = {
190
- schema: '2.0',
191
- header: { title: { tag: 'plain_text', content: title }, template: color },
192
- body: { elements },
193
- };
194
- const res = await withTimeout(client.im.message.create({
195
- params: { receive_id_type: 'chat_id' },
196
- data: { receive_id: chatId, msg_type: 'interactive', content: JSON.stringify(card) },
197
- }));
198
- const msgId = res?.data?.message_id;
199
- return msgId ? { message_id: msgId } : null;
134
+ const header = { title: { tag: 'plain_text', content: title }, template: color };
135
+ const elements = body ? toMdChunks(body).map(c => ({ tag: 'markdown', content: c })) : [];
136
+ return _sendInteractive(chatId, { schema: '2.0', header, body: { elements } });
200
137
  },
201
138
 
202
139
  /**
@@ -214,39 +151,54 @@ function createBot(config) {
214
151
  async sendTyping(_chatId) {},
215
152
 
216
153
  /**
217
- * Send interactive card with action buttons
154
+ * Send interactive card with action buttons (V1 schema — required for card.action.trigger)
218
155
  * @param {string} chatId
219
- * @param {string} title - card header
156
+ * @param {string} title - card header (first line) + optional body (remaining lines)
220
157
  * @param {Array<Array<{text: string, callback_data: string}>>} buttons - rows of buttons
221
158
  */
222
159
  async sendButtons(chatId, title, buttons) {
223
- // Feishu cards: each action element holds up to 3 buttons.
224
- // For a vertical list, put each button in its own action element.
225
- const elements = buttons.map(row => ({
160
+ // Each row becomes one action element; a row can hold up to 3 buttons side-by-side.
161
+ const buttonElements = buttons.map(row => ({
226
162
  tag: 'action',
227
- actions: [{
163
+ actions: row.map(b => ({
228
164
  tag: 'button',
229
- text: { tag: 'plain_text', content: row[0].text },
165
+ text: { tag: 'plain_text', content: b.text },
230
166
  type: 'default',
231
- value: { cmd: row[0].callback_data },
232
- }],
167
+ value: { cmd: b.callback_data },
168
+ })),
233
169
  }));
234
- const card = {
170
+
171
+ // Feishu card header is single-line — split multi-line title into header + body
172
+ const lines = title.split('\n');
173
+ const headerText = lines[0].slice(0, 60);
174
+ const bodyText = lines.slice(1).join('\n').trim();
175
+
176
+ const elements = [];
177
+ if (bodyText) {
178
+ elements.push({ tag: 'div', text: { tag: 'lark_md', content: bodyText } });
179
+ elements.push({ tag: 'hr' });
180
+ }
181
+ elements.push(...buttonElements);
182
+
183
+ return _sendInteractive(chatId, {
235
184
  config: { wide_screen_mode: true },
236
- header: {
237
- title: { tag: 'plain_text', content: title },
238
- template: 'blue',
239
- },
185
+ header: { title: { tag: 'plain_text', content: headerText }, template: 'blue' },
240
186
  elements,
241
- };
242
- await withTimeout(client.im.message.create({
243
- params: { receive_id_type: 'chat_id' },
244
- data: {
245
- receive_id: chatId,
246
- msg_type: 'interactive',
247
- content: JSON.stringify(card),
248
- },
249
- }));
187
+ });
188
+ },
189
+
190
+ /**
191
+ * Send a rich interactive card with pre-built elements (V1 schema — required for card.action.trigger)
192
+ * @param {string} chatId
193
+ * @param {string} headerText - single-line card header
194
+ * @param {Array} elements - Feishu V1 card elements array
195
+ */
196
+ async sendRawCard(chatId, headerText, elements) {
197
+ return _sendInteractive(chatId, {
198
+ config: { wide_screen_mode: true },
199
+ header: { title: { tag: 'plain_text', content: headerText }, template: 'blue' },
200
+ elements,
201
+ });
250
202
  },
251
203
 
252
204
  /**