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 +12 -0
- package/index.js +224 -7
- package/package.json +2 -2
- package/scripts/daemon.js +44 -4
- package/scripts/distill.js +128 -82
- package/scripts/providers.js +243 -0
- package/scripts/schema.js +23 -0
- package/scripts/session-analytics.js +322 -0
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
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
|
package/scripts/distill.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
${
|
|
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
|
-
|
|
106
|
-
1. Extract ONLY cognitive traits, preferences,
|
|
107
|
-
2. IGNORE task-specific messages
|
|
108
|
-
3. Only
|
|
109
|
-
4.
|
|
110
|
-
5.
|
|
111
|
-
6.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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: []
|
|
161
|
+
avoidance_topics: []
|
|
154
162
|
emotional_response: analytical | blame_external | blame_self | withdrawal | null
|
|
155
|
-
topics: []
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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}]
|
|
646
|
-
|
|
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
|
+
}
|