metame-cli 1.4.0 → 1.4.3
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 -5
- package/index.js +15 -1
- package/package.json +1 -1
- package/scripts/daemon.js +308 -122
- package/scripts/distill.js +45 -9
- package/scripts/feishu-adapter.js +2 -2
- package/scripts/memory-extract.js +2 -1
- package/scripts/memory-search.js +95 -66
- package/scripts/schema.js +2 -5
- package/scripts/signal-capture.js +19 -9
package/README.md
CHANGED
|
@@ -216,7 +216,7 @@ Done. Open Telegram, message your bot.
|
|
|
216
216
|
| **Mobile Bridge** | Full Claude Code via Telegram/Feishu. Stateful sessions, file transfer both ways, real-time streaming status. |
|
|
217
217
|
| **Skill Evolution** | Self-healing skill system. Auto-discovers missing skills, learns from browser recordings, evolves after every task. Skills get smarter over time. |
|
|
218
218
|
| **Heartbeat System** | Three-layer programmable nervous system. Layer 0 kernel always-on (zero config). Layer 1 system evolution built-in (distill + memory + skills). Layer 2 your custom scheduled tasks with `require_idle`, `precondition`, `notify`, workflows. |
|
|
219
|
-
| **Multi-Agent** | Multiple projects with dedicated chat groups. `/bind` for one-tap setup. True parallel execution. |
|
|
219
|
+
| **Multi-Agent** | Multiple projects with dedicated chat groups. `/agent bind` for one-tap setup. True parallel execution. |
|
|
220
220
|
| **Browser Automation** | Built-in Playwright MCP. Browser control out of the box for every user. |
|
|
221
221
|
| **Provider Relay** | Route through any Anthropic-compatible API. Use GPT-4, DeepSeek, Gemini — zero config file mutation. |
|
|
222
222
|
| **Metacognition** | Detects behavioral patterns (decision style, comfort zones, goal drift) and injects mirror observations. Zero extra API cost. |
|
|
@@ -298,10 +298,11 @@ All agents share your cognitive profile (`~/.claude_profile.yaml`) — they all
|
|
|
298
298
|
| `/new` | Start new session (project picker) |
|
|
299
299
|
| `/resume` | Pick from session list |
|
|
300
300
|
| `/stop` | Interrupt current task (ESC) |
|
|
301
|
-
| `/undo` |
|
|
301
|
+
| `/undo` | Show recent messages as buttons — tap to roll back context + code to before that message |
|
|
302
|
+
| `/undo <hash>` | Roll back to a specific git checkpoint |
|
|
302
303
|
| `/list` | Browse & download project files |
|
|
303
304
|
| `/model` | Switch model (sonnet/opus/haiku) |
|
|
304
|
-
| `/bind <name
|
|
305
|
+
| `/agent bind <name> [dir]` | Register group as dedicated agent |
|
|
305
306
|
| `/sh <cmd>` | Raw shell — bypasses Claude |
|
|
306
307
|
| `/memory` | Memory stats: fact count, session tags, DB size |
|
|
307
308
|
| `/memory <keyword>` | Search long-term facts by keyword |
|
|
@@ -359,14 +360,20 @@ All agents share your cognitive profile (`~/.claude_profile.yaml`) — they all
|
|
|
359
360
|
|
|
360
361
|
> Both memory consolidation and session summarization run in the background via Haiku (`--model haiku`). Input is capped by code: skeleton text ≤ 3,000 chars, summary output ≤ 500 chars. Neither runs per-message — memory consolidation triggers on sleep mode (30-min idle), summaries trigger once per idle session.
|
|
361
362
|
|
|
362
|
-
## Plugin
|
|
363
|
+
## Plugin
|
|
363
364
|
|
|
364
|
-
|
|
365
|
+
Install directly into Claude Code without npm:
|
|
365
366
|
|
|
366
367
|
```bash
|
|
367
368
|
claude plugin install github:Yaron9/MetaMe/plugin
|
|
368
369
|
```
|
|
369
370
|
|
|
371
|
+
Includes: cognitive profile injection, daemon (Telegram/Feishu), heartbeat tasks, layered memory, all mobile commands, slash commands (`/metame:evolve`, `/metame:daemon`, `/metame:refresh`, etc.).
|
|
372
|
+
|
|
373
|
+
**One key difference from the npm CLI:** the plugin daemon starts when you open Claude Code and stops when you close it. It does not run 24/7 in the background. For always-on mobile access (receiving messages while Claude Code is closed), use the npm CLI with `metame daemon install-launchd`.
|
|
374
|
+
|
|
375
|
+
Use the plugin if you prefer not to install a global npm package and only need mobile access while Claude Code is open. Use the npm CLI (`metame-cli`) for 24/7 daemon, the `metame` command, and first-run interview.
|
|
376
|
+
|
|
370
377
|
## License
|
|
371
378
|
|
|
372
379
|
MIT
|
package/index.js
CHANGED
|
@@ -140,7 +140,21 @@ function ensureHookInstalled() {
|
|
|
140
140
|
ensureHookInstalled();
|
|
141
141
|
|
|
142
142
|
// ---------------------------------------------------------
|
|
143
|
-
// 1.6b
|
|
143
|
+
// 1.6b LOCAL ACTIVITY HEARTBEAT
|
|
144
|
+
// ---------------------------------------------------------
|
|
145
|
+
// Touch ~/.metame/local_active so the daemon knows the user is active on desktop.
|
|
146
|
+
// This prevents dream tasks (require_idle: true) from firing during live Claude sessions.
|
|
147
|
+
try {
|
|
148
|
+
const localActiveFile = path.join(METAME_DIR, 'local_active');
|
|
149
|
+
// Ensure file exists (open with 'a' is a no-op if it already exists)
|
|
150
|
+
fs.closeSync(fs.openSync(localActiveFile, 'a'));
|
|
151
|
+
// Update mtime so daemon idle detection sees fresh activity
|
|
152
|
+
const now = new Date();
|
|
153
|
+
fs.utimesSync(localActiveFile, now, now);
|
|
154
|
+
} catch { /* non-fatal */ }
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------
|
|
157
|
+
// 1.6c ENSURE PROJECT-LEVEL MCP CONFIG
|
|
144
158
|
// ---------------------------------------------------------
|
|
145
159
|
// MCP servers are registered per-project via .mcp.json (not user-scope ~/.claude.json)
|
|
146
160
|
// so they only load when working in projects that need them.
|
package/package.json
CHANGED
package/scripts/daemon.js
CHANGED
|
@@ -924,19 +924,41 @@ function physiologicalHeartbeat(config) {
|
|
|
924
924
|
}
|
|
925
925
|
|
|
926
926
|
// ---------------------------------------------------------
|
|
927
|
-
// HEARTBEAT
|
|
927
|
+
// HEARTBEAT TASK HELPERS (single source of truth)
|
|
928
928
|
// ---------------------------------------------------------
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Collect all heartbeat tasks from config (general + per-project).
|
|
932
|
+
* Each project task gets _project metadata attached.
|
|
933
|
+
* Returns { general: [...], project: [...], all: [...] }
|
|
934
|
+
*/
|
|
935
|
+
function getAllTasks(cfg) {
|
|
936
|
+
const general = (cfg.heartbeat && cfg.heartbeat.tasks) || [];
|
|
937
|
+
const project = [];
|
|
938
|
+
const generalNames = new Set(general.map(t => t.name));
|
|
939
|
+
for (const [key, proj] of Object.entries(cfg.projects || {})) {
|
|
934
940
|
for (const t of (proj.heartbeat_tasks || [])) {
|
|
935
|
-
if (
|
|
936
|
-
|
|
941
|
+
if (generalNames.has(t.name)) log('WARN', `Duplicate task name "${t.name}" in project "${key}" and general heartbeat`);
|
|
942
|
+
project.push({ ...t, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } });
|
|
937
943
|
}
|
|
938
944
|
}
|
|
939
|
-
|
|
945
|
+
return { general, project, all: [...general, ...project] };
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Find a task by name across all groups.
|
|
950
|
+
*/
|
|
951
|
+
function findTask(cfg, name) {
|
|
952
|
+
const { general, project } = getAllTasks(cfg);
|
|
953
|
+
const found = general.find(t => t.name === name) || project.find(t => t.name === name);
|
|
954
|
+
return found || null;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// ---------------------------------------------------------
|
|
958
|
+
// HEARTBEAT SCHEDULER
|
|
959
|
+
// ---------------------------------------------------------
|
|
960
|
+
function startHeartbeat(config, notifyFn) {
|
|
961
|
+
const { all: tasks } = getAllTasks(config);
|
|
940
962
|
|
|
941
963
|
const enabledTasks = tasks.filter(t => t.enabled !== false);
|
|
942
964
|
const checkIntervalSec = (config.daemon && config.daemon.heartbeat_check_interval) || 60;
|
|
@@ -978,6 +1000,9 @@ function startHeartbeat(config, notifyFn) {
|
|
|
978
1000
|
log('INFO', '[DAEMON] Entering Sleep Mode');
|
|
979
1001
|
// Generate summaries for sessions idle 2-24h
|
|
980
1002
|
spawnSessionSummaries();
|
|
1003
|
+
} else if (!idle && _inSleepMode) {
|
|
1004
|
+
_inSleepMode = false;
|
|
1005
|
+
log('INFO', '[DAEMON] Exiting Sleep Mode — local activity detected');
|
|
981
1006
|
}
|
|
982
1007
|
|
|
983
1008
|
// ② Task heartbeat (burns tokens on schedule)
|
|
@@ -1527,6 +1552,7 @@ async function doBindAgent(bot, chatId, agentName, agentCwd) {
|
|
|
1527
1552
|
}
|
|
1528
1553
|
|
|
1529
1554
|
async function handleCommand(bot, chatId, text, config, executeTaskByName, senderId = null, readOnly = false) {
|
|
1555
|
+
if (text && !text.startsWith('/chatid') && !text.startsWith('/myid')) log('INFO', `CMD [${String(chatId).slice(-8)}]: ${text.slice(0, 80)}`);
|
|
1530
1556
|
const state = loadState();
|
|
1531
1557
|
|
|
1532
1558
|
// --- /chatid: reply with current chatId ---
|
|
@@ -1714,7 +1740,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
1714
1740
|
const factCount = s.facts ?? '?';
|
|
1715
1741
|
const tagFile = path.join(HOME, '.metame', 'session_tags.json');
|
|
1716
1742
|
let tagCount = 0;
|
|
1717
|
-
try { tagCount = Object.keys(JSON.parse(fs.readFileSync(tagFile, 'utf8'))).length; } catch {}
|
|
1743
|
+
try { tagCount = Object.keys(JSON.parse(fs.readFileSync(tagFile, 'utf8'))).length; } catch { }
|
|
1718
1744
|
const lines = [
|
|
1719
1745
|
`🧠 *Memory Stats*`,
|
|
1720
1746
|
`━━━━━━━━━━━━━━━━`,
|
|
@@ -2269,22 +2295,25 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
2269
2295
|
}
|
|
2270
2296
|
|
|
2271
2297
|
if (text === '/tasks') {
|
|
2298
|
+
const { general, project } = getAllTasks(config);
|
|
2272
2299
|
let msg = '';
|
|
2273
|
-
|
|
2274
|
-
const legacyTasks = (config.heartbeat && config.heartbeat.tasks) || [];
|
|
2275
|
-
if (legacyTasks.length > 0) {
|
|
2300
|
+
if (general.length > 0) {
|
|
2276
2301
|
msg += '📋 General:\n';
|
|
2277
|
-
for (const t of
|
|
2302
|
+
for (const t of general) {
|
|
2278
2303
|
const ts = state.tasks[t.name] || {};
|
|
2279
2304
|
msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
|
|
2280
2305
|
}
|
|
2281
2306
|
}
|
|
2282
|
-
// Project tasks grouped
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2307
|
+
// Project tasks grouped by _project
|
|
2308
|
+
const byProject = new Map();
|
|
2309
|
+
for (const t of project) {
|
|
2310
|
+
const pk = t._project.key;
|
|
2311
|
+
if (!byProject.has(pk)) byProject.set(pk, { proj: t._project, tasks: [] });
|
|
2312
|
+
byProject.get(pk).tasks.push(t);
|
|
2313
|
+
}
|
|
2314
|
+
for (const [, { proj, tasks }] of byProject) {
|
|
2315
|
+
msg += `\n${proj.icon} ${proj.name}:\n`;
|
|
2316
|
+
for (const t of tasks) {
|
|
2288
2317
|
const ts = state.tasks[t.name] || {};
|
|
2289
2318
|
msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
|
|
2290
2319
|
}
|
|
@@ -2403,13 +2432,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
2403
2432
|
return;
|
|
2404
2433
|
}
|
|
2405
2434
|
const taskName = text.slice(5).trim();
|
|
2406
|
-
const
|
|
2407
|
-
for (const [key, proj] of Object.entries(config.projects || {})) {
|
|
2408
|
-
for (const t of (proj.heartbeat_tasks || [])) {
|
|
2409
|
-
allRunTasks.push({ ...t, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } });
|
|
2410
|
-
}
|
|
2411
|
-
}
|
|
2412
|
-
const task = allRunTasks.find(t => t.name === taskName);
|
|
2435
|
+
const task = findTask(config, taskName);
|
|
2413
2436
|
if (!task) { await bot.sendMessage(chatId, `❌ Task "${taskName}" not found`); return; }
|
|
2414
2437
|
|
|
2415
2438
|
// Script tasks: quick, run inline
|
|
@@ -2676,7 +2699,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
2676
2699
|
}
|
|
2677
2700
|
|
|
2678
2701
|
const session = getSession(chatId);
|
|
2679
|
-
if (!session || !session.id
|
|
2702
|
+
if (!session || !session.id) {
|
|
2680
2703
|
await bot.sendMessage(chatId, 'No active session to undo.');
|
|
2681
2704
|
return;
|
|
2682
2705
|
}
|
|
@@ -2684,107 +2707,190 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
2684
2707
|
const cwd = session.cwd;
|
|
2685
2708
|
const arg = text.slice(5).trim();
|
|
2686
2709
|
|
|
2687
|
-
//
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2710
|
+
// /undo <hash> — git reset to specific checkpoint (advanced usage)
|
|
2711
|
+
if (arg) {
|
|
2712
|
+
if (!cwd) {
|
|
2713
|
+
await bot.sendMessage(chatId, '❌ 当前 session 无工作目录,无法执行 git undo');
|
|
2714
|
+
return;
|
|
2715
|
+
}
|
|
2716
|
+
let isGitRepo = false;
|
|
2717
|
+
try { execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'ignore', timeout: 3000 }); isGitRepo = true; } catch { }
|
|
2718
|
+
const checkpoints = isGitRepo ? listCheckpoints(cwd) : [];
|
|
2719
|
+
const match = checkpoints.find(cp => cp.hash.startsWith(arg));
|
|
2720
|
+
if (!match) {
|
|
2721
|
+
await bot.sendMessage(chatId, `❌ 未找到 checkpoint: ${arg}`);
|
|
2722
|
+
return;
|
|
2723
|
+
}
|
|
2724
|
+
try {
|
|
2725
|
+
let diffFiles = '';
|
|
2726
|
+
try { diffFiles = execSync(`git diff --name-only HEAD ${match.hash}`, { cwd, encoding: 'utf8', timeout: 5000 }).trim(); } catch { }
|
|
2727
|
+
execSync(`git reset --hard ${match.hash}`, { cwd, stdio: 'ignore', timeout: 10000 });
|
|
2728
|
+
// Truncate context to checkpoint time (covers multi-turn rollback)
|
|
2729
|
+
truncateSessionToCheckpoint(session.id, match.message);
|
|
2730
|
+
const fileList = diffFiles ? diffFiles.split('\n').map(f => path.basename(f)).join(', ') : '';
|
|
2731
|
+
const fileCount = diffFiles ? diffFiles.split('\n').length : 0;
|
|
2732
|
+
let msg = `⏪ 已回退到 ${cpDisplayLabel(match.message)}`;
|
|
2733
|
+
if (fileCount > 0) msg += `\n📁 ${fileCount} 个文件恢复: ${fileList}`;
|
|
2734
|
+
log('INFO', `/undo <hash> executed for ${chatId}: reset to ${match.hash.slice(0, 8)}, files=${fileCount}`);
|
|
2735
|
+
await bot.sendMessage(chatId, msg);
|
|
2736
|
+
cleanupCheckpoints(cwd);
|
|
2737
|
+
} catch (e) {
|
|
2738
|
+
await bot.sendMessage(chatId, `❌ Undo failed: ${e.message}`);
|
|
2739
|
+
}
|
|
2698
2740
|
return;
|
|
2699
2741
|
}
|
|
2700
2742
|
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
const
|
|
2743
|
+
// /undo (no arg) — show recent user messages as buttons to pick rollback point
|
|
2744
|
+
try {
|
|
2745
|
+
const sessionFile = findSessionFile(session.id);
|
|
2746
|
+
if (!sessionFile) {
|
|
2747
|
+
await bot.sendMessage(chatId, '⚠️ 找不到 session 文件,无法列出历史消息');
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
const lines = fs.readFileSync(sessionFile, 'utf8').split('\n').filter(l => l.trim());
|
|
2751
|
+
|
|
2752
|
+
// Helper: extract real user text (skip tool_result entries and system annotations)
|
|
2753
|
+
const extractUserText = (obj) => {
|
|
2754
|
+
try {
|
|
2755
|
+
const content = obj.message?.content;
|
|
2756
|
+
if (typeof content === 'string') return content.trim();
|
|
2757
|
+
if (Array.isArray(content)) {
|
|
2758
|
+
// Skip entries that are purely tool results
|
|
2759
|
+
if (content.every(c => c.type === 'tool_result')) return '';
|
|
2760
|
+
// Find first text item that isn't a system annotation (exact patterns only)
|
|
2761
|
+
const SYSTEM_ANNOTATION = /^\[(Image source|Pasted|Attachment|File):/;
|
|
2762
|
+
const item = content.find(c => c.type === 'text' && c.text && !SYSTEM_ANNOTATION.test(c.text));
|
|
2763
|
+
return item?.text?.trim() || '';
|
|
2764
|
+
}
|
|
2765
|
+
} catch { }
|
|
2766
|
+
return '';
|
|
2767
|
+
};
|
|
2768
|
+
|
|
2769
|
+
// Collect only real human-written user messages (skip tool results / annotations)
|
|
2770
|
+
const userMsgs = [];
|
|
2771
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2772
|
+
try {
|
|
2773
|
+
const obj = JSON.parse(lines[i]);
|
|
2774
|
+
if (obj.type === 'user' && obj.message?.role === 'user') {
|
|
2775
|
+
const text = extractUserText(obj);
|
|
2776
|
+
if (text) userMsgs.push({ idx: i, obj, text });
|
|
2777
|
+
}
|
|
2778
|
+
} catch { }
|
|
2779
|
+
}
|
|
2780
|
+
if (userMsgs.length === 0) {
|
|
2781
|
+
await bot.sendMessage(chatId, '⚠️ 没有可回退的历史消息');
|
|
2782
|
+
return;
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
// Show last 10 (most recent first)
|
|
2786
|
+
const recent = userMsgs.slice(-10).reverse();
|
|
2704
2787
|
if (bot.sendButtons) {
|
|
2705
|
-
const buttons = recent.map((
|
|
2706
|
-
const
|
|
2707
|
-
|
|
2788
|
+
const buttons = recent.map(({ idx, text, obj }) => {
|
|
2789
|
+
const msgText = text.replace(/\n/g, ' ').slice(0, 28);
|
|
2790
|
+
let timeLabel = '';
|
|
2791
|
+
if (obj.timestamp) {
|
|
2792
|
+
const d = new Date(obj.timestamp);
|
|
2793
|
+
if (!isNaN(d)) timeLabel = ` (${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')})`;
|
|
2794
|
+
}
|
|
2795
|
+
return [{ text: `⏪ ${msgText}${timeLabel}`, callback_data: `/undo_to ${idx}` }];
|
|
2708
2796
|
});
|
|
2709
|
-
await bot.sendButtons(chatId,
|
|
2797
|
+
await bot.sendButtons(chatId, `↩️ 回退到哪条消息之前?(共 ${userMsgs.length} 轮)`, buttons);
|
|
2710
2798
|
} else {
|
|
2711
|
-
let msg = '
|
|
2712
|
-
recent.forEach(
|
|
2713
|
-
msg +=
|
|
2799
|
+
let msg = '回退到哪条消息之前?回复 /undo_to <序号>\n\n';
|
|
2800
|
+
recent.forEach(({ idx, text }) => {
|
|
2801
|
+
msg += `[${idx}] ${text.slice(0, 40)}\n`;
|
|
2714
2802
|
});
|
|
2715
2803
|
await bot.sendMessage(chatId, msg);
|
|
2716
2804
|
}
|
|
2805
|
+
} catch (e) {
|
|
2806
|
+
await bot.sendMessage(chatId, `❌ Undo failed: ${e.message}`);
|
|
2807
|
+
}
|
|
2808
|
+
return;
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
// /undo_to <lineIdx> — restore session to before the message at given JSONL line index
|
|
2812
|
+
if (text.startsWith('/undo_to ')) {
|
|
2813
|
+
const idx = parseInt(text.slice(9).trim(), 10);
|
|
2814
|
+
if (isNaN(idx) || idx < 0) {
|
|
2815
|
+
await bot.sendMessage(chatId, '❌ 无效的回退序号');
|
|
2816
|
+
return;
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
// Kill any running task
|
|
2820
|
+
if (messageQueue.has(chatId)) {
|
|
2821
|
+
const q = messageQueue.get(chatId);
|
|
2822
|
+
if (q.timer) clearTimeout(q.timer);
|
|
2823
|
+
messageQueue.delete(chatId);
|
|
2824
|
+
}
|
|
2825
|
+
const proc2 = activeProcesses.get(chatId);
|
|
2826
|
+
if (proc2 && proc2.child) {
|
|
2827
|
+
proc2.aborted = true;
|
|
2828
|
+
try { process.kill(-proc2.child.pid, 'SIGINT'); } catch { proc2.child.kill('SIGINT'); }
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
const session2 = getSession(chatId);
|
|
2832
|
+
if (!session2 || !session2.id) {
|
|
2833
|
+
await bot.sendMessage(chatId, 'No active session.');
|
|
2717
2834
|
return;
|
|
2718
2835
|
}
|
|
2719
2836
|
|
|
2720
|
-
// /undo <hash> — execute git reset
|
|
2721
2837
|
try {
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2838
|
+
const sessionFile2 = findSessionFile(session2.id);
|
|
2839
|
+
if (!sessionFile2) { await bot.sendMessage(chatId, '❌ 找不到 session 文件'); return; }
|
|
2840
|
+
|
|
2841
|
+
const lines2 = fs.readFileSync(sessionFile2, 'utf8').split('\n').filter(l => l.trim());
|
|
2842
|
+
if (idx >= lines2.length) {
|
|
2843
|
+
await bot.sendMessage(chatId, '❌ 序号超出范围,session 已变化,请重新 /undo');
|
|
2726
2844
|
return;
|
|
2727
2845
|
}
|
|
2728
2846
|
|
|
2729
|
-
// Get
|
|
2730
|
-
let
|
|
2847
|
+
// Get target message text + timestamp for display and git matching
|
|
2848
|
+
let targetMsg = '', targetTs = 0;
|
|
2731
2849
|
try {
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2850
|
+
const obj = JSON.parse(lines2[idx]);
|
|
2851
|
+
const content = obj.message?.content;
|
|
2852
|
+
if (typeof content === 'string') targetMsg = content;
|
|
2853
|
+
else if (Array.isArray(content)) targetMsg = content.find(c => c.type === 'text')?.text || '';
|
|
2854
|
+
if (obj.timestamp) targetTs = new Date(obj.timestamp).getTime() || 0;
|
|
2855
|
+
} catch { }
|
|
2737
2856
|
|
|
2738
|
-
//
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
//
|
|
2746
|
-
const
|
|
2747
|
-
const
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
break; // Found a message before checkpoint, stop
|
|
2760
|
-
}
|
|
2761
|
-
}
|
|
2762
|
-
} catch { }
|
|
2763
|
-
}
|
|
2764
|
-
if (cutIdx > 0) {
|
|
2765
|
-
const kept = lines.slice(0, cutIdx);
|
|
2766
|
-
fs.writeFileSync(sessionFile, kept.join('\n') + '\n', 'utf8');
|
|
2767
|
-
log('INFO', `Truncated session at line ${cutIdx} (${lines.length - cutIdx} lines removed)`);
|
|
2857
|
+
// Git reset first (before JSONL truncation) so failure leaves state consistent
|
|
2858
|
+
let gitMsg2 = '';
|
|
2859
|
+
const cwd2 = session2.cwd;
|
|
2860
|
+
if (cwd2) {
|
|
2861
|
+
let isGitRepo2 = false;
|
|
2862
|
+
try { execSync('git rev-parse --is-inside-work-tree', { cwd: cwd2, stdio: 'ignore', timeout: 3000 }); isGitRepo2 = true; } catch { }
|
|
2863
|
+
if (isGitRepo2) {
|
|
2864
|
+
// Exclude safety checkpoints from matching to avoid confusion
|
|
2865
|
+
const checkpoints2 = listCheckpoints(cwd2).filter(cp => !cp.message.includes('[metame-safety]'));
|
|
2866
|
+
const cpMatch = targetTs
|
|
2867
|
+
? checkpoints2.find(cp => { const t = new Date(cpExtractTimestamp(cp.message) || 0).getTime(); return t > 0 && t <= targetTs; })
|
|
2868
|
+
: checkpoints2[0];
|
|
2869
|
+
if (cpMatch) {
|
|
2870
|
+
let diffFiles2 = '';
|
|
2871
|
+
try { diffFiles2 = execSync(`git diff --name-only HEAD ${cpMatch.hash}`, { cwd: cwd2, encoding: 'utf8', timeout: 5000 }).trim(); } catch { }
|
|
2872
|
+
if (diffFiles2) {
|
|
2873
|
+
// Save current state with distinct prefix (excluded from normal /undo list)
|
|
2874
|
+
gitCheckpoint(cwd2, `[metame-safety] before rollback to: ${targetMsg.slice(0, 40)}`);
|
|
2875
|
+
execSync(`git reset --hard ${cpMatch.hash}`, { cwd: cwd2, stdio: 'ignore', timeout: 10000 });
|
|
2876
|
+
gitMsg2 = `\n📁 ${diffFiles2.split('\n').length} 个文件已恢复`;
|
|
2877
|
+
cleanupCheckpoints(cwd2);
|
|
2768
2878
|
}
|
|
2769
2879
|
}
|
|
2770
2880
|
}
|
|
2771
|
-
} catch (truncErr) {
|
|
2772
|
-
log('WARN', `Session truncation failed (non-fatal): ${truncErr.message}`);
|
|
2773
2881
|
}
|
|
2774
2882
|
|
|
2775
|
-
|
|
2776
|
-
const
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
msg += `\n📁 ${fileCount} 个文件恢复: ${fileList}`;
|
|
2781
|
-
}
|
|
2782
|
-
await bot.sendMessage(chatId, msg);
|
|
2883
|
+
// Truncate JSONL after git reset succeeds
|
|
2884
|
+
const kept2 = lines2.slice(0, idx);
|
|
2885
|
+
fs.writeFileSync(sessionFile2, kept2.length ? kept2.join('\n') + '\n' : '', 'utf8');
|
|
2886
|
+
_sessionFileCache.delete(session2.id);
|
|
2887
|
+
const removed2 = lines2.length - kept2.length;
|
|
2783
2888
|
|
|
2784
|
-
|
|
2785
|
-
|
|
2889
|
+
const preview = targetMsg.replace(/\n/g, ' ').slice(0, 30) || `行 ${idx}`;
|
|
2890
|
+
log('INFO', `/undo_to ${idx} for ${chatId}: removed=${removed2} lines${gitMsg2 ? ', ' + gitMsg2.trim() : ''}`);
|
|
2891
|
+
await bot.sendMessage(chatId, `⏪ 已回退到「${preview}」之前\n🧠 上下文回滚 ${removed2} 行${gitMsg2}`);
|
|
2786
2892
|
} catch (e) {
|
|
2787
|
-
await bot.sendMessage(chatId, `❌
|
|
2893
|
+
await bot.sendMessage(chatId, `❌ 回退失败: ${e.message}`);
|
|
2788
2894
|
}
|
|
2789
2895
|
return;
|
|
2790
2896
|
}
|
|
@@ -3016,7 +3122,8 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
3016
3122
|
'/cd <path> — 切换工作目录',
|
|
3017
3123
|
'/session — 查看当前会话',
|
|
3018
3124
|
'/stop — 中断当前任务 (ESC)',
|
|
3019
|
-
'/undo —
|
|
3125
|
+
'/undo — 选择历史消息,点击回退到该条之前',
|
|
3126
|
+
'/undo <hash> — 回退到指定 git checkpoint',
|
|
3020
3127
|
'/quit — 结束会话,重新加载 MCP/配置',
|
|
3021
3128
|
'',
|
|
3022
3129
|
`⚙️ /model [${currentModel}] /provider [${currentProvider}] /status /tasks /run /budget /reload`,
|
|
@@ -3117,6 +3224,80 @@ function findSessionFile(sessionId) {
|
|
|
3117
3224
|
return null;
|
|
3118
3225
|
}
|
|
3119
3226
|
|
|
3227
|
+
/**
|
|
3228
|
+
* Truncate the last conversation turn (user message + assistant response) from a session JSONL.
|
|
3229
|
+
* Finds the last {type:"user"} entry and removes it plus everything after.
|
|
3230
|
+
* Returns the number of lines removed, or 0 if nothing was truncated.
|
|
3231
|
+
*/
|
|
3232
|
+
function truncateSessionLastTurn(sessionId) {
|
|
3233
|
+
try {
|
|
3234
|
+
const sessionFile = findSessionFile(sessionId);
|
|
3235
|
+
if (!sessionFile) return 0;
|
|
3236
|
+
const fileContent = fs.readFileSync(sessionFile, 'utf8');
|
|
3237
|
+
const lines = fileContent.split('\n').filter(l => l.trim());
|
|
3238
|
+
// Find the last user-type entry (walk backwards)
|
|
3239
|
+
let cutIdx = -1;
|
|
3240
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
3241
|
+
try {
|
|
3242
|
+
const obj = JSON.parse(lines[i]);
|
|
3243
|
+
if (obj.type === 'user') { cutIdx = i; break; }
|
|
3244
|
+
} catch { /* skip malformed lines */ }
|
|
3245
|
+
}
|
|
3246
|
+
if (cutIdx <= 0) return 0; // nothing to cut (keep at least line 0)
|
|
3247
|
+
const kept = lines.slice(0, cutIdx);
|
|
3248
|
+
fs.writeFileSync(sessionFile, kept.join('\n') + '\n', 'utf8');
|
|
3249
|
+
// Invalidate cache so next findSessionFile call re-reads fresh
|
|
3250
|
+
_sessionFileCache.delete(sessionId);
|
|
3251
|
+
const removed = lines.length - kept.length;
|
|
3252
|
+
log('INFO', `truncateSessionLastTurn: removed ${removed} lines from ${path.basename(sessionFile)}`);
|
|
3253
|
+
return removed;
|
|
3254
|
+
} catch (e) {
|
|
3255
|
+
log('WARN', `truncateSessionLastTurn failed: ${e.message}`);
|
|
3256
|
+
return 0;
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
/**
|
|
3261
|
+
* Truncate session JSONL to the point before a given checkpoint (timestamp-based).
|
|
3262
|
+
* Used for /undo <hash> to handle multi-turn rollback correctly.
|
|
3263
|
+
* Falls back to truncateSessionLastTurn if timestamp parsing fails.
|
|
3264
|
+
*/
|
|
3265
|
+
function truncateSessionToCheckpoint(sessionId, checkpointMessage) {
|
|
3266
|
+
try {
|
|
3267
|
+
const cpTs = cpExtractTimestamp(checkpointMessage);
|
|
3268
|
+
const cpTime = cpTs ? new Date(cpTs).getTime() : 0;
|
|
3269
|
+
if (!cpTime) return truncateSessionLastTurn(sessionId);
|
|
3270
|
+
|
|
3271
|
+
const sessionFile = findSessionFile(sessionId);
|
|
3272
|
+
if (!sessionFile) return 0;
|
|
3273
|
+
const fileContent = fs.readFileSync(sessionFile, 'utf8');
|
|
3274
|
+
const lines = fileContent.split('\n').filter(l => l.trim());
|
|
3275
|
+
|
|
3276
|
+
// Find the first user message at or after checkpoint time → cut there
|
|
3277
|
+
let cutIdx = -1;
|
|
3278
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3279
|
+
try {
|
|
3280
|
+
const obj = JSON.parse(lines[i]);
|
|
3281
|
+
if (obj.type === 'user' && obj.timestamp) {
|
|
3282
|
+
const msgTime = new Date(obj.timestamp).getTime();
|
|
3283
|
+
if (msgTime && msgTime >= cpTime) { cutIdx = i; break; }
|
|
3284
|
+
}
|
|
3285
|
+
} catch { /* skip malformed lines */ }
|
|
3286
|
+
}
|
|
3287
|
+
if (cutIdx <= 0) return truncateSessionLastTurn(sessionId); // fallback
|
|
3288
|
+
|
|
3289
|
+
const kept = lines.slice(0, cutIdx);
|
|
3290
|
+
fs.writeFileSync(sessionFile, kept.join('\n') + '\n', 'utf8');
|
|
3291
|
+
_sessionFileCache.delete(sessionId);
|
|
3292
|
+
const removed = lines.length - kept.length;
|
|
3293
|
+
log('INFO', `truncateSessionToCheckpoint: removed ${removed} lines from ${path.basename(sessionFile)}`);
|
|
3294
|
+
return removed;
|
|
3295
|
+
} catch (e) {
|
|
3296
|
+
log('WARN', `truncateSessionToCheckpoint failed: ${e.message}`);
|
|
3297
|
+
return truncateSessionLastTurn(sessionId); // fallback
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3120
3301
|
/**
|
|
3121
3302
|
* Scan all project session indexes, return most recent N sessions.
|
|
3122
3303
|
* Results cached for 10 seconds to avoid repeated directory scans.
|
|
@@ -3603,13 +3784,26 @@ let lastInteractionTime = Date.now(); // updated on every incoming message
|
|
|
3603
3784
|
let _inSleepMode = false; // tracks current sleep state for log transitions
|
|
3604
3785
|
|
|
3605
3786
|
const IDLE_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
|
|
3787
|
+
const LOCAL_ACTIVE_FILE = path.join(METAME_DIR, 'local_active');
|
|
3606
3788
|
|
|
3607
3789
|
/**
|
|
3608
3790
|
* Returns true when user has been inactive for >30min AND no sessions are running.
|
|
3791
|
+
* Checks BOTH mobile adapter activity (Telegram/Feishu) AND the local_active heartbeat
|
|
3792
|
+
* file (updated by Claude Code / index.js on each session start).
|
|
3609
3793
|
* Dream tasks (require_idle: true) only execute in this state.
|
|
3610
3794
|
*/
|
|
3611
3795
|
function isUserIdle() {
|
|
3612
|
-
|
|
3796
|
+
// Check mobile adapter activity (Telegram/Feishu)
|
|
3797
|
+
if (Date.now() - lastInteractionTime <= IDLE_THRESHOLD_MS) return false;
|
|
3798
|
+
// Check local desktop activity via ~/.metame/local_active mtime
|
|
3799
|
+
try {
|
|
3800
|
+
if (fs.existsSync(LOCAL_ACTIVE_FILE)) {
|
|
3801
|
+
const mtime = fs.statSync(LOCAL_ACTIVE_FILE).mtimeMs;
|
|
3802
|
+
if (Date.now() - mtime < IDLE_THRESHOLD_MS) return false;
|
|
3803
|
+
}
|
|
3804
|
+
} catch { /* ignore — treat as idle if file unreadable */ }
|
|
3805
|
+
// Only idle if no active Claude sub-processes either
|
|
3806
|
+
return activeProcesses.size === 0;
|
|
3613
3807
|
}
|
|
3614
3808
|
|
|
3615
3809
|
// Fix3: persist child PIDs so next daemon startup can kill orphans
|
|
@@ -4590,7 +4784,7 @@ async function main() {
|
|
|
4590
4784
|
qmd.startDaemon().then(running => {
|
|
4591
4785
|
if (running) log('INFO', '[QMD] Semantic search daemon started (localhost:8181)');
|
|
4592
4786
|
else log('INFO', '[QMD] Available but daemon not started — will use CLI fallback');
|
|
4593
|
-
}).catch(() => {});
|
|
4787
|
+
}).catch(() => { });
|
|
4594
4788
|
}
|
|
4595
4789
|
} catch { /* qmd-client not available, skip */ }
|
|
4596
4790
|
// Hourly heartbeat so daemon.log stays fresh even when idle (visible aliveness check)
|
|
@@ -4600,14 +4794,7 @@ async function main() {
|
|
|
4600
4794
|
|
|
4601
4795
|
// Task executor lookup (always reads fresh config)
|
|
4602
4796
|
function executeTaskByName(name) {
|
|
4603
|
-
const
|
|
4604
|
-
let task = legacy.find(t => t.name === name);
|
|
4605
|
-
if (!task) {
|
|
4606
|
-
for (const [key, proj] of Object.entries(config.projects || {})) {
|
|
4607
|
-
const found = (proj.heartbeat_tasks || []).find(t => t.name === name);
|
|
4608
|
-
if (found) { task = { ...found, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } }; break; }
|
|
4609
|
-
}
|
|
4610
|
-
}
|
|
4797
|
+
const task = findTask(config, name);
|
|
4611
4798
|
if (!task) return { success: false, error: `Task "${name}" not found` };
|
|
4612
4799
|
return executeTask(task, config);
|
|
4613
4800
|
}
|
|
@@ -4686,10 +4873,9 @@ async function main() {
|
|
|
4686
4873
|
refreshLogMaxSize(config);
|
|
4687
4874
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
4688
4875
|
heartbeatTimer = startHeartbeat(config, notifyFn);
|
|
4689
|
-
const
|
|
4690
|
-
const
|
|
4691
|
-
|
|
4692
|
-
log('INFO', `Config reloaded: ${totalCount} tasks (${projectCount} in projects)`);
|
|
4876
|
+
const { general, project } = getAllTasks(config);
|
|
4877
|
+
const totalCount = general.length + project.length;
|
|
4878
|
+
log('INFO', `Config reloaded: ${totalCount} tasks (${project.length} in projects)`);
|
|
4693
4879
|
return { success: true, tasks: totalCount };
|
|
4694
4880
|
}
|
|
4695
4881
|
// Expose reloadConfig to handleCommand via closure
|
|
@@ -4797,11 +4983,11 @@ if (process.argv.includes('--run')) {
|
|
|
4797
4983
|
process.exit(1);
|
|
4798
4984
|
}
|
|
4799
4985
|
const config = loadConfig();
|
|
4800
|
-
const
|
|
4801
|
-
const task = tasks.find(t => t.name === taskName);
|
|
4986
|
+
const task = findTask(config, taskName);
|
|
4802
4987
|
if (!task) {
|
|
4988
|
+
const { all } = getAllTasks(config);
|
|
4803
4989
|
console.error(`Task "${taskName}" not found in daemon.yaml`);
|
|
4804
|
-
console.error(`Available: ${
|
|
4990
|
+
console.error(`Available: ${all.map(t => t.name).join(', ') || '(none)'}`);
|
|
4805
4991
|
process.exit(1);
|
|
4806
4992
|
}
|
|
4807
4993
|
const result = executeTask(task, config);
|
package/scripts/distill.js
CHANGED
|
@@ -82,14 +82,14 @@ async function distill() {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
try {
|
|
85
|
-
// 3. Parse signals (preserve confidence from signal-capture)
|
|
85
|
+
// 3. Parse signals (preserve confidence + type from signal-capture)
|
|
86
86
|
const signals = [];
|
|
87
87
|
let highConfidenceCount = 0;
|
|
88
88
|
for (const line of lines) {
|
|
89
89
|
try {
|
|
90
90
|
const entry = JSON.parse(line);
|
|
91
91
|
if (entry.prompt) {
|
|
92
|
-
signals.push(entry.prompt);
|
|
92
|
+
signals.push({ text: entry.prompt, type: entry.type || 'implicit' });
|
|
93
93
|
if (entry.confidence === 'high') highConfidenceCount++;
|
|
94
94
|
}
|
|
95
95
|
} catch {
|
|
@@ -127,18 +127,24 @@ async function distill() {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
// 5. Build distillation prompt (compact + session-aware)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
130
|
+
// Input budget: keep total prompt under INPUT_TOKEN_BUDGET to control cost/latency.
|
|
131
|
+
// Priority: system prompt + profile + writable keys (must keep) > user messages > session context
|
|
132
|
+
const INPUT_TOKEN_BUDGET = 4000; // ~12K chars mixed zh/en
|
|
133
133
|
|
|
134
134
|
const writableKeys = getWritableKeysForPrompt();
|
|
135
135
|
|
|
136
|
-
//
|
|
136
|
+
// Reserve budget for fixed parts (system prompt template ~600 tokens, profile, writable keys)
|
|
137
|
+
const fixedOverhead = 600; // system prompt template + rules
|
|
138
|
+
const profileTokens = estimateTokens(currentProfile);
|
|
139
|
+
const keysTokens = estimateTokens(writableKeys);
|
|
140
|
+
const reservedTokens = fixedOverhead + profileTokens + keysTokens;
|
|
141
|
+
const availableForContent = Math.max(INPUT_TOKEN_BUDGET - reservedTokens, 200);
|
|
142
|
+
|
|
143
|
+
// Build session context (lower priority — truncate first)
|
|
137
144
|
let sessionSection = sessionContext
|
|
138
145
|
? `\nSESSION CONTEXT (what actually happened in the latest coding session):\n${sessionContext}\n`
|
|
139
146
|
: '';
|
|
140
147
|
|
|
141
|
-
// Add summary for long sessions (~30 tokens when present)
|
|
142
148
|
if (sessionSummary) {
|
|
143
149
|
const pivotText = sessionSummary.pivots.length > 0
|
|
144
150
|
? `\nPivots: ${sessionSummary.pivots.join('; ')}`
|
|
@@ -146,12 +152,41 @@ async function distill() {
|
|
|
146
152
|
sessionSection += `Summary: ${sessionSummary.intent} → ${sessionSummary.outcome}${pivotText}\n`;
|
|
147
153
|
}
|
|
148
154
|
|
|
149
|
-
// Goal context section (~11 tokens when present)
|
|
150
155
|
let goalContext = '';
|
|
151
156
|
if (sessionAnalytics) {
|
|
152
157
|
try { goalContext = sessionAnalytics.formatGoalContext(BRAIN_FILE); } catch { }
|
|
153
158
|
}
|
|
154
|
-
|
|
159
|
+
let goalSection = goalContext ? `\n${goalContext}\n` : '';
|
|
160
|
+
|
|
161
|
+
// Allocate remaining budget: user messages get priority over session context
|
|
162
|
+
const sessionTokens = estimateTokens(sessionSection + goalSection);
|
|
163
|
+
let budgetForMessages = availableForContent - sessionTokens;
|
|
164
|
+
|
|
165
|
+
// If not enough room, drop session context first, then trim messages
|
|
166
|
+
if (budgetForMessages < 100) {
|
|
167
|
+
sessionSection = '';
|
|
168
|
+
goalSection = '';
|
|
169
|
+
budgetForMessages = availableForContent;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Format signals: tag metacognitive signals so Haiku treats them differently
|
|
173
|
+
const formatSignal = (s, i) => {
|
|
174
|
+
const tag = s.type === 'metacognitive' ? ' [META]' : '';
|
|
175
|
+
return `${i + 1}. "${s.text}"${tag}`;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Truncate user messages to fit budget (keep most recent, they're more relevant)
|
|
179
|
+
let truncatedSignals = signals;
|
|
180
|
+
let userMessages = signals.map(formatSignal).join('\n');
|
|
181
|
+
if (estimateTokens(userMessages) > budgetForMessages) {
|
|
182
|
+
// Drop oldest messages until we fit
|
|
183
|
+
while (truncatedSignals.length > 1 && estimateTokens(
|
|
184
|
+
truncatedSignals.map(formatSignal).join('\n')
|
|
185
|
+
) > budgetForMessages) {
|
|
186
|
+
truncatedSignals = truncatedSignals.slice(1);
|
|
187
|
+
}
|
|
188
|
+
userMessages = truncatedSignals.map(formatSignal).join('\n');
|
|
189
|
+
}
|
|
155
190
|
|
|
156
191
|
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.
|
|
157
192
|
|
|
@@ -172,6 +207,7 @@ RULES:
|
|
|
172
207
|
3. Only output fields from WRITABLE FIELDS. Any other key will be rejected.
|
|
173
208
|
4. For enum fields, use one of the listed values.
|
|
174
209
|
5. Strong directives (以后一律/always/never/from now on) → _confidence: high. Otherwise: normal.
|
|
210
|
+
6. Messages tagged [META] are metacognitive signals (self-reflection, strategy shifts, error awareness). These are HIGH VALUE for cognition fields — extract decision_style, error_response, receptive_to_challenge, and behavioral patterns from them.
|
|
175
211
|
7. Add _confidence and _source blocks mapping field keys to confidence level and triggering quote.
|
|
176
212
|
8. NEVER extract agent identity or role definitions. Messages like "你是贾维斯/你的角色是.../you are Jarvis" define the AGENT, not the USER. The profile is about the USER's cognition only.
|
|
177
213
|
|
|
@@ -118,7 +118,7 @@ function createBot(config) {
|
|
|
118
118
|
* Send markdown as Feishu interactive card (lark_md renders bold, lists, code, links)
|
|
119
119
|
*/
|
|
120
120
|
async sendMarkdown(chatId, markdown) {
|
|
121
|
-
const elements = toMdChunks(markdown).map(c => ({ tag: 'markdown', content: c }));
|
|
121
|
+
const elements = toMdChunks(markdown).map(c => ({ tag: 'markdown', content: c, text_size: 'x-large' }));
|
|
122
122
|
return _sendInteractive(chatId, { schema: '2.0', body: { elements } });
|
|
123
123
|
},
|
|
124
124
|
|
|
@@ -132,7 +132,7 @@ function createBot(config) {
|
|
|
132
132
|
*/
|
|
133
133
|
async sendCard(chatId, { title, body, color = 'blue' }) {
|
|
134
134
|
const header = { title: { tag: 'plain_text', content: title }, template: color };
|
|
135
|
-
const elements = body ? toMdChunks(body).map(c => ({ tag: 'markdown', content: c })) : [];
|
|
135
|
+
const elements = body ? toMdChunks(body).map(c => ({ tag: 'markdown', content: c, text_size: 'x-large' })) : [];
|
|
136
136
|
return _sendInteractive(chatId, { schema: '2.0', header, body: { elements } });
|
|
137
137
|
},
|
|
138
138
|
|
|
@@ -28,8 +28,9 @@ const FACT_EXTRACTION_PROMPT = `你是精准的知识提取引擎。从以下会
|
|
|
28
28
|
- bug_lesson(Bug根因:什么设计/假设导致了问题)
|
|
29
29
|
- arch_convention(架构约定:系统组件的行为边界)
|
|
30
30
|
- config_fact(配置事实:某个值的真实含义,尤其反直觉的)
|
|
31
|
+
- config_change(配置变更:用户选择/确认了某个具体配置值,如”字体选了x-large”、”间隔改为2h”)
|
|
31
32
|
- user_pref(用户明确表达的偏好/红线)
|
|
32
|
-
- workflow_rule
|
|
33
|
+
- workflow_rule(工作流戒律:如”不要在某情况下做某事”的反常识流)
|
|
33
34
|
- project_milestone(项目里程碑:主要架构重构、版本发布等跨会话级成果)
|
|
34
35
|
|
|
35
36
|
绝对不提取:
|
package/scripts/memory-search.js
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
* memory-search.js — Cross-session memory recall CLI
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
|
-
* node memory-search.js "<query>"
|
|
7
|
-
* node memory-search.js
|
|
8
|
-
* node memory-search.js --
|
|
9
|
-
* node memory-search.js --
|
|
6
|
+
* node memory-search.js "<query>" # hybrid search (QMD + FTS5)
|
|
7
|
+
* node memory-search.js "<q1>" "<q2>" "<q3>" # multi-keyword parallel search
|
|
8
|
+
* node memory-search.js --facts "<query>" # search facts only
|
|
9
|
+
* node memory-search.js --sessions "<query>" # search sessions only
|
|
10
|
+
* node memory-search.js --recent # show recent sessions
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
+
* Multi-keyword: results are deduplicated by fact ID, best rank wins.
|
|
13
|
+
* Async: uses QMD hybrid search (BM25 + vector) when available, falls back to FTS5.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
'use strict';
|
|
@@ -16,7 +18,6 @@
|
|
|
16
18
|
const path = require('path');
|
|
17
19
|
const os = require('os');
|
|
18
20
|
|
|
19
|
-
// Support both local dev and installed (~/.metame/) paths
|
|
20
21
|
const memoryPath = [
|
|
21
22
|
path.join(os.homedir(), '.metame', 'memory.js'),
|
|
22
23
|
path.join(__dirname, 'memory.js'),
|
|
@@ -31,69 +32,97 @@ const memory = require(memoryPath);
|
|
|
31
32
|
|
|
32
33
|
const args = process.argv.slice(2);
|
|
33
34
|
const mode = args[0] && args[0].startsWith('--') ? args[0] : null;
|
|
34
|
-
const
|
|
35
|
+
const queries = mode ? args.slice(1) : args;
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
37
|
+
async function main() {
|
|
38
|
+
try {
|
|
39
|
+
if (mode === '--recent') {
|
|
40
|
+
const rows = memory.recentSessions({ limit: 5 });
|
|
41
|
+
console.log(JSON.stringify(rows.map(r => ({
|
|
42
|
+
type: 'session',
|
|
43
|
+
project: r.project,
|
|
44
|
+
date: r.created_at,
|
|
45
|
+
summary: r.summary,
|
|
46
|
+
})), null, 2));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!queries.length || !queries[0]) {
|
|
51
|
+
console.log('[]');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const useAsync = typeof memory.searchFactsAsync === 'function';
|
|
56
|
+
|
|
57
|
+
if (mode === '--facts') {
|
|
58
|
+
const results = await searchMulti(queries, { searchFn: q => useAsync ? memory.searchFactsAsync(q, { limit: 5 }) : memory.searchFacts(q, { limit: 5 }), type: 'fact' });
|
|
59
|
+
console.log(JSON.stringify(results, null, 2));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (mode === '--sessions') {
|
|
64
|
+
const results = await searchMulti(queries, { searchFn: q => Promise.resolve(memory.searchSessions(q, { limit: 5 })), type: 'session' });
|
|
65
|
+
console.log(JSON.stringify(results, null, 2));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Default: search both facts and sessions, all queries in parallel
|
|
70
|
+
const factResults = await searchMulti(queries, {
|
|
71
|
+
searchFn: q => useAsync ? memory.searchFactsAsync(q, { limit: 5 }) : Promise.resolve(memory.searchFacts(q, { limit: 5 })),
|
|
50
72
|
type: 'fact',
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
})), null, 2));
|
|
57
|
-
|
|
58
|
-
} else if (mode === '--sessions') {
|
|
59
|
-
if (!query) { console.log('[]'); process.exit(0); }
|
|
60
|
-
const sessions = memory.searchSessions(query, { limit: 5 });
|
|
61
|
-
console.log(JSON.stringify(sessions.map(s => ({
|
|
73
|
+
limit: 5,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const sessionResults = await searchMulti(queries, {
|
|
77
|
+
searchFn: q => Promise.resolve(memory.searchSessions(q, { limit: 3 })),
|
|
62
78
|
type: 'session',
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
} else {
|
|
69
|
-
// Default: search both facts and sessions
|
|
70
|
-
if (!query) { console.log('[]'); process.exit(0); }
|
|
71
|
-
const facts = (typeof memory.searchFacts === 'function')
|
|
72
|
-
? memory.searchFacts(query, { limit: 3 })
|
|
73
|
-
: [];
|
|
74
|
-
const sessions = memory.searchSessions(query, { limit: 3 });
|
|
75
|
-
|
|
76
|
-
const results = [
|
|
77
|
-
...facts.map(f => ({
|
|
78
|
-
type: 'fact',
|
|
79
|
-
entity: f.entity,
|
|
80
|
-
relation: f.relation,
|
|
81
|
-
value: f.value,
|
|
82
|
-
confidence: f.confidence,
|
|
83
|
-
date: f.created_at,
|
|
84
|
-
})),
|
|
85
|
-
...sessions.map(s => ({
|
|
86
|
-
type: 'session',
|
|
87
|
-
project: s.project,
|
|
88
|
-
date: s.created_at,
|
|
89
|
-
summary: s.summary,
|
|
90
|
-
})),
|
|
91
|
-
];
|
|
79
|
+
limit: 3,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
console.log(JSON.stringify([...factResults, ...sessionResults], null, 2));
|
|
92
83
|
|
|
93
|
-
|
|
84
|
+
} catch (e) {
|
|
85
|
+
console.log('[]');
|
|
86
|
+
} finally {
|
|
87
|
+
try { memory.close(); } catch {}
|
|
94
88
|
}
|
|
95
|
-
} catch (e) {
|
|
96
|
-
console.log('[]');
|
|
97
|
-
} finally {
|
|
98
|
-
try { memory.close(); } catch {}
|
|
99
89
|
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Run multiple queries in parallel, deduplicate and format results.
|
|
93
|
+
*/
|
|
94
|
+
async function searchMulti(queries, { searchFn, type, limit = 5 }) {
|
|
95
|
+
const allResults = await Promise.all(queries.map(q => searchFn(q).catch(() => [])));
|
|
96
|
+
|
|
97
|
+
// Deduplicate by id (facts) or created_at+project (sessions)
|
|
98
|
+
const seen = new Set();
|
|
99
|
+
const merged = [];
|
|
100
|
+
|
|
101
|
+
for (const batch of allResults) {
|
|
102
|
+
for (const item of (batch || [])) {
|
|
103
|
+
const key = type === 'fact'
|
|
104
|
+
? `f:${item.id || item.entity + item.value}`
|
|
105
|
+
: `s:${item.created_at}:${item.project}`;
|
|
106
|
+
if (!seen.has(key)) {
|
|
107
|
+
seen.add(key);
|
|
108
|
+
merged.push(type === 'fact' ? {
|
|
109
|
+
type: 'fact',
|
|
110
|
+
entity: item.entity,
|
|
111
|
+
relation: item.relation,
|
|
112
|
+
value: item.value,
|
|
113
|
+
confidence: item.confidence,
|
|
114
|
+
date: item.created_at,
|
|
115
|
+
} : {
|
|
116
|
+
type: 'session',
|
|
117
|
+
project: item.project,
|
|
118
|
+
date: item.created_at,
|
|
119
|
+
summary: item.summary,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return merged.slice(0, limit);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
main();
|
package/scripts/schema.js
CHANGED
|
@@ -44,13 +44,10 @@ const SCHEMA = {
|
|
|
44
44
|
'cognition.info_processing.entry_point': { tier: 'T3', type: 'enum', values: ['big_picture', 'details', 'examples'] },
|
|
45
45
|
'cognition.info_processing.preferred_format': { tier: 'T3', type: 'enum', values: ['structured', 'narrative', 'visual_metaphor'] },
|
|
46
46
|
'cognition.abstraction.default_level': { tier: 'T3', type: 'enum', values: ['strategic', 'architectural', 'implementation', 'operational'] },
|
|
47
|
-
'cognition.abstraction.range': { tier: 'T3', type: 'enum', values: ['narrow', 'wide'] },
|
|
48
47
|
'cognition.cognitive_load.chunk_size': { tier: 'T3', type: 'enum', values: ['small', 'medium', 'large'] },
|
|
49
|
-
'cognition.
|
|
50
|
-
'cognition.motivation.primary_driver': { tier: 'T3', type: 'enum', values: ['autonomy', 'competence', 'meaning', 'social_proof'] },
|
|
51
|
-
'cognition.motivation.energy_source': { tier: 'T3', type: 'enum', values: ['creation', 'optimization', 'problem_solving', 'teaching'] },
|
|
52
|
-
'cognition.metacognition.self_awareness': { tier: 'T3', type: 'enum', values: ['high', 'medium', 'low'] },
|
|
48
|
+
'cognition.motivation.driver': { tier: 'T3', type: 'enum', values: ['autonomy', 'competence', 'meaning', 'creation', 'optimization'] },
|
|
53
49
|
'cognition.metacognition.receptive_to_challenge': { tier: 'T3', type: 'enum', values: ['yes', 'sometimes', 'no'] },
|
|
50
|
+
'cognition.metacognition.error_response': { tier: 'T3', type: 'enum', values: ['quick_pivot', 'root_cause_first', 'seek_help', 'retry_same'] },
|
|
54
51
|
|
|
55
52
|
// === T4: Context ===
|
|
56
53
|
'context.focus': { tier: 'T4', type: 'string', maxChars: 80 },
|
|
@@ -29,6 +29,10 @@ const IMPLICIT_EN = /I (prefer|like|hate|usually|tend to|always)/i;
|
|
|
29
29
|
const CORRECTION_ZH = /不是.*我(要|想|说)的|我说的不是|你理解错了|不对.*应该/;
|
|
30
30
|
const CORRECTION_EN = /(no,? I meant|that's not what I|you misunderstood|wrong.+should be)/i;
|
|
31
31
|
|
|
32
|
+
// Metacognitive signals → normal confidence (self-reflection, strategy shifts)
|
|
33
|
+
const META_ZH = /我(发现|意识到|觉得|反思|总结|复盘)|想错了|换个(思路|方向|方案)|回头(想想|看看)|之前的(方案|思路|方向).*(不行|不对|有问题)|我的(问题|毛病|习惯)是|下次(应该|要|得)/;
|
|
34
|
+
const META_EN = /(I realize|looking back|on reflection|my (mistake|problem|habit) is|let me rethink|wrong approach|next time I should)/i;
|
|
35
|
+
|
|
32
36
|
// Read JSON from stdin
|
|
33
37
|
let input = '';
|
|
34
38
|
process.stdin.setEncoding('utf8');
|
|
@@ -38,7 +42,11 @@ process.stdin.on('end', () => {
|
|
|
38
42
|
const data = JSON.parse(input);
|
|
39
43
|
const prompt = (data.prompt || '').trim();
|
|
40
44
|
|
|
45
|
+
// === LAYER 0: Metacognitive bypass — always capture self-reflection ===
|
|
46
|
+
const isMeta = META_ZH.test(prompt) || META_EN.test(prompt);
|
|
47
|
+
|
|
41
48
|
// === LAYER 1: Hard filters (definitely not preferences) ===
|
|
49
|
+
// Metacognitive signals bypass all hard filters — they reveal how user thinks
|
|
42
50
|
|
|
43
51
|
// Skip empty or very short messages
|
|
44
52
|
// Chinese chars carry more info per char, so use weighted length
|
|
@@ -48,43 +56,43 @@ process.stdin.on('end', () => {
|
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
// Skip messages that are purely code or file paths
|
|
51
|
-
if (/^(```|\/[\w/]+\.\w+$)/.test(prompt)) {
|
|
59
|
+
if (!isMeta && /^(```|\/[\w/]+\.\w+$)/.test(prompt)) {
|
|
52
60
|
process.exit(0);
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
// Skip common non-preference commands
|
|
56
|
-
if (/^(\/\w+|!metame|git |npm |pnpm |yarn |brew |sudo |cd |ls |cat |mkdir )/.test(prompt)) {
|
|
64
|
+
if (!isMeta && /^(\/\w+|!metame|git |npm |pnpm |yarn |brew |sudo |cd |ls |cat |mkdir )/.test(prompt)) {
|
|
57
65
|
process.exit(0);
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
// Skip pure task instructions (fix/add/delete/refactor/debug/deploy/test/run/build)
|
|
61
|
-
if (/^(帮我|请你|麻烦)?\s*(fix|add|delete|remove|refactor|debug|deploy|test|run|build|create|update|implement|write|generate|make)/i.test(prompt)) {
|
|
69
|
+
if (!isMeta && /^(帮我|请你|麻烦)?\s*(fix|add|delete|remove|refactor|debug|deploy|test|run|build|create|update|implement|write|generate|make)/i.test(prompt)) {
|
|
62
70
|
process.exit(0);
|
|
63
71
|
}
|
|
64
|
-
if (/^(帮我|请你|麻烦)?\s*(修|加|删|重构|调试|部署|测试|运行|构建|创建|更新|实现|写|生成|做)/.test(prompt)) {
|
|
72
|
+
if (!isMeta && /^(帮我|请你|麻烦)?\s*(修|加|删|重构|调试|部署|测试|运行|构建|创建|更新|实现|写|生成|做)/.test(prompt)) {
|
|
65
73
|
process.exit(0);
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
// Skip agent identity definitions (these belong in project CLAUDE.md, not user profile)
|
|
69
|
-
if (/^(你是|你叫|你的(角色|身份|职责|任务)|你负责|你现在是|from now on you are|you are now|your role is)/i.test(prompt)) {
|
|
77
|
+
if (!isMeta && /^(你是|你叫|你的(角色|身份|职责|任务)|你负责|你现在是|from now on you are|you are now|your role is)/i.test(prompt)) {
|
|
70
78
|
process.exit(0);
|
|
71
79
|
}
|
|
72
80
|
|
|
73
81
|
// Skip pasted error logs / stack traces
|
|
74
|
-
if (/^(Error|TypeError|SyntaxError|ReferenceError|at\s+\w+|Traceback|FATAL|WARN|ERR!)/i.test(prompt)) {
|
|
82
|
+
if (!isMeta && /^(Error|TypeError|SyntaxError|ReferenceError|at\s+\w+|Traceback|FATAL|WARN|ERR!)/i.test(prompt)) {
|
|
75
83
|
process.exit(0);
|
|
76
84
|
}
|
|
77
|
-
if (prompt.split('\n').length > 10) {
|
|
85
|
+
if (!isMeta && prompt.split('\n').length > 10) {
|
|
78
86
|
// Multi-line pastes are usually code or logs, not preferences
|
|
79
87
|
process.exit(0);
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
// Skip pure questions with no preference signal
|
|
83
|
-
if (/^(what|how|why|where|when|which|can you|could you|is there|are there|does|do you)\s/i.test(prompt) &&
|
|
91
|
+
if (!isMeta && /^(what|how|why|where|when|which|can you|could you|is there|are there|does|do you)\s/i.test(prompt) &&
|
|
84
92
|
!/prefer|like|hate|always|never|style|习惯|偏好|喜欢|讨厌/.test(prompt)) {
|
|
85
93
|
process.exit(0);
|
|
86
94
|
}
|
|
87
|
-
if (/^(什么|怎么|为什么|哪|能不能|可以|是不是)\s/.test(prompt) &&
|
|
95
|
+
if (!isMeta && /^(什么|怎么|为什么|哪|能不能|可以|是不是)\s/.test(prompt) &&
|
|
88
96
|
!/偏好|喜欢|讨厌|习惯|以后|一律|总是|永远|记住/.test(prompt)) {
|
|
89
97
|
process.exit(0);
|
|
90
98
|
}
|
|
@@ -93,12 +101,14 @@ process.stdin.on('end', () => {
|
|
|
93
101
|
const isStrong = STRONG_SIGNAL_ZH.test(prompt) || STRONG_SIGNAL_EN.test(prompt);
|
|
94
102
|
const isCorrection = CORRECTION_ZH.test(prompt) || CORRECTION_EN.test(prompt);
|
|
95
103
|
const confidence = (isStrong || isCorrection) ? 'high' : 'normal';
|
|
104
|
+
const signalType = isMeta ? 'metacognitive' : isCorrection ? 'correction' : isStrong ? 'directive' : 'implicit';
|
|
96
105
|
|
|
97
106
|
// Append to buffer
|
|
98
107
|
const entry = {
|
|
99
108
|
ts: new Date().toISOString(),
|
|
100
109
|
prompt: prompt,
|
|
101
110
|
confidence: confidence,
|
|
111
|
+
type: signalType,
|
|
102
112
|
session: data.session_id || null,
|
|
103
113
|
cwd: data.cwd || null
|
|
104
114
|
};
|