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.
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * memory-write.js — Active memory injection CLI
4
+ *
5
+ * Usage:
6
+ * node memory-write.js "entity" "relation" "value"
7
+ * node memory-write.js "entity" "relation" "value" --confidence high --project metame --tags "tag1,tag2"
8
+ * node memory-write.js --help
9
+ *
10
+ * Reuses memory.js saveFacts() and acquire/release pattern. Zero new dependencies.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const path = require('path');
16
+ const os = require('os');
17
+
18
+ // ── Legal relation types ──────────────────────────────────────────────────────
19
+ const VALID_RELATIONS = new Set([
20
+ 'tech_decision',
21
+ 'bug_lesson',
22
+ 'arch_convention',
23
+ 'config_fact',
24
+ 'config_change',
25
+ 'user_pref',
26
+ 'workflow_rule',
27
+ 'project_milestone',
28
+ ]);
29
+
30
+ // ── Entity whitelist (no dot required for these) ──────────────────────────────
31
+ const ENTITY_WHITELIST = new Set(['王总', 'system', 'user', 'MetaMe']);
32
+
33
+ // ── Help text ─────────────────────────────────────────────────────────────────
34
+ const HELP = `
35
+ memory-write.js — 主动写入事实到 memory.db
36
+
37
+ 用法:
38
+ node memory-write.js <entity> <relation> <value> [options]
39
+
40
+ 参数:
41
+ entity 知识主体,须含点号(如 MetaMe.daemon)或在白名单(王总/system/user/MetaMe)
42
+ relation 关系类型,合法值:
43
+ ${[...VALID_RELATIONS].join(', ')}
44
+ value 事实内容,20-300 字符
45
+
46
+ 选项:
47
+ --confidence <level> high | medium | low (默认 medium)
48
+ --project <key> 项目标识(默认从 cwd 推断,推断不到则 '*')
49
+ --tags <tag1,tag2> 最多 3 个标签,逗号分隔
50
+ --help 显示此帮助
51
+
52
+ 示例:
53
+ node memory-write.js "MetaMe.daemon" "bug_lesson" "修复X前必须先Y,否则Z会挂"
54
+ node memory-write.js "MetaMe.bridge" "tech_decision" "飞书回调走 3000 端口,nginx 转发" --confidence high --project metame
55
+ `.trim();
56
+
57
+ // ── Project inference ─────────────────────────────────────────────────────────
58
+ function inferProject(cwd) {
59
+ if (!cwd) return '*';
60
+ const known = { 'MetaMe': 'metame', 'metame-desktop': 'desktop' };
61
+ for (const [dir, key] of Object.entries(known)) {
62
+ if (cwd.includes(dir)) return key;
63
+ }
64
+ return '*';
65
+ }
66
+
67
+ // ── Argv parser (zero deps) ───────────────────────────────────────────────────
68
+ function parseArgs(argv) {
69
+ const positional = [];
70
+ const opts = {};
71
+ let i = 0;
72
+ while (i < argv.length) {
73
+ const a = argv[i];
74
+ if (a === '--help' || a === '-h') { opts.help = true; i++; continue; }
75
+ if (a.startsWith('--')) {
76
+ const key = a.slice(2);
77
+ const val = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : null;
78
+ if (val !== null) { opts[key] = val; i += 2; } else { opts[key] = true; i++; }
79
+ } else {
80
+ positional.push(a);
81
+ i++;
82
+ }
83
+ }
84
+ return { positional, opts };
85
+ }
86
+
87
+ // ── Validation ────────────────────────────────────────────────────────────────
88
+ function validateEntity(entity) {
89
+ if (!entity) return 'entity 不能为空';
90
+ if (ENTITY_WHITELIST.has(entity)) return null;
91
+ if (!entity.includes('.')) return `entity 格式不合法:须含点号(如 MetaMe.daemon)或为白名单值(${[...ENTITY_WHITELIST].join('/')})`;
92
+ return null;
93
+ }
94
+
95
+ function validateRelation(relation) {
96
+ if (!relation) return 'relation 不能为空';
97
+ if (!VALID_RELATIONS.has(relation)) {
98
+ return `relation 不合法:"${relation}"\n合法值: ${[...VALID_RELATIONS].join(', ')}`;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ function validateValue(value) {
104
+ if (!value) return 'value 不能为空';
105
+ if (value.length < 20) return `value 太短(${value.length} 字符),最少 20 字符`;
106
+ if (value.length > 300) return `value 太长(${value.length} 字符),最多 300 字符`;
107
+ return null;
108
+ }
109
+
110
+ function validateConfidence(conf) {
111
+ const valid = ['high', 'medium', 'low'];
112
+ if (!valid.includes(conf)) return `confidence 不合法:"${conf}",合法值: high | medium | low`;
113
+ return null;
114
+ }
115
+
116
+ // ── Resolve memory.js (supports both dev and deployed paths) ──────────────────
117
+ function resolveMemory() {
118
+ const candidates = [
119
+ path.join(os.homedir(), '.metame', 'memory.js'),
120
+ path.join(__dirname, 'memory.js'),
121
+ ];
122
+ for (const p of candidates) {
123
+ try { require.resolve(p); return p; } catch { }
124
+ }
125
+ return null;
126
+ }
127
+
128
+ // ── Main ──────────────────────────────────────────────────────────────────────
129
+ function main() {
130
+ const { positional, opts } = parseArgs(process.argv.slice(2));
131
+
132
+ if (opts.help) {
133
+ console.log(HELP);
134
+ process.exit(0);
135
+ }
136
+
137
+ const [entity, relation, value] = positional;
138
+ const confidence = opts.confidence || 'medium';
139
+ const project = opts.project || process.env.METAME_PROJECT || inferProject(process.cwd());
140
+ const tagsRaw = opts.tags || '';
141
+ const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean).slice(0, 3) : [];
142
+
143
+ // Validate
144
+ const errors = [
145
+ validateEntity(entity),
146
+ validateRelation(relation),
147
+ validateValue(value),
148
+ opts.confidence ? validateConfidence(confidence) : null,
149
+ ].filter(Boolean);
150
+
151
+ if (errors.length) {
152
+ for (const e of errors) console.error('错误: ' + e);
153
+ console.error('\n运行 node memory-write.js --help 查看用法');
154
+ process.exit(1);
155
+ }
156
+
157
+ // Resolve memory module
158
+ const memoryPath = resolveMemory();
159
+ if (!memoryPath) {
160
+ console.error('错误: 找不到 memory.js,请确认 MetaMe 已部署');
161
+ process.exit(1);
162
+ }
163
+
164
+ const memory = require(memoryPath);
165
+ const sessionId = 'manual-' + Date.now();
166
+
167
+ if (typeof memory.acquire === 'function') memory.acquire();
168
+ try {
169
+ const result = memory.saveFacts(sessionId, project, [
170
+ { entity, relation, value, confidence, tags, source_type: 'manual' },
171
+ ]);
172
+
173
+ if (result.saved > 0) {
174
+ const preview = value.slice(0, 30) + (value.length > 30 ? '...' : '');
175
+ console.log(`✓ 已保存 [${relation}] ${entity}: "${preview}"`);
176
+ if (result.superseded > 0) {
177
+ console.log(` (已将 ${result.superseded} 条旧记录标记为 superseded)`);
178
+ }
179
+ } else {
180
+ // skipped: duplicate or validation filtered by saveFacts
181
+ console.log(`⚠ 未保存(可能是重复内容)。已跳过: ${result.skipped}`);
182
+ process.exit(1);
183
+ }
184
+ } catch (err) {
185
+ console.error('错误: 写入失败 —', err.message);
186
+ process.exit(1);
187
+ } finally {
188
+ try { if (typeof memory.release === 'function') memory.release(); } catch { }
189
+ }
190
+ }
191
+
192
+ main();
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