metame-cli 1.4.18 → 1.4.20

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.
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * self-reflect.js — Weekly Self-Reflection Task
4
+ * self-reflect.js — Daily Self-Reflection Task
5
5
  *
6
6
  * Scans correction/metacognitive signals from the past 7 days,
7
7
  * aggregates "where did the AI get it wrong", and writes a brief
8
8
  * self-critique pattern into growth.patterns in ~/.claude_profile.yaml.
9
9
  *
10
- * Heartbeat: weekly, require_idle, non-blocking.
10
+ * Also distills correction signals into lessons/ SOP markdown files.
11
+ *
12
+ * Heartbeat: nightly at 23:00, require_idle, non-blocking.
11
13
  */
12
14
 
13
15
  'use strict';
@@ -16,13 +18,111 @@ const fs = require('fs');
16
18
  const path = require('path');
17
19
  const os = require('os');
18
20
  const { callHaiku, buildDistillEnv } = require('./providers');
21
+ const { writeBrainFileSafe } = require('./utils');
19
22
 
20
23
  const HOME = os.homedir();
21
24
  const SIGNAL_FILE = path.join(HOME, '.metame', 'raw_signals.jsonl');
22
25
  const BRAIN_FILE = path.join(HOME, '.claude_profile.yaml');
23
26
  const LOCK_FILE = path.join(HOME, '.metame', 'self-reflect.lock');
27
+ const LESSONS_DIR = path.join(HOME, '.metame', 'memory', 'lessons');
24
28
  const WINDOW_DAYS = 7;
25
29
 
30
+ /**
31
+ * Distill correction signals into reusable SOP markdown files.
32
+ * Each run produces at most one lesson file per unique slug.
33
+ * Returns the number of lesson files actually written.
34
+ *
35
+ * @param {Array} signals - all recent signals (will filter to 'correction' type internally)
36
+ * @param {string} lessonsDir - absolute path where lesson .md files are written
37
+ */
38
+ async function generateLessons(signals, lessonsDir) {
39
+ // Only process correction signals that carry explicit feedback
40
+ const corrections = signals.filter(s => s.type === 'correction' && s.feedback);
41
+ if (corrections.length < 2) {
42
+ console.log(`[self-reflect] Only ${corrections.length} correction signal(s) with feedback, skipping lessons.`);
43
+ return 0;
44
+ }
45
+
46
+ fs.mkdirSync(lessonsDir, { recursive: true });
47
+
48
+ const correctionText = corrections
49
+ .slice(-15) // cap to avoid prompt bloat
50
+ .map(c => `- Prompt: ${(c.prompt || '').slice(0, 100)}\n Feedback: ${(c.feedback || '').slice(0, 150)}`)
51
+ .join('\n');
52
+
53
+ const prompt = `You are distilling correction signals into a reusable SOP for an AI assistant.
54
+
55
+ Corrections (JSON):
56
+ ${correctionText}
57
+
58
+ Generate ONE actionable lesson in this JSON format:
59
+ {
60
+ "title": "简短标题(中文,10字以内)",
61
+ "slug": "kebab-case-english-slug",
62
+ "content": "## 问题\\n...\\n## 根因\\n...\\n## 操作手册\\n1. ...\\n2. ...\\n3. ..."
63
+ }
64
+
65
+ Rules: content must be in 中文, concrete and actionable, 100-300 chars total.
66
+ Only output the JSON object, no explanation.`;
67
+
68
+ let distillEnv = {};
69
+ try { distillEnv = buildDistillEnv(); } catch {}
70
+
71
+ let result;
72
+ try {
73
+ result = await Promise.race([
74
+ callHaiku(prompt, distillEnv, 60000),
75
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 90000)),
76
+ ]);
77
+ } catch (e) {
78
+ console.log(`[self-reflect] generateLessons Haiku call failed: ${e.message}`);
79
+ return 0;
80
+ }
81
+
82
+ let lesson;
83
+ try {
84
+ const cleaned = result.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
85
+ lesson = JSON.parse(cleaned);
86
+ if (!lesson.title || !lesson.slug || !lesson.content) throw new Error('missing fields');
87
+ } catch (e) {
88
+ console.log(`[self-reflect] Failed to parse lesson JSON: ${e.message}`);
89
+ return 0;
90
+ }
91
+
92
+ // Sanitize slug: only lowercase alphanumeric and hyphens
93
+ const slug = (lesson.slug || '').toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
94
+ if (!slug) {
95
+ console.log('[self-reflect] generateLessons: empty slug, skipping');
96
+ return 0;
97
+ }
98
+
99
+ // Prevent duplicates: skip if any existing file already uses this slug
100
+ const existing = fs.readdirSync(lessonsDir).filter(f => f.endsWith(`-${slug}.md`));
101
+ if (existing.length > 0) {
102
+ console.log(`[self-reflect] Lesson '${slug}' already exists (${existing[0]}), skipping.`);
103
+ return 0;
104
+ }
105
+
106
+ const today = new Date().toISOString().slice(0, 10);
107
+ const filename = `${today}-${slug}.md`;
108
+ const filepath = path.join(lessonsDir, filename);
109
+
110
+ const fileContent = `---
111
+ date: ${today}
112
+ source: self-reflect
113
+ corrections: ${corrections.length}
114
+ ---
115
+
116
+ # ${lesson.title}
117
+
118
+ ${lesson.content}
119
+ `;
120
+
121
+ fs.writeFileSync(filepath, fileContent, 'utf8');
122
+ console.log(`[self-reflect] Lesson written: ${filepath}`);
123
+ return 1;
124
+ }
125
+
26
126
  async function run() {
27
127
  // Atomic lock
28
128
  let lockFd;
@@ -35,7 +135,12 @@ async function run() {
35
135
  const age = Date.now() - fs.statSync(LOCK_FILE).mtimeMs;
36
136
  if (age < 300000) { console.log('[self-reflect] Already running.'); return; }
37
137
  fs.unlinkSync(LOCK_FILE);
38
- lockFd = fs.openSync(LOCK_FILE, 'wx');
138
+ try {
139
+ lockFd = fs.openSync(LOCK_FILE, 'wx');
140
+ } catch {
141
+ // Another process acquired the lock
142
+ return;
143
+ }
39
144
  fs.writeSync(lockFd, process.pid.toString());
40
145
  fs.closeSync(lockFd);
41
146
  } else throw e;
@@ -105,7 +210,8 @@ ${signalText}
105
210
  try {
106
211
  result = await Promise.race([
107
212
  callHaiku(prompt, distillEnv, 60000),
108
- new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 65000)),
213
+ // outer safety net in case callHaiku's internal timeout doesn't propagate
214
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 90000)),
109
215
  ]);
110
216
  } catch (e) {
111
217
  console.log(`[self-reflect] Haiku call failed: ${e.message}`);
@@ -125,6 +231,16 @@ ${signalText}
125
231
  return;
126
232
  }
127
233
 
234
+ // === Generate lessons/ from correction signals (independent of patterns result) ===
235
+ try {
236
+ const lessonsCount = await generateLessons(recentSignals, LESSONS_DIR);
237
+ if (lessonsCount > 0) {
238
+ console.log(`[self-reflect] Generated ${lessonsCount} lesson(s) in ${LESSONS_DIR}`);
239
+ }
240
+ } catch (e) {
241
+ console.log(`[self-reflect] generateLessons failed (non-fatal): ${e.message}`);
242
+ }
243
+
128
244
  if (patterns.length === 0) {
129
245
  console.log('[self-reflect] No patterns found this week.');
130
246
  return;
@@ -146,7 +262,7 @@ ${signalText}
146
262
 
147
263
  // Preserve locked lines (simple approach: only update growth section)
148
264
  const dumped = yaml.dump(profile, { lineWidth: -1 });
149
- fs.writeFileSync(BRAIN_FILE, dumped, 'utf8');
265
+ await writeBrainFileSafe(dumped);
150
266
  console.log(`[self-reflect] ${patterns.length} pattern(s) written to growth.patterns: ${patterns.join(' | ')}`);
151
267
  } catch (e) {
152
268
  console.log(`[self-reflect] Failed to write profile: ${e.message}`);
@@ -576,17 +576,16 @@ function findSessionById(sessionId) {
576
576
  * Read declared goals from the user's profile.
577
577
  * Returns a compact string like "DECLARED_GOALS: focus1 | focus2" (~11 tokens).
578
578
  */
579
- function formatGoalContext(profilePath) {
579
+ function formatGoalContext(_profilePath) {
580
+ // Work state now lives in NOW.md (task whiteboard), not in the profile.
580
581
  try {
581
- const yaml = require('js-yaml');
582
- const profile = yaml.load(fs.readFileSync(profilePath, 'utf8')) || {};
583
- const goals = [];
584
- if (profile.status && profile.status.focus) goals.push(profile.status.focus);
585
- if (profile.context && profile.context.focus && profile.context.focus !== (profile.status && profile.status.focus)) {
586
- goals.push(profile.context.focus);
587
- }
588
- if (goals.length === 0) return '';
589
- return `DECLARED_GOALS: ${goals.join(' | ')}`;
582
+ const nowPath = path.join(HOME, '.metame', 'memory', 'NOW.md');
583
+ if (!fs.existsSync(nowPath)) return '';
584
+ const content = fs.readFileSync(nowPath, 'utf8').trim();
585
+ if (!content) return '';
586
+ // Truncate to avoid bloating prompts
587
+ const truncated = content.length > 300 ? content.slice(0, 300) + '…' : content;
588
+ return `CURRENT_TASK:\n${truncated}`;
590
589
  } catch { return ''; }
591
590
  }
592
591
 
@@ -73,9 +73,15 @@ function createTaskBoard(opts = {}) {
73
73
  updated_at TEXT NOT NULL
74
74
  )
75
75
  `);
76
- try { db.exec("ALTER TABLE tasks ADD COLUMN scope_id TEXT NOT NULL DEFAULT ''"); } catch {}
77
- try { db.exec("ALTER TABLE tasks ADD COLUMN task_kind TEXT NOT NULL DEFAULT 'team'"); } catch {}
78
- try { db.exec("ALTER TABLE tasks ADD COLUMN participants TEXT NOT NULL DEFAULT '[]'"); } catch {}
76
+ for (const col of [
77
+ "ALTER TABLE tasks ADD COLUMN scope_id TEXT NOT NULL DEFAULT ''",
78
+ "ALTER TABLE tasks ADD COLUMN task_kind TEXT NOT NULL DEFAULT 'team'",
79
+ "ALTER TABLE tasks ADD COLUMN participants TEXT NOT NULL DEFAULT '[]'",
80
+ ]) {
81
+ try { db.exec(col); } catch (e) {
82
+ if (!e.message.includes('duplicate column name')) throw e;
83
+ }
84
+ }
79
85
 
80
86
  db.exec(`
81
87
  CREATE TABLE IF NOT EXISTS handoffs (
@@ -122,19 +122,21 @@ function createBot(token) {
122
122
  * @param {string} markdown - Markdown text
123
123
  */
124
124
  async sendMarkdown(chatId, markdown) {
125
- const chunks = splitMessage(markdown, 4096);
125
+ // Convert first, then split — avoids mid-token cuts and length underestimation
126
+ const converted = toTelegramMarkdownV2(markdown);
127
+ const chunks = splitMessage(converted, 4096);
126
128
  for (const chunk of chunks) {
127
129
  try {
128
130
  await apiRequest(token, 'sendMessage', {
129
131
  chat_id: chatId,
130
132
  text: chunk,
131
- parse_mode: 'Markdown',
133
+ parse_mode: 'MarkdownV2',
132
134
  });
133
135
  } catch {
134
- // Fallback to plain text if markdown parsing fails
136
+ // Fallback: send stripped plain text
135
137
  await apiRequest(token, 'sendMessage', {
136
138
  chat_id: chatId,
137
- text: chunk,
139
+ text: stripMarkdown(markdown).slice(0, 4096),
138
140
  });
139
141
  }
140
142
  }
@@ -179,11 +181,21 @@ function createBot(token) {
179
181
  * @param {string} text
180
182
  */
181
183
  async editMessage(chatId, messageId, text) {
182
- await apiRequest(token, 'editMessageText', {
183
- chat_id: chatId,
184
- message_id: messageId,
185
- text: text.slice(0, 4096),
186
- });
184
+ const converted = toTelegramMarkdownV2(text).slice(0, 4096);
185
+ try {
186
+ await apiRequest(token, 'editMessageText', {
187
+ chat_id: chatId,
188
+ message_id: messageId,
189
+ text: converted,
190
+ parse_mode: 'MarkdownV2',
191
+ });
192
+ } catch {
193
+ await apiRequest(token, 'editMessageText', {
194
+ chat_id: chatId,
195
+ message_id: messageId,
196
+ text: stripMarkdown(text).slice(0, 4096),
197
+ });
198
+ }
187
199
  },
188
200
 
189
201
  /**
@@ -336,4 +348,60 @@ function splitMessage(text, maxLen) {
336
348
  return chunks;
337
349
  }
338
350
 
351
+ /**
352
+ * Convert standard Markdown to Telegram MarkdownV2 format.
353
+ *
354
+ * Mapping: **bold** → *bold*, *italic* → _italic_, # Heading → *Heading*
355
+ * All MarkdownV2 special chars in plain text are escaped with backslash.
356
+ */
357
+ function toTelegramMarkdownV2(md) {
358
+ const escapePlain = s => s.replace(/([_*[\]()~`>#+\-=|{}.!\\])/g, '\\$1');
359
+
360
+ // Order matters: code blocks → inline code → bold (before italic) → links → headings → blockquotes
361
+ // Note: bold uses [\s\S]+? to allow * inside; _italic_ only matches boundary underscores
362
+ // (_[^_\n]+_ would match snake_case — use word-boundary aware pattern instead)
363
+ const pattern = /```(?:\w*\n?)?([\s\S]*?)```|`([^`\n]+)`|\*\*([\s\S]+?)\*\*|\*([^*\n]+)\*|(?<!\w)_([^_\n]+)_(?!\w)|\[([^\]]+)\]\(([^)\s]+)\)|^(#{1,6}) (.+)$|^(>.+(?:\n>.*)*)$/mg;
364
+
365
+ let out = '';
366
+ let last = 0;
367
+ let m;
368
+
369
+ while ((m = pattern.exec(md)) !== null) {
370
+ if (m.index > last) out += escapePlain(md.slice(last, m.index));
371
+
372
+ if (m[1] !== undefined) out += '```' + m[1].replace(/[`\\]/g, '\\$&') + '```';
373
+ else if (m[2] !== undefined) out += '`' + m[2].replace(/[`\\]/g, '\\$&') + '`';
374
+ else if (m[3] !== undefined) out += '*' + toTelegramMarkdownV2(m[3]) + '*';
375
+ else if (m[4] !== undefined) out += '_' + toTelegramMarkdownV2(m[4]) + '_';
376
+ else if (m[5] !== undefined) out += '_' + toTelegramMarkdownV2(m[5]) + '_';
377
+ else if (m[6] !== undefined) out += '[' + toTelegramMarkdownV2(m[6]) + '](' + m[7].replace(/[()\\]/g, '\\$&') + ')';
378
+ else if (m[9] !== undefined) out += '*' + escapePlain(m[9]) + '*';
379
+ else if (m[10] !== undefined) {
380
+ // > blockquote — prefix each line with TG quote syntax
381
+ const lines = m[10].split('\n').map(l => '>' + escapePlain(l.replace(/^>\s?/, '')));
382
+ out += lines.join('\n');
383
+ }
384
+
385
+ last = m.index + m[0].length;
386
+ }
387
+
388
+ if (last < md.length) out += escapePlain(md.slice(last));
389
+ return out;
390
+ }
391
+
392
+ /**
393
+ * Strip all Markdown formatting, returning plain text.
394
+ * Used as fallback when MarkdownV2 rendering fails.
395
+ */
396
+ function stripMarkdown(md) {
397
+ return md
398
+ .replace(/```[\s\S]*?```/g, m => m.replace(/^```\w*\n?/, '').replace(/```$/, ''))
399
+ .replace(/`([^`]+)`/g, '$1')
400
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
401
+ .replace(/\*([^*\n]+)\*/g, '$1')
402
+ .replace(/_([^_\n]+)_/g, '$1')
403
+ .replace(/^#{1,6} (.+)$/mg, '$1')
404
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
405
+ }
406
+
339
407
  module.exports = { createBot };