metame-cli 1.4.19 → 1.4.21

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/scripts/memory.js CHANGED
@@ -25,8 +25,17 @@ const fs = require('fs');
25
25
 
26
26
  const DB_PATH = path.join(os.homedir(), '.metame', 'memory.db');
27
27
 
28
+ /** Minimal structured logger. level: 'INFO' | 'WARN' | 'ERROR' */
29
+ function log(level, msg) {
30
+ const ts = new Date().toISOString();
31
+ process.stderr.write(`${ts} [${level}] ${msg}\n`);
32
+ }
33
+
28
34
  // Lazy-init: only open DB when first called
29
35
  let _db = null;
36
+ // Counts external callers that have called acquire() but not yet release().
37
+ // Internal helpers (getDb, _trackSearch, etc.) do NOT affect this counter.
38
+ let _refCount = 0;
30
39
 
31
40
  function getDb() {
32
41
  if (_db) return _db;
@@ -164,6 +173,9 @@ function getDb() {
164
173
  // One-time migration: copy recall_count → search_count for existing rows
165
174
  try { _db.exec('UPDATE facts SET search_count = recall_count WHERE recall_count > 0 AND search_count = 0'); } catch {}
166
175
 
176
+ // conflict_status: 'OK' (default) | 'CONFLICT' — set by _detectConflict for non-stateful relations
177
+ try { _db.exec("ALTER TABLE facts ADD COLUMN conflict_status TEXT NOT NULL DEFAULT 'OK'"); } catch {}
178
+
167
179
  return _db;
168
180
  }
169
181
 
@@ -260,13 +272,14 @@ function saveFacts(sessionId, project, facts, { scope = null } = {}) {
260
272
 
261
273
  const insert = db.prepare(`
262
274
  INSERT INTO facts (id, entity, relation, value, confidence, source_type, source_id, project, scope, tags, created_at, updated_at)
263
- VALUES (?, ?, ?, ?, ?, 'session', ?, ?, ?, ?, datetime('now'), datetime('now'))
275
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
264
276
  ON CONFLICT(id) DO NOTHING
265
277
  `);
266
278
 
267
279
  let saved = 0;
268
280
  let skipped = 0;
269
281
  let superseded = 0;
282
+ let conflicts = 0;
270
283
  const savedFacts = [];
271
284
  const batchDedup = new Set();
272
285
 
@@ -287,8 +300,9 @@ function saveFacts(sessionId, project, facts, { scope = null } = {}) {
287
300
  const id = `f-${sessionId.slice(0, 8)}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
288
301
  const tags = JSON.stringify(Array.isArray(f.tags) ? f.tags.slice(0, 3) : []);
289
302
  try {
303
+ const sourceType = f.source_type || 'session';
290
304
  insert.run(id, f.entity, f.relation, f.value.slice(0, 300),
291
- f.confidence || 'medium', sessionId, normalizedProject, normalizedScope, tags);
305
+ f.confidence || 'medium', sourceType, sessionId, normalizedProject, normalizedScope, tags);
292
306
  batchDedup.add(dedupKey);
293
307
  savedFacts.push({ id, entity: f.entity, relation: f.relation, value: f.value,
294
308
  project: normalizedProject, scope: normalizedScope, tags: f.tags || [], created_at: new Date().toISOString() });
@@ -328,6 +342,20 @@ function saveFacts(sessionId, project, facts, { scope = null } = {}) {
328
342
  if (changes > 0) {
329
343
  _logSupersede(toSupersede, id, f.entity, f.relation, f.value, sessionId);
330
344
  }
345
+ } else {
346
+ // Conflict detection for non-stateful relations
347
+ let whereSql = '';
348
+ let filterParams = [];
349
+ if (normalizedScope === '*') {
350
+ whereSql = `((scope = '*') OR (scope IS NULL AND project = '*'))`;
351
+ } else if (normalizedScope) {
352
+ whereSql = `((scope = ?) OR (scope IS NULL AND project = ?))`;
353
+ filterParams = [normalizedScope, normalizedProject];
354
+ } else {
355
+ whereSql = `(project IN (?, '*'))`;
356
+ filterParams = [normalizedProject];
357
+ }
358
+ conflicts += _detectConflict(db, f, id, whereSql, filterParams, sessionId);
331
359
  }
332
360
  } catch { skipped++; }
333
361
  }
@@ -339,7 +367,9 @@ function saveFacts(sessionId, project, facts, { scope = null } = {}) {
339
367
  if (qmdClient) qmdClient.upsertFacts(savedFacts);
340
368
  }
341
369
 
342
- return { saved, skipped, superseded };
370
+ if (conflicts > 0) log('WARN', `[MEMORY] ${conflicts} conflict(s) detected`);
371
+
372
+ return { saved, skipped, superseded, conflicts };
343
373
  }
344
374
 
345
375
  /**
@@ -361,6 +391,7 @@ function _trackSearch(ids) {
361
391
  }
362
392
 
363
393
  const SUPERSEDE_LOG = path.join(os.homedir(), '.metame', 'memory_supersede_log.jsonl');
394
+ const CONFLICT_LOG = path.join(os.homedir(), '.metame', 'memory_conflict_log.jsonl');
364
395
 
365
396
  /**
366
397
  * Append supersede operations to audit log (append-only, never mutated).
@@ -383,6 +414,77 @@ function _logSupersede(oldFacts, newId, entity, relation, newValue, sessionId) {
383
414
  } catch { /* non-fatal */ }
384
415
  }
385
416
 
417
+ /**
418
+ * Detect value conflicts for non-stateful facts.
419
+ *
420
+ * When a new fact (entity, relation) already has an active record whose value
421
+ * differs significantly from the incoming value, both are flagged CONFLICT.
422
+ * "Significant difference" = trimmed values are not equal AND neither contains
423
+ * the other as a substring (handles minor rewording and prefix matches).
424
+ *
425
+ * @param {object} db - DatabaseSync instance
426
+ * @param {object} fact - The newly-inserted fact { entity, relation, value }
427
+ * @param {string} newId - Row ID of the newly-inserted fact
428
+ * @param {string} whereSql - Scope WHERE clause (reused from saveFacts)
429
+ * @param {Array} filterParams - Bind params for whereSql
430
+ * @param {string} sessionId - Source session ID (for audit log)
431
+ * @returns {number} Number of conflicts detected (0 or more)
432
+ */
433
+ function _detectConflict(db, fact, newId, whereSql, filterParams, sessionId) {
434
+ try {
435
+ const existing = db.prepare(
436
+ `SELECT id, value FROM facts
437
+ WHERE entity = ? AND relation = ? AND id != ? AND superseded_by IS NULL
438
+ AND (conflict_status IS NULL OR conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))
439
+ AND ${whereSql}`
440
+ ).all(fact.entity, fact.relation, newId, ...filterParams);
441
+
442
+ if (existing.length === 0) return 0;
443
+
444
+ const newVal = fact.value.trim();
445
+ let conflictCount = 0;
446
+
447
+ const conflicting = [];
448
+ for (const row of existing) {
449
+ const oldVal = row.value.trim();
450
+ // Skip if values are equivalent or one contains the other
451
+ if (oldVal === newVal) continue;
452
+ if (oldVal.includes(newVal) || newVal.includes(oldVal)) continue;
453
+
454
+ // Mark existing record as CONFLICT
455
+ db.prepare(
456
+ `UPDATE facts SET conflict_status = 'CONFLICT', updated_at = datetime('now') WHERE id = ?`
457
+ ).run(row.id);
458
+
459
+ conflicting.push({ id: row.id, value: row.value.slice(0, 80) });
460
+ conflictCount++;
461
+ }
462
+
463
+ if (conflictCount > 0) {
464
+ // Mark the new fact as CONFLICT too
465
+ db.prepare(
466
+ `UPDATE facts SET conflict_status = 'CONFLICT', updated_at = datetime('now') WHERE id = ?`
467
+ ).run(newId);
468
+
469
+ // Audit log (append-only, never mutated)
470
+ try {
471
+ const entry = {
472
+ ts: new Date().toISOString(),
473
+ entity: fact.entity,
474
+ relation: fact.relation,
475
+ new_id: newId,
476
+ new_value: fact.value.slice(0, 80),
477
+ session_id: sessionId,
478
+ conflicting,
479
+ };
480
+ fs.appendFileSync(CONFLICT_LOG, JSON.stringify(entry) + '\n', 'utf8');
481
+ } catch { /* non-fatal */ }
482
+ }
483
+
484
+ return conflictCount;
485
+ } catch { return 0; }
486
+ }
487
+
386
488
  /**
387
489
  * Scope filter semantics (new + legacy):
388
490
  * - New rows: prefer `scope` exact match or global scope '*'
@@ -426,7 +528,8 @@ async function searchFactsAsync(query, { limit = 5, project = null, scope = null
426
528
  const placeholders = ids.map(() => '?').join(',');
427
529
  let rows = db.prepare(
428
530
  `SELECT id, entity, relation, value, confidence, project, scope, tags, created_at
429
- FROM facts WHERE id IN (${placeholders}) AND superseded_by IS NULL`
531
+ FROM facts WHERE id IN (${placeholders}) AND superseded_by IS NULL
532
+ AND (conflict_status IS NULL OR conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))`
430
533
  ).all(...ids);
431
534
 
432
535
  // Apply project/scope filter
@@ -476,6 +579,7 @@ function searchFacts(query, { limit = 5, project = null, scope = null } = {}) {
476
579
  WHERE facts_fts MATCH ?
477
580
  AND ((f.scope = ? OR f.scope = '*') OR (f.scope IS NULL AND (f.project = ? OR f.project = '*')))
478
581
  AND f.superseded_by IS NULL
582
+ AND (f.conflict_status IS NULL OR f.conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))
479
583
  ORDER BY rank LIMIT ?
480
584
  `;
481
585
  params = [sanitized, scope, project, limit];
@@ -484,6 +588,7 @@ function searchFacts(query, { limit = 5, project = null, scope = null } = {}) {
484
588
  SELECT f.id, f.entity, f.relation, f.value, f.confidence, f.project, f.scope, f.tags, f.created_at, rank
485
589
  FROM facts_fts fts JOIN facts f ON f.rowid = fts.rowid
486
590
  WHERE facts_fts MATCH ? AND (f.scope = ? OR f.scope = '*') AND f.superseded_by IS NULL
591
+ AND (f.conflict_status IS NULL OR f.conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))
487
592
  ORDER BY rank LIMIT ?
488
593
  `;
489
594
  params = [sanitized, scope, limit];
@@ -492,6 +597,7 @@ function searchFacts(query, { limit = 5, project = null, scope = null } = {}) {
492
597
  SELECT f.id, f.entity, f.relation, f.value, f.confidence, f.project, f.scope, f.tags, f.created_at, rank
493
598
  FROM facts_fts fts JOIN facts f ON f.rowid = fts.rowid
494
599
  WHERE facts_fts MATCH ? AND (f.project = ? OR f.project = '*') AND f.superseded_by IS NULL
600
+ AND (f.conflict_status IS NULL OR f.conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))
495
601
  ORDER BY rank LIMIT ?
496
602
  `;
497
603
  params = [sanitized, project, limit];
@@ -500,6 +606,7 @@ function searchFacts(query, { limit = 5, project = null, scope = null } = {}) {
500
606
  SELECT f.id, f.entity, f.relation, f.value, f.confidence, f.project, f.scope, f.tags, f.created_at, rank
501
607
  FROM facts_fts fts JOIN facts f ON f.rowid = fts.rowid
502
608
  WHERE facts_fts MATCH ? AND f.superseded_by IS NULL
609
+ AND (f.conflict_status IS NULL OR f.conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))
503
610
  ORDER BY rank LIMIT ?
504
611
  `;
505
612
  params = [sanitized, limit];
@@ -518,20 +625,24 @@ function searchFacts(query, { limit = 5, project = null, scope = null } = {}) {
518
625
  FROM facts WHERE (entity LIKE ? OR value LIKE ? OR tags LIKE ?)
519
626
  AND ((scope = ? OR scope = '*') OR (scope IS NULL AND (project = ? OR project = '*')))
520
627
  AND superseded_by IS NULL
628
+ AND (conflict_status IS NULL OR conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))
521
629
  ORDER BY created_at DESC LIMIT ?`
522
630
  : scope
523
631
  ? `SELECT id, entity, relation, value, confidence, project, scope, tags, created_at
524
632
  FROM facts WHERE (entity LIKE ? OR value LIKE ? OR tags LIKE ?)
525
633
  AND (scope = ? OR scope = '*') AND superseded_by IS NULL
634
+ AND (conflict_status IS NULL OR conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))
526
635
  ORDER BY created_at DESC LIMIT ?`
527
636
  : project
528
637
  ? `SELECT id, entity, relation, value, confidence, project, scope, tags, created_at
529
638
  FROM facts WHERE (entity LIKE ? OR value LIKE ? OR tags LIKE ?)
530
639
  AND (project = ? OR project = '*') AND superseded_by IS NULL
640
+ AND (conflict_status IS NULL OR conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))
531
641
  ORDER BY created_at DESC LIMIT ?`
532
642
  : `SELECT id, entity, relation, value, confidence, project, scope, tags, created_at
533
643
  FROM facts WHERE (entity LIKE ? OR value LIKE ? OR tags LIKE ?)
534
644
  AND superseded_by IS NULL
645
+ AND (conflict_status IS NULL OR conflict_status NOT IN ('ARCHIVED', 'CONFLICT'))
535
646
  ORDER BY created_at DESC LIMIT ?`;
536
647
  const likeResults = scope && project
537
648
  ? db.prepare(likeSql).all(like, like, like, scope, project, limit)
@@ -697,8 +808,35 @@ function stats() {
697
808
  /**
698
809
  * Close the database connection (for clean shutdown).
699
810
  */
700
- function close() {
811
+ /**
812
+ * Acquire a reference. Call once per logical "session" (e.g. per task run).
813
+ * Ensures DB is open and increments the ref count.
814
+ * Must be paired with a matching release() call.
815
+ */
816
+ function acquire() {
817
+ _refCount++;
818
+ getDb(); // ensure DB is initialised
819
+ }
820
+
821
+ /**
822
+ * Release a reference. When the last caller releases, the DB is closed.
823
+ * Safe to call even if acquire() was never called (no-op when _refCount <= 0).
824
+ */
825
+ function release() {
826
+ if (_refCount > 0) _refCount--;
827
+ if (_refCount === 0 && _db) { _db.close(); _db = null; }
828
+ }
829
+
830
+ /**
831
+ * Backwards-compatible alias. Equivalent to release().
832
+ * External callers that previously called close() continue to work correctly.
833
+ */
834
+ function close() { release(); }
835
+
836
+ /** Force-close regardless of ref count. Only call on process exit. */
837
+ function forceClose() {
838
+ _refCount = 0;
701
839
  if (_db) { _db.close(); _db = null; }
702
840
  }
703
841
 
704
- module.exports = { saveSession, saveFacts, searchFacts, searchFactsAsync, searchSessions, recentSessions, getSession, stats, close, DB_PATH };
842
+ module.exports = { saveSession, saveFacts, searchFacts, searchFactsAsync, searchSessions, recentSessions, getSession, stats, acquire, release, close, forceClose, DB_PATH };
package/scripts/schema.js CHANGED
@@ -11,8 +11,11 @@
11
11
  * T1 — Identity (LOCKED, never auto-modify)
12
12
  * T2 — Core Values (LOCKED, deep personality)
13
13
  * T3 — Preferences (auto-writable, needs confidence)
14
- * T4 — Context (free overwrite, current state)
15
14
  * T5 — Evolution (system-managed, strict limits)
15
+ *
16
+ * NOTE: T4 (Context/Status) was intentionally removed. Work state (focus,
17
+ * active_projects, blockers) belongs in ~/.metame/memory/NOW.md (task
18
+ * whiteboard), not in the cognitive profile. This prevents role pollution.
16
19
  */
17
20
 
18
21
  const SCHEMA = {
@@ -49,14 +52,15 @@ const SCHEMA = {
49
52
  'cognition.metacognition.receptive_to_challenge': { tier: 'T3', type: 'enum', values: ['yes', 'sometimes', 'no'] },
50
53
  'cognition.metacognition.error_response': { tier: 'T3', type: 'enum', values: ['quick_pivot', 'root_cause_first', 'seek_help', 'retry_same'] },
51
54
 
52
- // === T4: Context ===
53
- 'context.focus': { tier: 'T4', type: 'string', maxChars: 80 },
54
- 'context.focus_since': { tier: 'T4', type: 'string' },
55
- 'context.active_projects': { tier: 'T4', type: 'array', maxItems: 5 },
56
- 'context.blockers': { tier: 'T4', type: 'array', maxItems: 3 },
57
- 'context.energy': { tier: 'T4', type: 'enum', values: ['high', 'medium', 'low', null] },
58
- 'status.focus': { tier: 'T4', type: 'string', maxChars: 80 },
59
- 'status.language': { tier: 'T4', type: 'string' },
55
+ // === T3c: User Competence Map (ZPD scaffolding) ===
56
+ 'user_competence_map': {
57
+ tier: 'T3',
58
+ type: 'map', // dynamic key-value, keys are domain names
59
+ valueType: 'enum',
60
+ values: ['beginner', 'intermediate', 'expert'],
61
+ maxKeys: 20,
62
+ description: 'Per-domain skill level for ZPD-based explanation depth'
63
+ },
60
64
 
61
65
  // === T5: Evolution (system-managed) ===
62
66
  'evolution.last_distill': { tier: 'T5', type: 'string' },
@@ -144,6 +148,23 @@ function validate(key, value) {
144
148
  }
145
149
  }
146
150
 
151
+ if (def.type === 'map') {
152
+ if (typeof value !== 'object' || Array.isArray(value)) {
153
+ return { valid: false, reason: `${key} must be an object (map)` };
154
+ }
155
+ if (def.maxKeys && Object.keys(value).length > def.maxKeys) {
156
+ return { valid: false, reason: `${key} exceeds maxKeys (${def.maxKeys})` };
157
+ }
158
+ if (def.values) {
159
+ for (const [k, v] of Object.entries(value)) {
160
+ if (!def.values.includes(v)) {
161
+ return { valid: false, reason: `${key}.${k} must be one of: ${def.values.join(', ')}` };
162
+ }
163
+ }
164
+ }
165
+ return { valid: true };
166
+ }
167
+
147
168
  return { valid: true };
148
169
  }
149
170
 
@@ -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 };