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/README.md +30 -24
- package/index.js +39 -1
- package/package.json +1 -1
- package/scripts/daemon-admin-commands.js +86 -4
- package/scripts/daemon-agent-commands.js +73 -63
- package/scripts/daemon-agent-tools.js +49 -12
- package/scripts/daemon-bridges.js +26 -6
- package/scripts/daemon-claude-engine.js +111 -39
- package/scripts/daemon-command-router.js +38 -35
- package/scripts/daemon-default.yaml +18 -0
- package/scripts/daemon-exec-commands.js +6 -12
- package/scripts/daemon-file-browser.js +6 -5
- package/scripts/daemon-runtime-lifecycle.js +19 -5
- package/scripts/daemon-session-commands.js +8 -3
- package/scripts/daemon-task-scheduler.js +30 -29
- package/scripts/daemon.js +38 -6
- package/scripts/distill.js +11 -12
- package/scripts/memory-gc.js +239 -0
- package/scripts/memory-index.js +103 -0
- package/scripts/memory-nightly-reflect.js +299 -0
- package/scripts/memory-write.js +192 -0
- package/scripts/memory.js +144 -6
- package/scripts/schema.js +30 -9
- package/scripts/self-reflect.js +121 -5
- package/scripts/session-analytics.js +9 -10
- package/scripts/task-board.js +9 -3
- package/scripts/telegram-adapter.js +77 -9
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 (?, ?, ?, ?, ?,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// ===
|
|
53
|
-
'
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
package/scripts/self-reflect.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* self-reflect.js —
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
579
|
+
function formatGoalContext(_profilePath) {
|
|
580
|
+
// Work state now lives in NOW.md (task whiteboard), not in the profile.
|
|
580
581
|
try {
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
const
|
|
584
|
-
if (
|
|
585
|
-
|
|
586
|
-
|
|
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
|
|
package/scripts/task-board.js
CHANGED
|
@@ -73,9 +73,15 @@ function createTaskBoard(opts = {}) {
|
|
|
73
73
|
updated_at TEXT NOT NULL
|
|
74
74
|
)
|
|
75
75
|
`);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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: '
|
|
133
|
+
parse_mode: 'MarkdownV2',
|
|
132
134
|
});
|
|
133
135
|
} catch {
|
|
134
|
-
// Fallback
|
|
136
|
+
// Fallback: send stripped plain text
|
|
135
137
|
await apiRequest(token, 'sendMessage', {
|
|
136
138
|
chat_id: chatId,
|
|
137
|
-
text:
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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 };
|