metame-cli 1.3.10 → 1.3.12

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 CHANGED
@@ -4,6 +4,17 @@
4
4
  <img src="./logo.png" alt="MetaMe Logo" width="200"/>
5
5
  </p>
6
6
 
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/metame-cli"><img src="https://img.shields.io/npm/v/metame-cli.svg" alt="npm version"></a>
9
+ <a href="https://www.npmjs.com/package/metame-cli"><img src="https://img.shields.io/npm/dm/metame-cli.svg" alt="npm downloads"></a>
10
+ <a href="https://nodejs.org"><img src="https://img.shields.io/node/v/metame-cli.svg" alt="node version"></a>
11
+ <a href="https://github.com/Yaron9/MetaMe/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/metame-cli.svg" alt="license"></a>
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="./README.md">English</a> | <a href="./README中文版.md">中文</a>
16
+ </p>
17
+
7
18
  > **The Cognitive Profile Layer for Claude Code.**
8
19
  >
9
20
  > *Knows how you think. Works wherever you are.*
@@ -37,6 +48,7 @@
37
48
  * **📱 Remote Claude Code (v1.3):** Full Claude Code from your phone via Telegram or Feishu (Lark). Stateful sessions with `--resume` — same conversation history, tool use, and file editing as your terminal. Interactive buttons for project/session picking, directory browser, and macOS launchd auto-start.
38
49
  * **🔄 Workflow Engine (v1.3):** Define multi-step skill chains as heartbeat tasks. Each workflow runs in a single Claude Code session via `--resume`, so step outputs flow as context to the next step. Example: `deep-research` → `tech-writing` → `wechat-publisher` — fully automated content pipeline.
39
50
  * **⏹ Full Terminal Control from Mobile (v1.3.10):** `/stop` (ESC), `/undo` (ESC×2) with native file-history restoration, `/model` to switch models, concurrent task protection, daemon auto-restart, and `metame continue` for seamless mobile-to-desktop sync.
51
+ * **🎯 Goal Alignment & Drift Detection (v1.3.11):** MetaMe now tracks whether your sessions align with your declared goals. Each distill assesses `goal_alignment` (aligned/partial/drifted) at zero extra API cost. When you drift for 2+ consecutive sessions, a mirror observation is injected passively; after 3+ sessions, a reflection prompt gently asks: "Was this an intentional pivot, or did you lose track?" Session logs now record project, branch, intent, and file directories for richer retrospective analysis. Pattern detection can spot sustained drift trends across your session history.
40
52
 
41
53
  ## 🛠 Prerequisites
42
54
 
package/index.js CHANGED
@@ -23,7 +23,7 @@ if (!fs.existsSync(METAME_DIR)) {
23
23
  }
24
24
 
25
25
  // Auto-deploy bundled scripts to ~/.metame/
26
- const BUNDLED_SCRIPTS = ['signal-capture.js', 'distill.js', 'schema.js', 'pending-traits.js', 'migrate-v2.js', 'daemon.js', 'telegram-adapter.js', 'feishu-adapter.js', 'daemon-default.yaml'];
26
+ const BUNDLED_SCRIPTS = ['signal-capture.js', 'distill.js', 'schema.js', 'pending-traits.js', 'migrate-v2.js', 'daemon.js', 'telegram-adapter.js', 'feishu-adapter.js', 'daemon-default.yaml', 'providers.js', 'session-analytics.js'];
27
27
  const scriptsDir = path.join(__dirname, 'scripts');
28
28
 
29
29
  for (const script of BUNDLED_SCRIPTS) {
@@ -319,6 +319,27 @@ try {
319
319
  candidate.surfaced = new Date().toISOString().slice(0, 10);
320
320
  fs.writeFileSync(BRAIN_FILE, yaml.dump(brainDoc, { lineWidth: -1 }), 'utf8');
321
321
  }
322
+
323
+ // Drift mirror fallback — when no pattern candidate, check recent session drift
324
+ if (!candidate) {
325
+ const sessionLogFile = path.join(METAME_DIR, 'session_log.yaml');
326
+ if (fs.existsSync(sessionLogFile)) {
327
+ const log = yaml.load(fs.readFileSync(sessionLogFile, 'utf8'));
328
+ if (log && Array.isArray(log.sessions)) {
329
+ const recent = log.sessions.slice(-3);
330
+ const driftCount = recent.filter(s =>
331
+ s.goal_alignment === 'drifted' || s.goal_alignment === 'partial'
332
+ ).length;
333
+ if (driftCount >= 2 && recent.length >= 2) {
334
+ const projects = [...new Set(recent.map(s => s.project).filter(Boolean))];
335
+ const declaredFocus = brainDoc.status?.focus || brainDoc.context?.focus;
336
+ if (declaredFocus && projects.length > 0) {
337
+ mirrorLine = `\n[MetaMe observation: 最近${driftCount}个session都在${projects.join(',')}上,和声明的目标"${declaredFocus}"有偏差。不要主动提起,只在用户自然提到相关话题时温和回应。]\n`;
338
+ }
339
+ }
340
+ }
341
+ }
342
+ }
322
343
  }
323
344
  }
324
345
  } catch {
@@ -350,9 +371,31 @@ try {
350
371
  const lastThree = zoneHistory.slice(-3);
351
372
  const triggerComfort = lastThree.length === 3 && lastThree.every(z => z === 'C');
352
373
 
353
- if (trigger7th || triggerComfort) {
374
+ // Trigger 3: Persistent goal drift (2+ drifted in last 3 sessions)
375
+ let triggerDrift = false;
376
+ let driftDeclaredFocus = null;
377
+ try {
378
+ const sessionLogFile = path.join(METAME_DIR, 'session_log.yaml');
379
+ if (fs.existsSync(sessionLogFile)) {
380
+ const driftLog = yaml.load(fs.readFileSync(sessionLogFile, 'utf8'));
381
+ if (driftLog && Array.isArray(driftLog.sessions)) {
382
+ const recentSessions = driftLog.sessions.slice(-3);
383
+ const driftCount = recentSessions.filter(s =>
384
+ s.goal_alignment === 'drifted' || s.goal_alignment === 'partial'
385
+ ).length;
386
+ if (driftCount >= 2 && recentSessions.length >= 2) {
387
+ driftDeclaredFocus = refDoc.status?.focus || refDoc.context?.focus;
388
+ if (driftDeclaredFocus) triggerDrift = true;
389
+ }
390
+ }
391
+ }
392
+ } catch { /* non-fatal */ }
393
+
394
+ if (triggerDrift || triggerComfort || trigger7th) {
354
395
  let hint = '';
355
- if (triggerComfort) {
396
+ if (triggerDrift) {
397
+ hint = `最近几个session的方向和"${driftDeclaredFocus}"有偏差。如果session自然结束,可以温和地问:🪞 是方向有意调整了,还是不小心偏了?`;
398
+ } else if (triggerComfort) {
356
399
  hint = '连续几次都在熟悉领域。如果用户在session结束时自然停顿,可以温和地问:🪞 准备好探索拉伸区了吗?';
357
400
  } else {
358
401
  hint = '这是第' + distillCount + '次session。如果session自然结束,可以附加一句:🪞 一个词形容这次session的感受?';
@@ -557,7 +600,173 @@ if (isMirror) {
557
600
  }
558
601
 
559
602
  // ---------------------------------------------------------
560
- // 5.6 DAEMON SUBCOMMANDS
603
+ // 5.6 PROVIDER SUBCOMMANDS
604
+ // ---------------------------------------------------------
605
+ const isProvider = process.argv.includes('provider');
606
+ if (isProvider) {
607
+ const providers = require(path.join(__dirname, 'scripts', 'providers.js'));
608
+ const providerIndex = process.argv.indexOf('provider');
609
+ const subCmd = process.argv[providerIndex + 1];
610
+
611
+ if (!subCmd || subCmd === 'list') {
612
+ const active = providers.getActiveProvider();
613
+ console.log(`🔌 MetaMe Providers (active: ${active ? active.name : 'anthropic'})`);
614
+ console.log(providers.listFormatted());
615
+ process.exit(0);
616
+ }
617
+
618
+ if (subCmd === 'use') {
619
+ const name = process.argv[providerIndex + 2];
620
+ if (!name) {
621
+ console.error("❌ Usage: metame provider use <name>");
622
+ process.exit(1);
623
+ }
624
+ try {
625
+ providers.setActive(name);
626
+ const p = providers.getActiveProvider();
627
+ console.log(`✅ Provider switched → ${name} (${p.label || name})`);
628
+ if (name !== 'anthropic') {
629
+ console.log(` Base URL: ${p.base_url || 'not set'}`);
630
+ }
631
+ } catch (e) {
632
+ console.error(`❌ ${e.message}`);
633
+ process.exit(1);
634
+ }
635
+ process.exit(0);
636
+ }
637
+
638
+ if (subCmd === 'add') {
639
+ const name = process.argv[providerIndex + 2];
640
+ if (!name) {
641
+ console.error("❌ Usage: metame provider add <name>");
642
+ process.exit(1);
643
+ }
644
+ const readline = require('readline');
645
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
646
+ const ask = (q) => new Promise(r => rl.question(q, r));
647
+
648
+ (async () => {
649
+ console.log(`\n🔌 Add Provider: ${name}\n`);
650
+ console.log("The relay must accept Anthropic Messages API format.");
651
+ console.log("(Most quality relays like OpenRouter, OneAPI, etc. support this.)\n");
652
+
653
+ const label = (await ask("Display name (e.g. OpenRouter): ")).trim() || name;
654
+ const base_url = (await ask("Base URL (e.g. https://openrouter.ai/api/v1): ")).trim();
655
+ const api_key = (await ask("API Key: ")).trim();
656
+
657
+ if (!base_url) {
658
+ console.error("❌ Base URL is required.");
659
+ rl.close();
660
+ process.exit(1);
661
+ }
662
+
663
+ const config = { label };
664
+ if (base_url) config.base_url = base_url;
665
+ if (api_key) config.api_key = api_key;
666
+
667
+ try {
668
+ providers.addProvider(name, config);
669
+ console.log(`\n✅ Provider "${name}" added.`);
670
+ console.log(` Switch to it: metame provider use ${name}`);
671
+ } catch (e) {
672
+ console.error(`❌ ${e.message}`);
673
+ }
674
+ rl.close();
675
+ process.exit(0);
676
+ })();
677
+ return; // Prevent further execution while async runs
678
+ }
679
+
680
+ if (subCmd === 'remove') {
681
+ const name = process.argv[providerIndex + 2];
682
+ if (!name) {
683
+ console.error("❌ Usage: metame provider remove <name>");
684
+ process.exit(1);
685
+ }
686
+ try {
687
+ providers.removeProvider(name);
688
+ console.log(`✅ Provider "${name}" removed.`);
689
+ } catch (e) {
690
+ console.error(`❌ ${e.message}`);
691
+ }
692
+ process.exit(0);
693
+ }
694
+
695
+ if (subCmd === 'set-role') {
696
+ const role = process.argv[providerIndex + 2]; // distill | daemon
697
+ const name = process.argv[providerIndex + 3]; // provider name or empty to clear
698
+ if (!role) {
699
+ console.error("❌ Usage: metame provider set-role <distill|daemon> [provider-name]");
700
+ console.error(" Omit provider name to reset to active provider.");
701
+ process.exit(1);
702
+ }
703
+ try {
704
+ providers.setRole(role, name || null);
705
+ console.log(`✅ ${role} provider ${name ? `set to "${name}"` : 'reset to active'}.`);
706
+ } catch (e) {
707
+ console.error(`❌ ${e.message}`);
708
+ }
709
+ process.exit(0);
710
+ }
711
+
712
+ if (subCmd === 'test') {
713
+ const targetName = process.argv[providerIndex + 2];
714
+ const prov = providers.loadProviders();
715
+ const name = targetName || prov.active;
716
+ const p = prov.providers[name];
717
+ if (!p) {
718
+ console.error(`❌ Provider "${name}" not found.`);
719
+ process.exit(1);
720
+ }
721
+
722
+ console.log(`🔍 Testing provider: ${name} (${p.label || name})`);
723
+ if (name === 'anthropic') {
724
+ console.log(" Using official Anthropic endpoint — testing via claude CLI...");
725
+ } else {
726
+ console.log(` Base URL: ${p.base_url || 'not set'}`);
727
+ }
728
+
729
+ try {
730
+ const env = { ...process.env, ...providers.buildEnv(name) };
731
+ const { execSync } = require('child_process');
732
+ const start = Date.now();
733
+ const result = execSync(
734
+ 'claude -p --model haiku --no-session-persistence',
735
+ {
736
+ input: 'Respond with exactly: PROVIDER_OK',
737
+ encoding: 'utf8',
738
+ timeout: 30000,
739
+ env,
740
+ stdio: ['pipe', 'pipe', 'pipe'],
741
+ }
742
+ ).trim();
743
+ const elapsed = Date.now() - start;
744
+
745
+ if (result.includes('PROVIDER_OK')) {
746
+ console.log(` ✅ Connected (${elapsed}ms)`);
747
+ } else {
748
+ console.log(` ⚠️ Response received (${elapsed}ms) but unexpected: ${result.slice(0, 80)}`);
749
+ }
750
+ } catch (e) {
751
+ console.error(` ❌ Failed: ${e.message.split('\n')[0]}`);
752
+ }
753
+ process.exit(0);
754
+ }
755
+
756
+ // Unknown subcommand — show help
757
+ console.log("🔌 MetaMe Provider Commands:");
758
+ console.log(" metame provider — list providers");
759
+ console.log(" metame provider use <name> — switch active provider");
760
+ console.log(" metame provider add <name> — add a new provider");
761
+ console.log(" metame provider remove <name> — remove provider");
762
+ console.log(" metame provider test [name] — test connectivity");
763
+ console.log(" metame provider set-role <distill|daemon> [name]");
764
+ console.log(" — assign provider for background tasks");
765
+ process.exit(0);
766
+ }
767
+
768
+ // ---------------------------------------------------------
769
+ // 5.7 DAEMON SUBCOMMANDS
561
770
  // ---------------------------------------------------------
562
771
  const isDaemon = process.argv.includes('daemon');
563
772
  if (isDaemon) {
@@ -951,9 +1160,10 @@ if (isSync) {
951
1160
  }
952
1161
 
953
1162
  console.log(`\n🔄 Resuming session ${bestSession.id.slice(0, 8)}...\n`);
1163
+ const providerEnv = (() => { try { return require(path.join(__dirname, 'scripts', 'providers.js')).buildActiveEnv(); } catch { return {}; } })();
954
1164
  const syncChild = spawn('claude', ['--resume', bestSession.id], {
955
1165
  stdio: 'inherit',
956
- env: { ...process.env, METAME_ACTIVE_SESSION: 'true' }
1166
+ env: { ...process.env, ...providerEnv, METAME_ACTIVE_SESSION: 'true' }
957
1167
  });
958
1168
  syncChild.on('error', () => {
959
1169
  console.error("Could not launch 'claude'. Is Claude Code installed?");
@@ -977,10 +1187,17 @@ if (process.env.METAME_ACTIVE_SESSION === 'true') {
977
1187
  // ---------------------------------------------------------
978
1188
  // 7. LAUNCH CLAUDE
979
1189
  // ---------------------------------------------------------
980
- // Spawn the official claude tool with our marker
1190
+ // Load provider env (zero-overhead for official Anthropic returns {})
1191
+ const activeProviderEnv = (() => { try { return require(path.join(__dirname, 'scripts', 'providers.js')).buildActiveEnv(); } catch { return {}; } })();
1192
+ const activeProviderName = (() => { try { return require(path.join(__dirname, 'scripts', 'providers.js')).getActiveName(); } catch { return 'anthropic'; } })();
1193
+ if (activeProviderName !== 'anthropic') {
1194
+ console.log(`🔌 Provider: ${activeProviderName}`);
1195
+ }
1196
+
1197
+ // Spawn the official claude tool with our marker + provider env
981
1198
  const child = spawn('claude', process.argv.slice(2), {
982
1199
  stdio: 'inherit',
983
- env: { ...process.env, METAME_ACTIVE_SESSION: 'true' }
1200
+ env: { ...process.env, ...activeProviderEnv, METAME_ACTIVE_SESSION: 'true' }
984
1201
  });
985
1202
 
986
1203
  child.on('error', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metame-cli",
3
- "version": "1.3.10",
3
+ "version": "1.3.12",
4
4
  "description": "The Cognitive Profile Layer for Claude Code. Knows how you think, not just what you said.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -12,7 +12,7 @@
12
12
  ],
13
13
  "scripts": {
14
14
  "start": "node index.js",
15
- "sync:plugin": "cp scripts/schema.js scripts/pending-traits.js scripts/signal-capture.js scripts/distill.js scripts/daemon.js scripts/telegram-adapter.js scripts/feishu-adapter.js scripts/daemon-default.yaml plugin/scripts/ && echo '✅ Plugin scripts synced'",
15
+ "sync:plugin": "cp scripts/schema.js scripts/pending-traits.js scripts/signal-capture.js scripts/distill.js scripts/daemon.js scripts/telegram-adapter.js scripts/feishu-adapter.js scripts/daemon-default.yaml scripts/providers.js plugin/scripts/ && echo '✅ Plugin scripts synced'",
16
16
  "restart:daemon": "node index.js daemon stop 2>/dev/null; sleep 1; node index.js daemon start 2>/dev/null || echo '⚠️ Daemon not running or restart failed'",
17
17
  "precommit": "npm run sync:plugin && npm run restart:daemon"
18
18
  },
package/scripts/daemon.js CHANGED
@@ -51,6 +51,22 @@ try {
51
51
  }
52
52
  }
53
53
 
54
+ // Provider env for daemon tasks (relay support)
55
+ let providerMod = null;
56
+ try {
57
+ providerMod = require('./providers');
58
+ } catch { /* providers.js not available — use defaults */ }
59
+
60
+ function getDaemonProviderEnv() {
61
+ if (!providerMod) return {};
62
+ try { return providerMod.buildDaemonEnv(); } catch { return {}; }
63
+ }
64
+
65
+ function getActiveProviderEnv() {
66
+ if (!providerMod) return {};
67
+ try { return providerMod.buildActiveEnv(); } catch { return {}; }
68
+ }
69
+
54
70
  // ---------------------------------------------------------
55
71
  // LOGGING
56
72
  // ---------------------------------------------------------
@@ -267,6 +283,7 @@ function executeTask(task, config) {
267
283
  encoding: 'utf8',
268
284
  timeout: 120000, // 2 min timeout
269
285
  maxBuffer: 1024 * 1024,
286
+ env: { ...process.env, ...getDaemonProviderEnv() },
270
287
  }
271
288
  ).trim();
272
289
 
@@ -351,7 +368,7 @@ function executeWorkflow(task, config) {
351
368
  log('INFO', `Workflow ${task.name} step ${i + 1}/${steps.length}: ${step.skill || 'prompt'}`);
352
369
  try {
353
370
  const output = execSync(`claude ${args.join(' ')}`, {
354
- input: prompt, encoding: 'utf8', timeout: step.timeout || 300000, maxBuffer: 5 * 1024 * 1024, cwd,
371
+ input: prompt, encoding: 'utf8', timeout: step.timeout || 300000, maxBuffer: 5 * 1024 * 1024, cwd, env: { ...process.env, ...getDaemonProviderEnv() },
355
372
  }).trim();
356
373
  const tk = Math.ceil((prompt.length + output.length) / 4);
357
374
  totalTokens += tk;
@@ -1186,8 +1203,31 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1186
1203
  return;
1187
1204
  }
1188
1205
 
1206
+ // /provider [name] — list or switch provider
1207
+ if (text === '/provider' || text.startsWith('/provider ')) {
1208
+ if (!providerMod) {
1209
+ await bot.sendMessage(chatId, '❌ Provider module not available.');
1210
+ return;
1211
+ }
1212
+ const arg = text.slice(9).trim();
1213
+ if (!arg) {
1214
+ const list = providerMod.listFormatted();
1215
+ await bot.sendMessage(chatId, `🔌 Providers:\n${list}\n\n用法: /provider <name>`);
1216
+ return;
1217
+ }
1218
+ try {
1219
+ providerMod.setActive(arg);
1220
+ const p = providerMod.getActiveProvider();
1221
+ await bot.sendMessage(chatId, `✅ Provider: ${arg} (${p.label || arg})`);
1222
+ } catch (e) {
1223
+ await bot.sendMessage(chatId, `❌ ${e.message}`);
1224
+ }
1225
+ return;
1226
+ }
1227
+
1189
1228
  if (text.startsWith('/')) {
1190
1229
  const currentModel = (config.daemon && config.daemon.model) || 'sonnet';
1230
+ const currentProvider = providerMod ? providerMod.getActiveName() : 'anthropic';
1191
1231
  await bot.sendMessage(chatId, [
1192
1232
  '📱 手机端 Claude Code',
1193
1233
  '',
@@ -1204,7 +1244,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
1204
1244
  '/stop — 中断当前任务 (ESC)',
1205
1245
  '/undo — 回退上一轮操作 (ESC×2)',
1206
1246
  '',
1207
- `⚙️ /model [${currentModel}] /status /tasks /run /budget /reload`,
1247
+ `⚙️ /model [${currentModel}] /provider [${currentProvider}] /status /tasks /run /budget /reload`,
1208
1248
  '',
1209
1249
  '直接打字即可对话 💬',
1210
1250
  ].join('\n'));
@@ -1525,7 +1565,7 @@ function spawnClaudeAsync(args, input, cwd, timeoutMs = 300000) {
1525
1565
  const child = spawn('claude', args, {
1526
1566
  cwd,
1527
1567
  stdio: ['pipe', 'pipe', 'pipe'],
1528
- env: { ...process.env },
1568
+ env: { ...process.env, ...getActiveProviderEnv() },
1529
1569
  });
1530
1570
 
1531
1571
  let stdout = '';
@@ -1630,7 +1670,7 @@ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, ch
1630
1670
  const child = spawn('claude', streamArgs, {
1631
1671
  cwd,
1632
1672
  stdio: ['pipe', 'pipe', 'pipe'],
1633
- env: { ...process.env },
1673
+ env: { ...process.env, ...getActiveProviderEnv() },
1634
1674
  });
1635
1675
 
1636
1676
  // Track active process for /stop
@@ -19,9 +19,22 @@ const BUFFER_FILE = path.join(HOME, '.metame', 'raw_signals.jsonl');
19
19
  const BRAIN_FILE = path.join(HOME, '.claude_profile.yaml');
20
20
  const LOCK_FILE = path.join(HOME, '.metame', 'distill.lock');
21
21
 
22
- const { hasKey, isLocked, getTier, getAllowedKeysForPrompt, estimateTokens, TOKEN_BUDGET } = require('./schema');
22
+ const { hasKey, isLocked, getTier, getWritableKeysForPrompt, estimateTokens, TOKEN_BUDGET } = require('./schema');
23
23
  const { loadPending, savePending, upsertPending, getPromotable, removePromoted } = require('./pending-traits');
24
24
 
25
+ // Session analytics — local skeleton extraction (zero API cost)
26
+ let sessionAnalytics = null;
27
+ try {
28
+ sessionAnalytics = require('./session-analytics');
29
+ } catch { /* session-analytics.js not available — graceful fallback */ }
30
+
31
+ // Provider env for distillation (cheap relay for background tasks)
32
+ let distillEnv = {};
33
+ try {
34
+ const { buildDistillEnv } = require('./providers');
35
+ distillEnv = buildDistillEnv();
36
+ } catch { /* providers.js not available — use defaults */ }
37
+
25
38
  /**
26
39
  * Main distillation process.
27
40
  * Returns { updated: boolean, summary: string }
@@ -74,6 +87,19 @@ function distill() {
74
87
  return { updated: false, behavior: null, summary: 'No valid signals.' };
75
88
  }
76
89
 
90
+ // 3b. Extract session skeleton (local, zero API cost)
91
+ let sessionContext = '';
92
+ let skeleton = null;
93
+ if (sessionAnalytics) {
94
+ try {
95
+ const latest = sessionAnalytics.findLatestUnanalyzedSession();
96
+ if (latest) {
97
+ skeleton = sessionAnalytics.extractSkeleton(latest.path);
98
+ sessionContext = sessionAnalytics.formatForPrompt(skeleton);
99
+ }
100
+ } catch { /* non-fatal */ }
101
+ }
102
+
77
103
  // 4. Read current profile
78
104
  let currentProfile = '';
79
105
  try {
@@ -82,95 +108,68 @@ function distill() {
82
108
  currentProfile = '(empty profile)';
83
109
  }
84
110
 
85
- // 5. Build distillation prompt
111
+ // 5. Build distillation prompt (compact + session-aware)
86
112
  const userMessages = signals
87
113
  .map((s, i) => `${i + 1}. "${s}"`)
88
114
  .join('\n');
89
115
 
90
- const allowedKeys = getAllowedKeysForPrompt();
116
+ const writableKeys = getWritableKeysForPrompt();
117
+
118
+ // Session context section (only when skeleton exists, ~60 tokens)
119
+ const sessionSection = sessionContext
120
+ ? `\nSESSION CONTEXT (what actually happened in the latest coding session):\n${sessionContext}\n`
121
+ : '';
91
122
 
92
- const distillPrompt = `You are a MetaMe cognitive profile distiller. Your job is to extract COGNITIVE TRAITS and PREFERENCES — how the user thinks, decides, and communicates. You are NOT a memory system. Do NOT store facts ("user lives in X"). Only store cognitive patterns and preferences.
123
+ // Goal context section (~11 tokens when present)
124
+ let goalContext = '';
125
+ if (sessionAnalytics) {
126
+ try { goalContext = sessionAnalytics.formatGoalContext(BRAIN_FILE); } catch {}
127
+ }
128
+ const goalSection = goalContext ? `\n${goalContext}\n` : '';
129
+
130
+ const distillPrompt = `You are a MetaMe cognitive profile distiller. Extract COGNITIVE TRAITS and PREFERENCES — how the user thinks, decides, and communicates. NOT a memory system. Do NOT store facts.
93
131
 
94
132
  CURRENT PROFILE:
95
133
  \`\`\`yaml
96
134
  ${currentProfile}
97
135
  \`\`\`
98
136
 
99
- ALLOWED FIELDS (you may ONLY output keys from this list):
100
- ${allowedKeys}
137
+ WRITABLE FIELDS (T1/T2 are LOCKED and omitted — you may ONLY output keys from this list):
138
+ ${writableKeys}
101
139
 
102
140
  RECENT USER MESSAGES:
103
141
  ${userMessages}
104
-
105
- INSTRUCTIONS:
106
- 1. Extract ONLY cognitive traits, preferences, and behavioral patterns — NOT facts or events.
107
- 2. IGNORE task-specific messages (e.g., "fix this bug", "add a button").
108
- 3. Only extract things that should persist across ALL future sessions.
109
- 4. You may ONLY output fields from ALLOWED FIELDS. Any other key will be rejected.
110
- 5. Fields marked [LOCKED] must NEVER be changed (T1 and T2 tiers).
111
- 6. For enum fields, you MUST use one of the listed values.
112
-
113
- EPISODIC MEMORY — TWO EXCEPTIONS to the "no facts" rule:
114
- 7. context.anti_patterns (max 5): If the user encountered a REPEATED technical failure or expressed strong frustration about a specific technical approach, record it as an anti-pattern. Format: "topic — what failed and why". Only cross-project generalizable lessons, NOT project-specific bugs.
115
- Example: ["async/await deadlock Promise.all rejects all on single failure, use Promise.allSettled", "CSS Grid in email templates — no support, use tables"]
116
- 8. context.milestones (max 3): If the user completed a significant milestone or made a key decision, record it. Only the 3 most recent. Format: short description string.
117
- Example: ["MetaMe v1.3 published", "Switched from REST to GraphQL"]
118
-
119
- COGNITIVE BIAS PREVENTION:
120
- - A single observation is a STATE, not a TRAIT. Do NOT infer T3 cognition fields from one message.
121
- - Never infer cognitive style from identity/demographics.
122
- - If a new signal contradicts an existing profile value, do NOT output the field — contradictions need accumulation.
123
- - Signal weight hierarchy:
124
- L1 Surface (word choice, tone) → low weight, needs 5+ observations
125
- L2 Behavior (question patterns, decision patterns) → medium weight, needs 3 observations
126
- L3 Self-declaration ("I prefer...", "以后一律...") → high weight, can write directly
127
-
128
- CONFIDENCE TAGGING:
129
- - If a message contains strong directives (以后一律/永远/always/never/记住/from now on), mark as HIGH.
130
- - Add a _confidence block mapping field keys to "high" or "normal".
131
- - Add a _source block mapping field keys to the quote that triggered the extraction.
132
-
133
- OUTPUT FORMAT — respond with ONLY a YAML code block:
134
- \`\`\`yaml
135
- preferences:
136
- code_style: concise
137
- context:
138
- focus: "API redesign"
139
- _confidence:
140
- preferences.code_style: high
141
- context.focus: normal
142
- _source:
143
- preferences.code_style: "以后代码一律简洁风格"
144
- context.focus: "我现在在做API重构"
145
- \`\`\`
146
-
147
- BEHAVIORAL PATTERN DETECTION (Phase C):
148
- In addition to cognitive traits, analyze the messages for behavioral patterns in THIS session.
149
- Output a _behavior block with these fields (use null if not enough signal):
142
+ ${sessionSection}${goalSection}
143
+ RULES:
144
+ 1. Extract ONLY cognitive traits, preferences, behavioral patterns — NOT facts or events.
145
+ 2. IGNORE task-specific messages. Only extract what persists across ALL sessions.
146
+ 3. Only output fields from WRITABLE FIELDS. Any other key will be rejected.
147
+ 4. For enum fields, use one of the listed values.
148
+ 5. Episodic exceptions: context.anti_patterns (max 5, cross-project lessons only), context.milestones (max 3).
149
+ 6. Strong directives (以后一律/always/never/from now on) _confidence: high. Otherwise: normal.
150
+ 7. Add _confidence and _source blocks mapping field keys to confidence level and triggering quote.
151
+
152
+ BIAS PREVENTION:
153
+ - Single observation = STATE, not TRAIT. T3 cognition needs 3+ observations.
154
+ - L1 Surface needs 5+, L2 Behavior needs 3, L3 Self-declaration direct write.
155
+ - Contradiction with existing value do NOT output (needs accumulation).
156
+
157
+ BEHAVIORAL ANALYSIS — _behavior block (always output, use null if insufficient signal):
150
158
  decision_pattern: premature_closure | exploratory | iterative | null
151
159
  cognitive_load: low | medium | high | null
152
160
  zone: comfort | stretch | panic | null
153
- avoidance_topics: [] # topics mentioned but not acted on
161
+ avoidance_topics: []
154
162
  emotional_response: analytical | blame_external | blame_self | withdrawal | null
155
- topics: [] # main topics discussed (max 5 keywords)
156
-
157
- Example _behavior block:
158
- \`\`\`yaml
159
- _behavior:
160
- decision_pattern: iterative
161
- cognitive_load: medium
162
- zone: stretch
163
- avoidance_topics: ["testing"]
164
- emotional_response: analytical
165
- topics: ["rust", "error-handling"]
166
- \`\`\`
167
-
168
- IMPORTANT: _behavior is ALWAYS output, even if no profile updates. If there are no profile updates but behavior was detected, output ONLY the _behavior block (do NOT output NO_UPDATE in that case).
169
-
170
- If nothing worth saving AND no behavior detected: respond with exactly NO_UPDATE
171
- Do NOT repeat existing unchanged values. Only output NEW or CHANGED fields.`;
172
-
173
- // 6. Call Claude in print mode with haiku
163
+ topics: []
164
+ session_outcome: completed | abandoned | blocked | pivoted | null
165
+ friction: [] # max 3 keywords describing pain points
166
+ goal_alignment: aligned | partial | drifted | null
167
+ drift_note: "max 30 char explanation" or null
168
+ ${sessionContext ? '\nHint: high tool_calls + routine messages → zone likely higher. If DECLARED_GOALS exist, assess goal_alignment.' : ''}
169
+ OUTPUT — respond with ONLY a YAML code block. If nothing worth saving AND no behavior: respond with exactly NO_UPDATE.
170
+ Do NOT repeat existing unchanged values.`;
171
+
172
+ // 6. Call Claude in print mode with haiku (+ provider env for relay support)
174
173
  let result;
175
174
  try {
176
175
  result = execSync(
@@ -179,7 +178,8 @@ Do NOT repeat existing unchanged values. Only output NEW or CHANGED fields.`;
179
178
  input: distillPrompt,
180
179
  encoding: 'utf8',
181
180
  timeout: 60000, // 60s — runs in background, no rush
182
- stdio: ['pipe', 'pipe', 'pipe']
181
+ stdio: ['pipe', 'pipe', 'pipe'],
182
+ env: { ...process.env, ...distillEnv },
183
183
  }
184
184
  ).trim();
185
185
  } catch (err) {
@@ -234,7 +234,10 @@ Do NOT repeat existing unchanged values. Only output NEW or CHANGED fields.`;
234
234
  // If only behavior detected but no profile updates
235
235
  if (Object.keys(filtered).length === 0 && behavior) {
236
236
  cleanup();
237
- return { updated: false, behavior, signalCount: signals.length, summary: `Analyzed ${signals.length} messages — behavior logged, no profile changes.` };
237
+ if (skeleton && sessionAnalytics) {
238
+ try { sessionAnalytics.markAnalyzed(skeleton.session_id); } catch {}
239
+ }
240
+ return { updated: false, behavior, skeleton, signalCount: signals.length, summary: `Analyzed ${signals.length} messages — behavior logged, no profile changes.` };
238
241
  }
239
242
 
240
243
  const profile = yaml.load(fs.readFileSync(BRAIN_FILE, 'utf8')) || {};
@@ -297,10 +300,16 @@ Do NOT repeat existing unchanged values. Only output NEW or CHANGED fields.`;
297
300
 
298
301
  fs.writeFileSync(BRAIN_FILE, restored, 'utf8');
299
302
 
303
+ // Mark session as analyzed after successful distill
304
+ if (skeleton && sessionAnalytics) {
305
+ try { sessionAnalytics.markAnalyzed(skeleton.session_id); } catch {}
306
+ }
307
+
300
308
  cleanup();
301
309
  return {
302
310
  updated: true,
303
311
  behavior,
312
+ skeleton,
304
313
  signalCount: signals.length,
305
314
  summary: `${Object.keys(filtered).length} new trait${Object.keys(filtered).length > 1 ? 's' : ''} absorbed. (${tokens} tokens)`
306
315
  };
@@ -577,8 +586,9 @@ const MAX_SESSION_LOG = 30;
577
586
  * Write a session entry to session_log.yaml.
578
587
  * @param {object} behavior - The _behavior block from Haiku
579
588
  * @param {number} signalCount - Number of signals processed
589
+ * @param {object} [skeleton] - Optional session skeleton from session-analytics
580
590
  */
581
- function writeSessionLog(behavior, signalCount) {
591
+ function writeSessionLog(behavior, signalCount, skeleton) {
582
592
  if (!behavior) return;
583
593
 
584
594
  const yaml = require('js-yaml');
@@ -602,6 +612,19 @@ function writeSessionLog(behavior, signalCount) {
602
612
  emotional_response: behavior.emotional_response || null,
603
613
  avoidance: behavior.avoidance_topics || [],
604
614
  signal_count: signalCount,
615
+ session_outcome: behavior.session_outcome || null,
616
+ friction: behavior.friction || [],
617
+ goal_alignment: behavior.goal_alignment || null,
618
+ drift_note: behavior.drift_note || null,
619
+ // From skeleton (if available)
620
+ ...(skeleton ? {
621
+ duration_min: skeleton.duration_min,
622
+ tool_calls: skeleton.total_tool_calls,
623
+ tools: skeleton.tool_counts,
624
+ project: skeleton.project || null,
625
+ branch: skeleton.branch || null,
626
+ intent: skeleton.intent || null,
627
+ } : {}),
605
628
  };
606
629
 
607
630
  log.sessions.push(entry);
@@ -641,20 +664,42 @@ function detectPatterns() {
641
664
 
642
665
  // Take last 20 sessions
643
666
  const recent = log.sessions.slice(-20);
644
- const sessionSummary = recent.map((s, i) =>
645
- `${i + 1}. [${s.ts}] topics=${(s.topics || []).join(',')} zone=${s.zone || '?'} decision=${s.decision_pattern || '?'} load=${s.cognitive_load || '?'} avoidance=[${(s.avoidance || []).join(',')}]`
646
- ).join('\n');
667
+ const sessionSummary = recent.map((s, i) => {
668
+ const parts = [`${i + 1}. [${s.ts}]`];
669
+ if (s.project) parts.push(`proj=${s.project}`);
670
+ parts.push(`topics=${(s.topics || []).join(',')}`);
671
+ parts.push(`zone=${s.zone || '?'}`);
672
+ if (s.goal_alignment) parts.push(`goal=${s.goal_alignment}`);
673
+ if (s.drift_note) parts.push(`drift="${s.drift_note}"`);
674
+ if (s.session_outcome) parts.push(`outcome=${s.session_outcome}`);
675
+ if (s.friction && s.friction.length) parts.push(`friction=[${s.friction.join(',')}]`);
676
+ if (s.tool_calls) parts.push(`tools=${s.tool_calls}`);
677
+ if (s.duration_min) parts.push(`${s.duration_min}min`);
678
+ parts.push(`load=${s.cognitive_load || '?'}`);
679
+ parts.push(`avoidance=[${(s.avoidance || []).join(',')}]`);
680
+ return parts.join(' ');
681
+ }).join('\n');
682
+
683
+ // Read declared goals for pattern context
684
+ let declaredGoals = '';
685
+ if (sessionAnalytics) {
686
+ try { declaredGoals = sessionAnalytics.formatGoalContext(BRAIN_FILE); } catch {}
687
+ }
688
+ const goalLine = declaredGoals ? `\nUSER'S ${declaredGoals}\n` : '';
647
689
 
648
690
  const patternPrompt = `You are a metacognition pattern detector. Analyze these ${recent.length} session summaries and find repeated behavioral patterns.
649
691
 
650
692
  SESSION HISTORY:
651
693
  ${sessionSummary}
652
-
694
+ ${goalLine}
653
695
  Find at most 2 patterns from these categories:
654
696
  1. Avoidance: topics mentioned repeatedly but never acted on
655
697
  2. Energy: what task types correlate with high/low cognitive load
656
698
  3. Zone: consecutive comfort zone? frequent panic?
657
699
  4. Growth: areas where user went from asking questions to giving commands (mastery signal)
700
+ 5. Friction: recurring pain points across sessions
701
+ 6. Efficiency: workflow patterns, underutilized tools
702
+ 7. Drift: sessions where goal_alignment is drifted/partial for 3+ consecutive sessions
658
703
 
659
704
  RULES:
660
705
  - Only report patterns with confidence > 0.7 (based on frequency/consistency)
@@ -664,7 +709,7 @@ RULES:
664
709
  OUTPUT FORMAT — respond with ONLY a YAML code block:
665
710
  \`\`\`yaml
666
711
  patterns:
667
- - type: avoidance|energy|zone|growth
712
+ - type: avoidance|energy|zone|growth|friction|efficiency|drift
668
713
  summary: "one sentence description"
669
714
  confidence: 0.7-1.0
670
715
  \`\`\`
@@ -678,7 +723,8 @@ If no clear patterns found: respond with exactly NO_PATTERNS`;
678
723
  input: patternPrompt,
679
724
  encoding: 'utf8',
680
725
  timeout: 30000,
681
- stdio: ['pipe', 'pipe', 'pipe']
726
+ stdio: ['pipe', 'pipe', 'pipe'],
727
+ env: { ...process.env, ...distillEnv },
682
728
  }
683
729
  ).trim();
684
730
 
@@ -735,7 +781,7 @@ if (require.main === module) {
735
781
  const result = distill();
736
782
  // Write session log if behavior was detected
737
783
  if (result.behavior) {
738
- writeSessionLog(result.behavior, result.signalCount || 0);
784
+ writeSessionLog(result.behavior, result.signalCount || 0, result.skeleton || null);
739
785
  }
740
786
  // Run pattern detection (only triggers every 5th distill)
741
787
  detectPatterns();
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * providers.js — MetaMe Provider Management
5
+ *
6
+ * Manages API provider configurations for Claude Code.
7
+ * Injects credentials via environment variables at spawn time — zero file mutation.
8
+ *
9
+ * Mechanism: Claude Code respects ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY
10
+ * env vars. By setting these before spawn(), we redirect Claude Code to any
11
+ * Anthropic-compatible API relay without touching ~/.claude/settings.json.
12
+ *
13
+ * Compatible relays must accept the Anthropic Messages API format.
14
+ * Model routing is handled by the relay — Claude Code sends standard model
15
+ * names (haiku, sonnet, opus) and the relay maps them as configured.
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const os = require('os');
23
+
24
+ const HOME = os.homedir();
25
+ const METAME_DIR = path.join(HOME, '.metame');
26
+ const PROVIDERS_FILE = path.join(METAME_DIR, 'providers.yaml');
27
+
28
+ // Resolve js-yaml (same pattern as daemon.js)
29
+ let yaml;
30
+ try {
31
+ yaml = require('js-yaml');
32
+ } catch {
33
+ const metameRoot = process.env.METAME_ROOT;
34
+ if (metameRoot) {
35
+ try { yaml = require(path.join(metameRoot, 'node_modules', 'js-yaml')); } catch {}
36
+ }
37
+ if (!yaml) {
38
+ const candidates = [
39
+ path.resolve(__dirname, '..', 'node_modules', 'js-yaml'),
40
+ path.resolve(__dirname, 'node_modules', 'js-yaml'),
41
+ ];
42
+ for (const p of candidates) {
43
+ try { yaml = require(p); break; } catch {}
44
+ }
45
+ }
46
+ }
47
+
48
+ // ---------------------------------------------------------
49
+ // DEFAULT CONFIG
50
+ // ---------------------------------------------------------
51
+ function defaultConfig() {
52
+ return {
53
+ active: 'anthropic',
54
+ providers: {
55
+ anthropic: { label: 'Anthropic (Official)' },
56
+ },
57
+ distill_provider: null,
58
+ daemon_provider: null,
59
+ };
60
+ }
61
+
62
+ // ---------------------------------------------------------
63
+ // LOAD / SAVE
64
+ // ---------------------------------------------------------
65
+ function loadProviders() {
66
+ try {
67
+ if (!fs.existsSync(PROVIDERS_FILE)) return defaultConfig();
68
+ const data = yaml.load(fs.readFileSync(PROVIDERS_FILE, 'utf8'));
69
+ if (!data || typeof data !== 'object') return defaultConfig();
70
+ // Ensure anthropic always exists
71
+ if (!data.providers) data.providers = {};
72
+ if (!data.providers.anthropic) data.providers.anthropic = { label: 'Anthropic (Official)' };
73
+ return {
74
+ active: data.active || 'anthropic',
75
+ providers: data.providers,
76
+ distill_provider: data.distill_provider || null,
77
+ daemon_provider: data.daemon_provider || null,
78
+ };
79
+ } catch {
80
+ return defaultConfig();
81
+ }
82
+ }
83
+
84
+ function saveProviders(config) {
85
+ if (!fs.existsSync(METAME_DIR)) fs.mkdirSync(METAME_DIR, { recursive: true });
86
+ fs.writeFileSync(PROVIDERS_FILE, yaml.dump(config, { lineWidth: -1 }), 'utf8');
87
+ }
88
+
89
+ // ---------------------------------------------------------
90
+ // PROVIDER ENV BUILDER (Core mechanism)
91
+ // ---------------------------------------------------------
92
+
93
+ /**
94
+ * Build env var overrides for a named provider.
95
+ * Returns {} for 'anthropic' (official) — use Claude Code defaults.
96
+ * Returns { ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY } for relays.
97
+ */
98
+ function buildEnv(providerName) {
99
+ const config = loadProviders();
100
+ const name = providerName || config.active;
101
+
102
+ if (name === 'anthropic') return {};
103
+
104
+ const provider = config.providers[name];
105
+ if (!provider) return {};
106
+
107
+ const env = {};
108
+ if (provider.base_url) env.ANTHROPIC_BASE_URL = provider.base_url;
109
+ if (provider.api_key) env.ANTHROPIC_API_KEY = provider.api_key;
110
+ return env;
111
+ }
112
+
113
+ /**
114
+ * Build a complete env object for spawn(), merging process.env + provider env.
115
+ */
116
+ function buildSpawnEnv(providerName) {
117
+ return { ...process.env, ...buildEnv(providerName) };
118
+ }
119
+
120
+ /**
121
+ * Build env for the active provider.
122
+ */
123
+ function buildActiveEnv() {
124
+ return buildEnv(null); // null → uses active
125
+ }
126
+
127
+ /**
128
+ * Build env for distill tasks (distill_provider → active fallback).
129
+ */
130
+ function buildDistillEnv() {
131
+ const config = loadProviders();
132
+ return buildEnv(config.distill_provider || config.active);
133
+ }
134
+
135
+ /**
136
+ * Build env for daemon tasks (daemon_provider → active fallback).
137
+ */
138
+ function buildDaemonEnv() {
139
+ const config = loadProviders();
140
+ return buildEnv(config.daemon_provider || config.active);
141
+ }
142
+
143
+ // ---------------------------------------------------------
144
+ // CRUD
145
+ // ---------------------------------------------------------
146
+ function getActiveProvider() {
147
+ const config = loadProviders();
148
+ const p = config.providers[config.active];
149
+ return p ? { name: config.active, ...p } : null;
150
+ }
151
+
152
+ function getActiveName() {
153
+ return loadProviders().active;
154
+ }
155
+
156
+ function setActive(name) {
157
+ const config = loadProviders();
158
+ if (!config.providers[name]) {
159
+ throw new Error(`Provider "${name}" not found. Available: ${Object.keys(config.providers).join(', ')}`);
160
+ }
161
+ config.active = name;
162
+ saveProviders(config);
163
+ }
164
+
165
+ function addProvider(name, providerConfig) {
166
+ if (name === 'anthropic') throw new Error('Cannot overwrite the default Anthropic provider.');
167
+ const config = loadProviders();
168
+ config.providers[name] = providerConfig;
169
+ saveProviders(config);
170
+ }
171
+
172
+ function removeProvider(name) {
173
+ if (name === 'anthropic') throw new Error('Cannot remove the default Anthropic provider.');
174
+ const config = loadProviders();
175
+ if (!config.providers[name]) throw new Error(`Provider "${name}" not found.`);
176
+ if (config.active === name) config.active = 'anthropic';
177
+ if (config.distill_provider === name) config.distill_provider = null;
178
+ if (config.daemon_provider === name) config.daemon_provider = null;
179
+ delete config.providers[name];
180
+ saveProviders(config);
181
+ }
182
+
183
+ function setRole(role, providerName) {
184
+ const config = loadProviders();
185
+ if (providerName && !config.providers[providerName]) {
186
+ throw new Error(`Provider "${providerName}" not found.`);
187
+ }
188
+ if (role === 'distill') {
189
+ config.distill_provider = providerName || null;
190
+ } else if (role === 'daemon') {
191
+ config.daemon_provider = providerName || null;
192
+ } else {
193
+ throw new Error(`Unknown role "${role}". Use: distill, daemon`);
194
+ }
195
+ saveProviders(config);
196
+ }
197
+
198
+ // ---------------------------------------------------------
199
+ // DISPLAY
200
+ // ---------------------------------------------------------
201
+ function listFormatted() {
202
+ const config = loadProviders();
203
+ const lines = [''];
204
+ for (const [name, p] of Object.entries(config.providers)) {
205
+ const active = name === config.active;
206
+ const icon = active ? '→' : ' ';
207
+ const label = p.label || name;
208
+ const url = p.base_url || 'official';
209
+ const badge = active ? ' (active)' : '';
210
+ lines.push(` ${icon} ${name}: ${label} [${url}]${badge}`);
211
+ }
212
+
213
+ const d = config.distill_provider;
214
+ const dm = config.daemon_provider;
215
+ if (d || dm) {
216
+ lines.push('');
217
+ if (d) lines.push(` Distill provider: ${d}`);
218
+ if (dm) lines.push(` Daemon provider: ${dm}`);
219
+ }
220
+
221
+ return lines.join('\n');
222
+ }
223
+
224
+ // ---------------------------------------------------------
225
+ // EXPORTS
226
+ // ---------------------------------------------------------
227
+ module.exports = {
228
+ loadProviders,
229
+ saveProviders,
230
+ buildEnv,
231
+ buildSpawnEnv,
232
+ buildActiveEnv,
233
+ buildDistillEnv,
234
+ buildDaemonEnv,
235
+ getActiveProvider,
236
+ getActiveName,
237
+ setActive,
238
+ addProvider,
239
+ removeProvider,
240
+ setRole,
241
+ listFormatted,
242
+ PROVIDERS_FILE,
243
+ };
package/scripts/schema.js CHANGED
@@ -175,6 +175,28 @@ function getAllowedKeysForPrompt() {
175
175
  return lines.join('\n');
176
176
  }
177
177
 
178
+ /**
179
+ * Get only writable keys (T3-T5) as a formatted list for the distill prompt.
180
+ * Saves ~150 tokens by omitting T1/T2 LOCKED fields the distiller can't write anyway.
181
+ */
182
+ function getWritableKeysForPrompt() {
183
+ const lines = [];
184
+ let currentTier = '';
185
+ for (const [key, def] of Object.entries(SCHEMA)) {
186
+ if (def.tier === 'T1' || def.tier === 'T2') continue;
187
+ if (def.tier !== currentTier) {
188
+ currentTier = def.tier;
189
+ lines.push(`\n# ${currentTier}:`);
190
+ }
191
+ let desc = ` ${key}: ${def.type}`;
192
+ if (def.values) desc += ` [${def.values.join('|')}]`;
193
+ if (def.maxChars) desc += ` (max ${def.maxChars} chars)`;
194
+ if (def.maxItems) desc += ` (max ${def.maxItems} items)`;
195
+ lines.push(desc);
196
+ }
197
+ return lines.join('\n');
198
+ }
199
+
178
200
  /**
179
201
  * Estimate token count for a YAML string (conservative for mixed zh/en).
180
202
  */
@@ -192,6 +214,7 @@ module.exports = {
192
214
  isLocked,
193
215
  validate,
194
216
  getAllowedKeysForPrompt,
217
+ getWritableKeysForPrompt,
195
218
  estimateTokens,
196
219
  TOKEN_BUDGET,
197
220
  };
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * MetaMe Session Analytics — Local Skeleton Extraction
5
+ *
6
+ * Parses Claude Code session JSONL transcripts to extract
7
+ * behavioral structure (tool usage, duration, git activity).
8
+ * Pure local processing — zero API cost.
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const os = require('os');
14
+
15
+ const HOME = os.homedir();
16
+ const PROJECTS_ROOT = path.join(HOME, '.claude', 'projects');
17
+ const STATE_FILE = path.join(HOME, '.metame', 'analytics_state.json');
18
+ const MAX_STATE_ENTRIES = 200;
19
+ const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
20
+ const MIN_FILE_SIZE = 1024; // 1KB
21
+
22
+ /**
23
+ * Load analytics state (set of already-analyzed session IDs).
24
+ */
25
+ function loadState() {
26
+ try {
27
+ if (fs.existsSync(STATE_FILE)) {
28
+ return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
29
+ }
30
+ } catch { /* corrupt state — start fresh */ }
31
+ return { analyzed: {} };
32
+ }
33
+
34
+ /**
35
+ * Save analytics state.
36
+ */
37
+ function saveState(state) {
38
+ // Cap entries
39
+ const keys = Object.keys(state.analyzed);
40
+ if (keys.length > MAX_STATE_ENTRIES) {
41
+ const sorted = keys.sort((a, b) => (state.analyzed[a] || 0) - (state.analyzed[b] || 0));
42
+ const toRemove = sorted.slice(0, keys.length - MAX_STATE_ENTRIES);
43
+ for (const k of toRemove) delete state.analyzed[k];
44
+ }
45
+ const dir = path.dirname(STATE_FILE);
46
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
47
+ fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf8');
48
+ }
49
+
50
+ /**
51
+ * Find the latest unanalyzed session JSONL across all projects.
52
+ * Returns { path, session_id, mtime } or null.
53
+ */
54
+ function findLatestUnanalyzedSession() {
55
+ const state = loadState();
56
+ let best = null;
57
+
58
+ try {
59
+ const projectDirs = fs.readdirSync(PROJECTS_ROOT);
60
+ for (const dir of projectDirs) {
61
+ const fullDir = path.join(PROJECTS_ROOT, dir);
62
+ let stat;
63
+ try { stat = fs.statSync(fullDir); } catch { continue; }
64
+ if (!stat.isDirectory()) continue;
65
+
66
+ let files;
67
+ try { files = fs.readdirSync(fullDir); } catch { continue; }
68
+
69
+ for (const file of files) {
70
+ if (!file.endsWith('.jsonl')) continue;
71
+ const sessionId = file.replace('.jsonl', '');
72
+ if (state.analyzed[sessionId]) continue;
73
+
74
+ const fullPath = path.join(fullDir, file);
75
+ let fstat;
76
+ try { fstat = fs.statSync(fullPath); } catch { continue; }
77
+
78
+ // Skip files that are too large or too small
79
+ if (fstat.size > MAX_FILE_SIZE || fstat.size < MIN_FILE_SIZE) continue;
80
+
81
+ if (!best || fstat.mtimeMs > best.mtime) {
82
+ best = { path: fullPath, session_id: sessionId, mtime: fstat.mtimeMs };
83
+ }
84
+ }
85
+ }
86
+ } catch {
87
+ // Projects root doesn't exist yet
88
+ return null;
89
+ }
90
+
91
+ return best;
92
+ }
93
+
94
+ /**
95
+ * Extract a behavioral skeleton from a session JSONL file.
96
+ * Only parses structural data — no content analysis of tool results.
97
+ */
98
+ function extractSkeleton(jsonlPath) {
99
+ const content = fs.readFileSync(jsonlPath, 'utf8');
100
+ const lines = content.split('\n');
101
+
102
+ const skeleton = {
103
+ session_id: path.basename(jsonlPath, '.jsonl'),
104
+ user_snippets: [],
105
+ tool_counts: {},
106
+ total_tool_calls: 0,
107
+ models: new Set(),
108
+ git_committed: false,
109
+ first_ts: null,
110
+ last_ts: null,
111
+ message_count: 0,
112
+ duration_min: 0,
113
+ project: null,
114
+ branch: null,
115
+ file_dirs: new Set(),
116
+ intent: null,
117
+ };
118
+
119
+ for (const line of lines) {
120
+ if (!line.trim()) continue;
121
+
122
+ // Fast pre-filter: only parse lines that look like user or assistant messages
123
+ if (!line.includes('"type":"user"') &&
124
+ !line.includes('"type":"assistant"') &&
125
+ !line.includes('"type": "user"') &&
126
+ !line.includes('"type": "assistant"')) {
127
+ continue;
128
+ }
129
+
130
+ let entry;
131
+ try { entry = JSON.parse(line); } catch { continue; }
132
+
133
+ const type = entry.type;
134
+ const ts = entry.timestamp;
135
+
136
+ // Track timestamps for duration
137
+ if (ts) {
138
+ if (!skeleton.first_ts || ts < skeleton.first_ts) skeleton.first_ts = ts;
139
+ if (!skeleton.last_ts || ts > skeleton.last_ts) skeleton.last_ts = ts;
140
+ }
141
+
142
+ if (type === 'user') {
143
+ // Extract project and branch from first occurrence
144
+ if (!skeleton.project && entry.cwd) {
145
+ skeleton.project = path.basename(entry.cwd);
146
+ }
147
+ if (!skeleton.branch && entry.gitBranch) {
148
+ skeleton.branch = entry.gitBranch;
149
+ }
150
+
151
+ const msg = entry.message;
152
+ if (!msg || !msg.content) continue;
153
+
154
+ const content = msg.content;
155
+ // Handle both string and array content
156
+ let userText = '';
157
+ if (typeof content === 'string') {
158
+ userText = content;
159
+ skeleton.user_snippets.push(content.slice(0, 100));
160
+ skeleton.message_count++;
161
+ } else if (Array.isArray(content)) {
162
+ for (const item of content) {
163
+ if (item.type === 'text' && item.text) {
164
+ userText = item.text;
165
+ skeleton.user_snippets.push(item.text.slice(0, 100));
166
+ skeleton.message_count++;
167
+ break; // One snippet per user message
168
+ }
169
+ }
170
+ }
171
+
172
+ // Extract intent from first substantial user message
173
+ if (!skeleton.intent && userText.length >= 15 && !userText.startsWith('[Request interrupted')) {
174
+ skeleton.intent = userText.slice(0, 80);
175
+ }
176
+ } else if (type === 'assistant') {
177
+ const msg = entry.message;
178
+ if (!msg) continue;
179
+
180
+ // Track model
181
+ if (msg.model) skeleton.models.add(msg.model);
182
+
183
+ // Count tool calls
184
+ const content = msg.content;
185
+ if (Array.isArray(content)) {
186
+ for (const item of content) {
187
+ if (item.type === 'tool_use') {
188
+ const name = item.name || 'unknown';
189
+ skeleton.tool_counts[name] = (skeleton.tool_counts[name] || 0) + 1;
190
+ skeleton.total_tool_calls++;
191
+
192
+ // Extract file directories from Read/Edit/Write operations
193
+ if ((name === 'Read' || name === 'Edit' || name === 'Write') &&
194
+ item.input && typeof item.input.file_path === 'string') {
195
+ const dirPath = path.dirname(item.input.file_path);
196
+ const segments = dirPath.split(path.sep).filter(Boolean);
197
+ const shortDir = segments.slice(-2).join('/');
198
+ if (shortDir) skeleton.file_dirs.add(shortDir);
199
+ }
200
+
201
+ // Detect git commits from Bash tool calls
202
+ if (name === 'Bash' && item.input && typeof item.input.command === 'string') {
203
+ const cmd = item.input.command;
204
+ if (cmd.includes('git commit') || cmd.includes('git push')) {
205
+ skeleton.git_committed = true;
206
+ }
207
+ }
208
+ }
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ // Calculate duration
215
+ if (skeleton.first_ts && skeleton.last_ts) {
216
+ const start = new Date(skeleton.first_ts).getTime();
217
+ const end = new Date(skeleton.last_ts).getTime();
218
+ skeleton.duration_min = Math.round((end - start) / 60000);
219
+ }
220
+
221
+ // Convert Sets to arrays for serialization
222
+ skeleton.models = [...skeleton.models];
223
+ skeleton.file_dirs = [...skeleton.file_dirs].slice(0, 5);
224
+
225
+ // Cap user snippets at 10
226
+ if (skeleton.user_snippets.length > 10) {
227
+ skeleton.user_snippets = skeleton.user_snippets.slice(0, 10);
228
+ }
229
+
230
+ return skeleton;
231
+ }
232
+
233
+ /**
234
+ * Format skeleton as a compact one-liner for injection into the distill prompt.
235
+ * Target: ~60 tokens.
236
+ */
237
+ function formatForPrompt(skeleton) {
238
+ if (!skeleton) return '';
239
+
240
+ const toolSummary = Object.entries(skeleton.tool_counts)
241
+ .sort((a, b) => b[1] - a[1])
242
+ .map(([name, count]) => `${name}×${count}`)
243
+ .join(' ');
244
+
245
+ const parts = [];
246
+ if (skeleton.project) {
247
+ const projLabel = skeleton.branch ? `${skeleton.project}@${skeleton.branch}` : skeleton.project;
248
+ parts.push(`Proj=${projLabel}`);
249
+ }
250
+ if (skeleton.duration_min > 0) parts.push(`Duration: ${skeleton.duration_min}min`);
251
+ parts.push(`Messages: ${skeleton.message_count}`);
252
+ if (skeleton.total_tool_calls > 0) parts.push(`Tools: ${skeleton.total_tool_calls} (${toolSummary})`);
253
+ if (skeleton.git_committed) parts.push('Git: committed');
254
+ if (skeleton.models.length > 0) {
255
+ const shortModels = skeleton.models.map(m => {
256
+ if (m.includes('opus')) return 'opus';
257
+ if (m.includes('sonnet')) return 'sonnet';
258
+ if (m.includes('haiku')) return 'haiku';
259
+ return m.split('-')[0];
260
+ });
261
+ parts.push(`Model: ${[...new Set(shortModels)].join(',')}`);
262
+ }
263
+
264
+ return parts.join(' | ');
265
+ }
266
+
267
+ /**
268
+ * Mark a session as analyzed.
269
+ */
270
+ function markAnalyzed(sessionId) {
271
+ const state = loadState();
272
+ state.analyzed[sessionId] = Date.now();
273
+ saveState(state);
274
+ }
275
+
276
+ /**
277
+ * Read declared goals from the user's profile.
278
+ * Returns a compact string like "DECLARED_GOALS: focus1 | focus2" (~11 tokens).
279
+ */
280
+ function formatGoalContext(profilePath) {
281
+ try {
282
+ const yaml = require('js-yaml');
283
+ const profile = yaml.load(fs.readFileSync(profilePath, 'utf8')) || {};
284
+ const goals = [];
285
+ if (profile.status && profile.status.focus) goals.push(profile.status.focus);
286
+ if (profile.context && profile.context.focus && profile.context.focus !== (profile.status && profile.status.focus)) {
287
+ goals.push(profile.context.focus);
288
+ }
289
+ if (goals.length === 0) return '';
290
+ return `DECLARED_GOALS: ${goals.join(' | ')}`;
291
+ } catch { return ''; }
292
+ }
293
+
294
+ module.exports = {
295
+ findLatestUnanalyzedSession,
296
+ extractSkeleton,
297
+ formatForPrompt,
298
+ formatGoalContext,
299
+ markAnalyzed,
300
+ };
301
+
302
+ // Direct execution for testing
303
+ if (require.main === module) {
304
+ console.log('🔍 Session Analytics — Testing\n');
305
+
306
+ const latest = findLatestUnanalyzedSession();
307
+ if (!latest) {
308
+ console.log('No unanalyzed sessions found.');
309
+ process.exit(0);
310
+ }
311
+
312
+ console.log(`Session: ${latest.session_id}`);
313
+ console.log(`Path: ${latest.path}`);
314
+ console.log(`Modified: ${new Date(latest.mtime).toISOString()}\n`);
315
+
316
+ const skeleton = extractSkeleton(latest.path);
317
+ console.log('Skeleton:', JSON.stringify(skeleton, null, 2));
318
+ console.log('\nPrompt format:', formatForPrompt(skeleton));
319
+
320
+ const goalCtx = formatGoalContext(path.join(HOME, '.claude_profile.yaml'));
321
+ if (goalCtx) console.log('Goal context:', goalCtx);
322
+ }