metame-cli 1.3.23 → 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 +197 -31
- package/index.js +202 -142
- package/package.json +3 -3
- package/scripts/daemon-default.yaml +39 -1
- package/scripts/daemon.js +761 -223
- package/scripts/distill.js +85 -99
- package/scripts/feishu-adapter.js +61 -148
- package/scripts/memory-extract.js +264 -0
- package/scripts/memory-search.js +128 -0
- package/scripts/memory.js +439 -0
- package/scripts/providers.js +32 -0
- package/scripts/qmd-client.js +276 -0
- package/scripts/schema.js +36 -42
- package/scripts/session-analytics.js +64 -7
- package/scripts/session-summarize.js +118 -0
- package/scripts/signal-capture.js +19 -9
- package/scripts/skill-evolution.js +19 -16
package/scripts/daemon.js
CHANGED
|
@@ -27,6 +27,7 @@ const LOG_FILE = path.join(METAME_DIR, 'daemon.log');
|
|
|
27
27
|
const BRAIN_FILE = path.join(HOME, '.claude_profile.yaml');
|
|
28
28
|
const DISPATCH_DIR = path.join(METAME_DIR, 'dispatch');
|
|
29
29
|
const DISPATCH_LOG = path.join(DISPATCH_DIR, 'dispatch-log.jsonl');
|
|
30
|
+
const SOCK_PATH = path.join(METAME_DIR, 'daemon.sock');
|
|
30
31
|
|
|
31
32
|
// Skill evolution module (hot path + cold path)
|
|
32
33
|
let skillEvolution = null;
|
|
@@ -318,11 +319,13 @@ function executeTask(task, config) {
|
|
|
318
319
|
if (task.type === 'script') {
|
|
319
320
|
log('INFO', `Executing script task: ${task.name} → ${task.command}`);
|
|
320
321
|
try {
|
|
322
|
+
const scriptEnv = { ...process.env, METAME_ROOT: process.env.METAME_ROOT || '' };
|
|
323
|
+
delete scriptEnv.CLAUDECODE;
|
|
321
324
|
const output = execSync(task.command, {
|
|
322
325
|
encoding: 'utf8',
|
|
323
|
-
timeout:
|
|
326
|
+
timeout: (task.timeout || 120) * 1000,
|
|
324
327
|
maxBuffer: 1024 * 1024,
|
|
325
|
-
env:
|
|
328
|
+
env: scriptEnv,
|
|
326
329
|
}).trim();
|
|
327
330
|
|
|
328
331
|
state.tasks[task.name] = {
|
|
@@ -331,7 +334,8 @@ function executeTask(task, config) {
|
|
|
331
334
|
output_preview: output.slice(0, 200),
|
|
332
335
|
};
|
|
333
336
|
saveState(state);
|
|
334
|
-
log('INFO', `Script task ${task.name} completed`);
|
|
337
|
+
if (output) log('INFO', `Script task ${task.name} completed: ${output.slice(0, 300)}`);
|
|
338
|
+
else log('INFO', `Script task ${task.name} completed`);
|
|
335
339
|
return { success: true, output, tokens: 0 };
|
|
336
340
|
} catch (e) {
|
|
337
341
|
log('ERROR', `Script task ${task.name} failed: ${e.message}`);
|
|
@@ -545,15 +549,62 @@ function setDispatchHandler(fn) { _handleCommand = fn; }
|
|
|
545
549
|
function createNullBot(onOutput) {
|
|
546
550
|
const noop = async () => ({ message_id: '_virtual' });
|
|
547
551
|
return {
|
|
548
|
-
sendMessage:
|
|
549
|
-
sendMarkdown:
|
|
550
|
-
sendCard:
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
552
|
+
sendMessage: async (chatId, text) => { if (onOutput) onOutput(text); return { message_id: '_virtual' }; },
|
|
553
|
+
sendMarkdown: async (chatId, text) => { if (onOutput) onOutput(text); return { message_id: '_virtual' }; },
|
|
554
|
+
sendCard: async (chatId, card) => { if (onOutput) onOutput(typeof card === 'object' ? (card.body || card.title || JSON.stringify(card)) : card); return { message_id: '_virtual' }; },
|
|
555
|
+
sendRawCard: async (chatId, header) => { if (onOutput) onOutput(header); return { message_id: '_virtual' }; },
|
|
556
|
+
sendButtons: async (chatId, text) => { if (onOutput) onOutput(text); return { message_id: '_virtual' }; },
|
|
557
|
+
sendTyping: async () => { },
|
|
558
|
+
editMessage: async () => { },
|
|
559
|
+
deleteMessage: async () => { },
|
|
560
|
+
sendFile: noop,
|
|
561
|
+
downloadFile: noop,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Forward bot: routes all calls to a real bot with a fixed chatId.
|
|
567
|
+
* Used for dispatch tasks so Claude's streaming output appears in the target's Feishu channel.
|
|
568
|
+
*/
|
|
569
|
+
function createStreamForwardBot(realBot, chatId) {
|
|
570
|
+
// Track edit-broken state independently so dispatch failures don't poison realBot's flag
|
|
571
|
+
let _editBroken = false;
|
|
572
|
+
return {
|
|
573
|
+
sendMessage: async (_, text) => {
|
|
574
|
+
log('INFO', `[StreamBot→${chatId.slice(-8)}] msg: ${String(text).slice(0, 80)}`);
|
|
575
|
+
return realBot.sendMessage(chatId, text);
|
|
576
|
+
},
|
|
577
|
+
sendMarkdown: async (_, text) => {
|
|
578
|
+
log('INFO', `[StreamBot→${chatId.slice(-8)}] md: ${String(text).slice(0, 80)}`);
|
|
579
|
+
return realBot.sendMarkdown(chatId, text);
|
|
580
|
+
},
|
|
581
|
+
sendCard: async (_, card) => {
|
|
582
|
+
const title = typeof card === 'object' ? (card.title || card.body || '').slice(0, 60) : String(card).slice(0, 60);
|
|
583
|
+
log('INFO', `[StreamBot→${chatId.slice(-8)}] card: ${title}`);
|
|
584
|
+
return realBot.sendCard(chatId, card);
|
|
585
|
+
},
|
|
586
|
+
sendRawCard: async (_, header, elements) => {
|
|
587
|
+
log('INFO', `[StreamBot→${chatId.slice(-8)}] rawcard: ${String(header).slice(0, 60)}`);
|
|
588
|
+
return realBot.sendRawCard(chatId, header, elements);
|
|
589
|
+
},
|
|
590
|
+
sendButtons: async (_, text, buttons) => realBot.sendButtons(chatId, text, buttons),
|
|
591
|
+
sendTyping: async () => realBot.sendTyping(chatId),
|
|
592
|
+
editMessage: async (_, msgId, text) => {
|
|
593
|
+
if (_editBroken) return false;
|
|
594
|
+
log('INFO', `[StreamBot→${chatId.slice(-8)}] edit ${String(msgId).slice(-8)}: ${String(text).slice(0, 60)}`);
|
|
595
|
+
try {
|
|
596
|
+
return await realBot.editMessage(chatId, msgId, text);
|
|
597
|
+
} catch (e) {
|
|
598
|
+
const code = e?.code || e?.response?.data?.code;
|
|
599
|
+
if (code === 230001 || code === 230002 || /permission|forbidden/i.test(String(e))) {
|
|
600
|
+
_editBroken = true;
|
|
601
|
+
}
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
},
|
|
605
|
+
deleteMessage: async (_, msgId) => realBot.deleteMessage(chatId, msgId),
|
|
606
|
+
sendFile: async (_, filePath, caption) => realBot.sendFile(chatId, filePath, caption),
|
|
607
|
+
downloadFile: async (...args) => realBot.downloadFile(...args),
|
|
557
608
|
};
|
|
558
609
|
}
|
|
559
610
|
|
|
@@ -564,8 +615,8 @@ function createNullBot(onOutput) {
|
|
|
564
615
|
* @param {object} config - current daemon config
|
|
565
616
|
* @returns {{ success: boolean, id?: string, error?: string }}
|
|
566
617
|
*/
|
|
567
|
-
function dispatchTask(targetProject, message, config, replyFn) {
|
|
568
|
-
const LIMITS = { max_per_hour_per_target:
|
|
618
|
+
function dispatchTask(targetProject, message, config, replyFn, streamOptions = null) {
|
|
619
|
+
const LIMITS = { max_per_hour_per_target: 20, max_total_per_hour: 60, max_depth: 2 };
|
|
569
620
|
|
|
570
621
|
// Anti-storm: check chain depth
|
|
571
622
|
const chain = message.chain || [];
|
|
@@ -638,22 +689,22 @@ function dispatchTask(targetProject, message, config, replyFn) {
|
|
|
638
689
|
}
|
|
639
690
|
|
|
640
691
|
// Inject ack-first instruction for all dispatched tasks
|
|
641
|
-
|
|
692
|
+
// Note: do NOT require dispatch_to (Bash) here — dispatched tasks run readOnly=true, Bash is blocked.
|
|
693
|
+
// Daemon sends the ack autonomously; Claude should just state its plan in the reply text.
|
|
694
|
+
prompt = `[行为要求:回复开头用1-2句「计划:xxx」说明执行方案,再开始执行。不要调用 dispatch_to,daemon 会自动转发你的回复。]\n\n${prompt}`;
|
|
642
695
|
|
|
643
696
|
// Prefer target's real Feishu chatId so dispatch reuses the existing session
|
|
644
697
|
// (--resume, no CLAUDE.md re-read, no token waste). Fall back to _agent_* virtual
|
|
645
|
-
//
|
|
646
|
-
|
|
647
|
-
const realChatId = Object.entries(feishuChatMap).find(([, v]) => v === targetProject)?.[0];
|
|
698
|
+
// All dispatches use _agent_* virtual chatId to ensure a clean session with
|
|
699
|
+
// the correct project context. Real Feishu chatIds are only for direct user messages.
|
|
648
700
|
const forceNew = !!fullMsg.new_session;
|
|
649
|
-
const dispatchChatId =
|
|
650
|
-
const sessionMode = forceNew ? 'fresh session (forced)' :
|
|
701
|
+
const dispatchChatId = `_agent_${targetProject}`;
|
|
702
|
+
const sessionMode = forceNew ? 'fresh session (forced)' : 'existing virtual session';
|
|
651
703
|
log('INFO', `Dispatching ${fullMsg.type} to ${targetProject} via ${sessionMode}: ${rawPrompt.slice(0, 80)}`);
|
|
652
704
|
|
|
653
|
-
const
|
|
705
|
+
const outputHandler = (output) => {
|
|
654
706
|
const outStr = typeof output === 'object' ? (output.body || JSON.stringify(output)) : String(output);
|
|
655
707
|
log('INFO', `Dispatch output from ${targetProject}: ${outStr.slice(0, 200)}`);
|
|
656
|
-
// Forward meaningful output back to the requester (skip typing indicators)
|
|
657
708
|
if (replyFn && outStr.trim().length > 2) {
|
|
658
709
|
replyFn(outStr);
|
|
659
710
|
} else if (!replyFn && fullMsg.callback && fullMsg.from && config) {
|
|
@@ -669,19 +720,163 @@ function dispatchTask(targetProject, message, config, replyFn) {
|
|
|
669
720
|
chain: [], // reset chain for callbacks
|
|
670
721
|
}, config);
|
|
671
722
|
}
|
|
672
|
-
}
|
|
673
|
-
//
|
|
674
|
-
|
|
723
|
+
};
|
|
724
|
+
// If streamOptions provided, use real bot so output appears in target's Feishu channel.
|
|
725
|
+
// Otherwise fall back to nullBot which captures output for replyFn.
|
|
726
|
+
const nullBot = streamOptions?.bot && streamOptions?.chatId
|
|
727
|
+
? createStreamForwardBot(streamOptions.bot, streamOptions.chatId)
|
|
728
|
+
: createNullBot(outputHandler);
|
|
729
|
+
// Permission inheritance: if daemon runs with dangerously_skip_permissions, dispatched agents
|
|
730
|
+
// inherit the same level — they need Write access for implementation tasks.
|
|
731
|
+
// Otherwise fall back to readOnly (safe default for untrusted daemon configs).
|
|
732
|
+
// When forceNew=true, clear any cached session for this virtual chatId so
|
|
733
|
+
// attachOrCreateSession in handleCommand actually creates a fresh Claude session.
|
|
734
|
+
if (forceNew) {
|
|
735
|
+
const st = loadState();
|
|
736
|
+
if (st.sessions && st.sessions[dispatchChatId]) {
|
|
737
|
+
delete st.sessions[dispatchChatId];
|
|
738
|
+
saveState(st);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
const dispatchReadOnly = !(config.daemon && config.daemon.dangerously_skip_permissions);
|
|
742
|
+
_handleCommand(nullBot, dispatchChatId, prompt, config, null, null, dispatchReadOnly).catch(e => {
|
|
675
743
|
log('ERROR', `Dispatch handleCommand failed for ${targetProject}: ${e.message}`);
|
|
676
744
|
});
|
|
677
745
|
|
|
678
746
|
return { success: true, id: fullMsg.id };
|
|
679
747
|
}
|
|
680
748
|
|
|
749
|
+
/**
|
|
750
|
+
* Spawn memory-extract.js as a detached background process.
|
|
751
|
+
* Called on sleep mode entry to consolidate session facts.
|
|
752
|
+
*/
|
|
753
|
+
/**
|
|
754
|
+
* Spawn session-summarize.js for sessions that have been idle 2-24 hours.
|
|
755
|
+
* Called on sleep mode entry. Skips sessions that already have a fresh summary.
|
|
756
|
+
*/
|
|
757
|
+
function spawnSessionSummaries() {
|
|
758
|
+
const scriptPath = path.join(__dirname, 'session-summarize.js');
|
|
759
|
+
if (!fs.existsSync(scriptPath)) return;
|
|
760
|
+
const state = loadState();
|
|
761
|
+
const now = Date.now();
|
|
762
|
+
const TWO_HOURS = 2 * 60 * 60 * 1000;
|
|
763
|
+
const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
|
|
764
|
+
for (const [cid, sess] of Object.entries(state.sessions || {})) {
|
|
765
|
+
if (!sess.id || !sess.started) continue;
|
|
766
|
+
const lastActive = sess.last_active || 0;
|
|
767
|
+
const idleMs = now - lastActive;
|
|
768
|
+
if (idleMs < TWO_HOURS || idleMs > SEVEN_DAYS) continue;
|
|
769
|
+
// Skip if summary is already newer than last activity
|
|
770
|
+
if ((sess.last_summary_at || 0) > lastActive) continue;
|
|
771
|
+
try {
|
|
772
|
+
const child = spawn(process.execPath, [scriptPath, cid, sess.id], {
|
|
773
|
+
detached: true, stdio: 'ignore',
|
|
774
|
+
});
|
|
775
|
+
child.unref();
|
|
776
|
+
log('INFO', `[DAEMON] Session summary spawned for ${cid} (idle ${Math.round(idleMs / 3600000)}h)`);
|
|
777
|
+
} catch (e) {
|
|
778
|
+
log('WARN', `[DAEMON] Failed to spawn session summary: ${e.message}`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
681
783
|
/**
|
|
682
784
|
* Physiological heartbeat: zero-token awareness check.
|
|
683
785
|
* Runs every tick unconditionally.
|
|
684
786
|
*/
|
|
787
|
+
/**
|
|
788
|
+
* Handle a single dispatch message (from socket or pending.jsonl fallback).
|
|
789
|
+
*/
|
|
790
|
+
function handleDispatchItem(item, config) {
|
|
791
|
+
if (!item.target || !item.prompt) return;
|
|
792
|
+
if (!(config && config.projects && config.projects[item.target])) {
|
|
793
|
+
log('WARN', `dispatch: unknown target "${item.target}"`);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
log('INFO', `Dispatch: ${item.from || '?'} → ${item.target}: ${item.prompt.slice(0, 60)}`);
|
|
797
|
+
let pendingReplyFn = null;
|
|
798
|
+
let streamOptions = null;
|
|
799
|
+
const liveBot = _dispatchBridgeRef && _dispatchBridgeRef.bot;
|
|
800
|
+
if (liveBot) {
|
|
801
|
+
const feishuMap = (config.feishu && config.feishu.chat_agent_map) || {};
|
|
802
|
+
const allowedFeishuIds = (config.feishu && config.feishu.allowed_chat_ids) || [];
|
|
803
|
+
const agentChatIds = new Set(Object.keys(feishuMap));
|
|
804
|
+
const targetChatId = Object.entries(feishuMap).find(([, v]) => v === item.target)?.[0] || null;
|
|
805
|
+
if (targetChatId) {
|
|
806
|
+
streamOptions = { bot: liveBot, chatId: targetChatId };
|
|
807
|
+
const ackText = `📬 **新任务**\n\n> ${item.prompt.slice(0, 120)}${item.prompt.length > 120 ? '...' : ''}`;
|
|
808
|
+
liveBot.sendMarkdown(targetChatId, ackText).catch(() =>
|
|
809
|
+
liveBot.sendMessage(targetChatId, ackText.replace(/\*\*/g, '')).catch(e =>
|
|
810
|
+
log('WARN', `Dispatch ack failed: ${e.message}`)
|
|
811
|
+
)
|
|
812
|
+
);
|
|
813
|
+
} else {
|
|
814
|
+
const _userSources = new Set(['unknown', 'claude_session', '_claude_session', 'user']);
|
|
815
|
+
let senderChatId = null;
|
|
816
|
+
if (!_userSources.has(item.from)) {
|
|
817
|
+
senderChatId = Object.entries(feishuMap).find(([, v]) => v === item.from)?.[0] || null;
|
|
818
|
+
}
|
|
819
|
+
if (!senderChatId) {
|
|
820
|
+
senderChatId = allowedFeishuIds.map(String).find(id => !agentChatIds.has(id)) || null;
|
|
821
|
+
}
|
|
822
|
+
if (senderChatId) {
|
|
823
|
+
const targetProj = (config.projects || {})[item.target] || {};
|
|
824
|
+
const ackText = `📬 已接收,转发给 ${targetProj.icon || '🤖'} **${targetProj.name || item.target}**...\n\n> ${item.prompt.slice(0, 100)}${item.prompt.length > 100 ? '...' : ''}`;
|
|
825
|
+
liveBot.sendMarkdown(senderChatId, ackText).catch(() =>
|
|
826
|
+
liveBot.sendMessage(senderChatId, ackText.replace(/\*\*/g, '')).catch(e =>
|
|
827
|
+
log('WARN', `Dispatch ack to sender failed: ${e.message}`)
|
|
828
|
+
)
|
|
829
|
+
);
|
|
830
|
+
pendingReplyFn = (output) => {
|
|
831
|
+
const text = `${targetProj.icon || '📬'} **${targetProj.name || item.target}** 回复:\n\n${output.slice(0, 2000)}`;
|
|
832
|
+
liveBot.sendMarkdown(senderChatId, text).catch(e => {
|
|
833
|
+
log('WARN', `Dispatch reply (markdown) failed: ${e.message}`);
|
|
834
|
+
liveBot.sendMessage(senderChatId, text.replace(/\*\*/g, '')).catch(e2 =>
|
|
835
|
+
log('ERROR', `Dispatch reply (text) failed: ${e2.message}`)
|
|
836
|
+
);
|
|
837
|
+
});
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
dispatchTask(item.target, {
|
|
843
|
+
from: item.from || 'claude_session',
|
|
844
|
+
type: 'task', priority: 'normal',
|
|
845
|
+
payload: { title: item.prompt.slice(0, 60), prompt: item.prompt },
|
|
846
|
+
callback: false,
|
|
847
|
+
new_session: !!item.new_session,
|
|
848
|
+
}, config, pendingReplyFn, streamOptions);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Start Unix Domain Socket server for low-latency dispatch.
|
|
853
|
+
*/
|
|
854
|
+
function startDispatchSocket(config) {
|
|
855
|
+
const net = require('net');
|
|
856
|
+
try { fs.unlinkSync(SOCK_PATH); } catch { /* ok */ }
|
|
857
|
+
const server = net.createServer((conn) => {
|
|
858
|
+
let buf = '';
|
|
859
|
+
conn.on('data', d => { buf += d; });
|
|
860
|
+
conn.on('end', () => {
|
|
861
|
+
try {
|
|
862
|
+
const item = JSON.parse(buf);
|
|
863
|
+
handleDispatchItem(item, config);
|
|
864
|
+
conn.write(JSON.stringify({ ok: true }) + '\n');
|
|
865
|
+
} catch (e) {
|
|
866
|
+
try { conn.write(JSON.stringify({ ok: false, error: e.message }) + '\n'); } catch { /* ignore */ }
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
conn.on('error', () => { /* ignore client disconnect */ });
|
|
870
|
+
});
|
|
871
|
+
server.on('error', (e) => {
|
|
872
|
+
log('WARN', `[DAEMON] Dispatch socket error: ${e.message} — file polling still active`);
|
|
873
|
+
});
|
|
874
|
+
server.listen(SOCK_PATH, () => {
|
|
875
|
+
log('INFO', `[DAEMON] Dispatch socket ready: ${SOCK_PATH}`);
|
|
876
|
+
});
|
|
877
|
+
return server;
|
|
878
|
+
}
|
|
879
|
+
|
|
685
880
|
function physiologicalHeartbeat(config) {
|
|
686
881
|
// 1. Update last_alive timestamp
|
|
687
882
|
const state = loadState();
|
|
@@ -702,38 +897,7 @@ function physiologicalHeartbeat(config) {
|
|
|
702
897
|
const items = content.split('\n').filter(Boolean)
|
|
703
898
|
.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
704
899
|
for (const item of items) {
|
|
705
|
-
|
|
706
|
-
if (!(config && config.projects && config.projects[item.target])) {
|
|
707
|
-
log('WARN', `pending dispatch: unknown target "${item.target}"`);
|
|
708
|
-
continue;
|
|
709
|
-
}
|
|
710
|
-
log('INFO', `Pending dispatch: ${item.from || '?'} → ${item.target}: ${item.prompt.slice(0, 60)}`);
|
|
711
|
-
// Build replyFn: use live bot from bridge ref (always fresh, survives reconnects)
|
|
712
|
-
let pendingReplyFn = null;
|
|
713
|
-
const liveBot = _dispatchBridgeRef && _dispatchBridgeRef.bot;
|
|
714
|
-
if (liveBot) {
|
|
715
|
-
const feishuMap = (config.feishu && config.feishu.chat_agent_map) || {};
|
|
716
|
-
const targetChatId = Object.entries(feishuMap).find(([, v]) => v === item.target)?.[0];
|
|
717
|
-
if (targetChatId) {
|
|
718
|
-
const proj = (config.projects || {})[item.target] || {};
|
|
719
|
-
pendingReplyFn = (output) => {
|
|
720
|
-
const text = `${proj.icon || '📬'} **${proj.name || item.target}**\n\n${output.slice(0, 2000)}`;
|
|
721
|
-
liveBot.sendMarkdown(targetChatId, text).catch(e => {
|
|
722
|
-
log('WARN', `Dispatch reply to ${item.target} (markdown) failed: ${e.message}`);
|
|
723
|
-
liveBot.sendMessage(targetChatId, text).catch(e2 => {
|
|
724
|
-
log('ERROR', `Dispatch reply to ${item.target} (text) failed: ${e2.message}`);
|
|
725
|
-
});
|
|
726
|
-
});
|
|
727
|
-
};
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
dispatchTask(item.target, {
|
|
731
|
-
from: item.from || 'claude_session',
|
|
732
|
-
type: 'task', priority: 'normal',
|
|
733
|
-
payload: { title: item.prompt.slice(0, 60), prompt: item.prompt },
|
|
734
|
-
callback: false,
|
|
735
|
-
new_session: !!item.new_session,
|
|
736
|
-
}, config, pendingReplyFn);
|
|
900
|
+
handleDispatchItem(item, config);
|
|
737
901
|
}
|
|
738
902
|
}
|
|
739
903
|
}
|
|
@@ -760,19 +924,41 @@ function physiologicalHeartbeat(config) {
|
|
|
760
924
|
}
|
|
761
925
|
|
|
762
926
|
// ---------------------------------------------------------
|
|
763
|
-
// HEARTBEAT
|
|
927
|
+
// HEARTBEAT TASK HELPERS (single source of truth)
|
|
764
928
|
// ---------------------------------------------------------
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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 || {})) {
|
|
770
940
|
for (const t of (proj.heartbeat_tasks || [])) {
|
|
771
|
-
if (
|
|
772
|
-
|
|
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 || '🤖' } });
|
|
773
943
|
}
|
|
774
944
|
}
|
|
775
|
-
|
|
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);
|
|
776
962
|
|
|
777
963
|
const enabledTasks = tasks.filter(t => t.enabled !== false);
|
|
778
964
|
const checkIntervalSec = (config.daemon && config.daemon.heartbeat_check_interval) || 60;
|
|
@@ -807,6 +993,18 @@ function startHeartbeat(config, notifyFn) {
|
|
|
807
993
|
// ① Physiological heartbeat (zero token, pure awareness)
|
|
808
994
|
physiologicalHeartbeat(config);
|
|
809
995
|
|
|
996
|
+
// Sleep mode detection — log transitions once
|
|
997
|
+
const idle = isUserIdle();
|
|
998
|
+
if (idle && !_inSleepMode) {
|
|
999
|
+
_inSleepMode = true;
|
|
1000
|
+
log('INFO', '[DAEMON] Entering Sleep Mode');
|
|
1001
|
+
// Generate summaries for sessions idle 2-24h
|
|
1002
|
+
spawnSessionSummaries();
|
|
1003
|
+
} else if (!idle && _inSleepMode) {
|
|
1004
|
+
_inSleepMode = false;
|
|
1005
|
+
log('INFO', '[DAEMON] Exiting Sleep Mode — local activity detected');
|
|
1006
|
+
}
|
|
1007
|
+
|
|
810
1008
|
// ② Task heartbeat (burns tokens on schedule)
|
|
811
1009
|
const currentTime = Date.now();
|
|
812
1010
|
for (const task of enabledTasks) {
|
|
@@ -814,6 +1012,12 @@ function startHeartbeat(config, notifyFn) {
|
|
|
814
1012
|
const intervalSec = parseInterval(task.interval);
|
|
815
1013
|
nextRun[task.name] = currentTime + intervalSec * 1000;
|
|
816
1014
|
|
|
1015
|
+
// Dream tasks: only run when user is idle
|
|
1016
|
+
if (task.require_idle && !isUserIdle()) {
|
|
1017
|
+
log('INFO', `[DAEMON] Deferring dream task "${task.name}" — user active`);
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
817
1021
|
if (runningTasks.has(task.name)) {
|
|
818
1022
|
log('WARN', `Task ${task.name} still running — skipping this interval`);
|
|
819
1023
|
continue;
|
|
@@ -1348,6 +1552,7 @@ async function doBindAgent(bot, chatId, agentName, agentCwd) {
|
|
|
1348
1552
|
}
|
|
1349
1553
|
|
|
1350
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)}`);
|
|
1351
1556
|
const state = loadState();
|
|
1352
1557
|
|
|
1353
1558
|
// --- /chatid: reply with current chatId ---
|
|
@@ -1522,6 +1727,56 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
1522
1727
|
return;
|
|
1523
1728
|
}
|
|
1524
1729
|
|
|
1730
|
+
// /memory [keyword] — show memory stats or search facts
|
|
1731
|
+
if (text === '/memory' || text.startsWith('/memory ')) {
|
|
1732
|
+
const query = text.startsWith('/memory ') ? text.slice(8).trim() : '';
|
|
1733
|
+
let memMod;
|
|
1734
|
+
try { memMod = require('./memory'); } catch { await bot.sendMessage(chatId, '❌ Memory module not available'); return; }
|
|
1735
|
+
|
|
1736
|
+
if (!query) {
|
|
1737
|
+
// Stats view
|
|
1738
|
+
try {
|
|
1739
|
+
const s = memMod.stats();
|
|
1740
|
+
const factCount = s.facts ?? '?';
|
|
1741
|
+
const tagFile = path.join(HOME, '.metame', 'session_tags.json');
|
|
1742
|
+
let tagCount = 0;
|
|
1743
|
+
try { tagCount = Object.keys(JSON.parse(fs.readFileSync(tagFile, 'utf8'))).length; } catch { }
|
|
1744
|
+
const lines = [
|
|
1745
|
+
`🧠 *Memory Stats*`,
|
|
1746
|
+
`━━━━━━━━━━━━━━━━`,
|
|
1747
|
+
`📌 Facts: ${factCount}`,
|
|
1748
|
+
`🏷 Sessions tagged: ${tagCount}`,
|
|
1749
|
+
`🗃 Sessions in DB: ${s.count}`,
|
|
1750
|
+
`💾 DB size: ${s.dbSizeKB} KB`,
|
|
1751
|
+
s.newestDate ? `🕐 Last updated: ${new Date(s.newestDate).toLocaleDateString()}` : '',
|
|
1752
|
+
``,
|
|
1753
|
+
`搜索: /memory <关键词>`,
|
|
1754
|
+
].filter(l => l !== undefined && !(l === '' && false));
|
|
1755
|
+
await bot.sendMessage(chatId, lines.join('\n'));
|
|
1756
|
+
} catch (e) {
|
|
1757
|
+
await bot.sendMessage(chatId, `❌ Memory stats error: ${e.message}`);
|
|
1758
|
+
}
|
|
1759
|
+
} else {
|
|
1760
|
+
// Search facts
|
|
1761
|
+
try {
|
|
1762
|
+
const results = await memMod.searchFactsAsync(query, { limit: 5 });
|
|
1763
|
+
if (!results || results.length === 0) {
|
|
1764
|
+
await bot.sendMessage(chatId, `🔍 No facts found for「${query}」`);
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
let msg = `🔍 *Facts: "${query}"* (${results.length})\n━━━━━━━━━━━━━━━━\n`;
|
|
1768
|
+
for (const r of results) {
|
|
1769
|
+
const tag = r.confidence === 'high' ? '🟢' : '🟡';
|
|
1770
|
+
msg += `${tag} *${r.entity}*\n${r.value}\n\n`;
|
|
1771
|
+
}
|
|
1772
|
+
await bot.sendMessage(chatId, msg.trim());
|
|
1773
|
+
} catch (e) {
|
|
1774
|
+
await bot.sendMessage(chatId, `❌ Search error: ${e.message}`);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1525
1780
|
// /sessions — compact list, tap to see details, then tap to switch
|
|
1526
1781
|
if (text === '/sessions') {
|
|
1527
1782
|
const allSessions = listRecentSessions(15);
|
|
@@ -1530,11 +1785,12 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
1530
1785
|
return;
|
|
1531
1786
|
}
|
|
1532
1787
|
if (bot.sendButtons) {
|
|
1533
|
-
await bot.
|
|
1788
|
+
await bot.sendRawCard(chatId, '📋 Recent Sessions', buildSessionCardElements(allSessions));
|
|
1534
1789
|
} else {
|
|
1790
|
+
const _tags1 = loadSessionTags();
|
|
1535
1791
|
let msg = '📋 Recent sessions:\n\n';
|
|
1536
1792
|
allSessions.forEach((s, i) => {
|
|
1537
|
-
msg += sessionRichLabel(s, i + 1) + '\n';
|
|
1793
|
+
msg += sessionRichLabel(s, i + 1, _tags1) + '\n';
|
|
1538
1794
|
});
|
|
1539
1795
|
await bot.sendMessage(chatId, msg);
|
|
1540
1796
|
}
|
|
@@ -1555,7 +1811,11 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
1555
1811
|
const realMtime = getSessionFileMtime(s.sessionId, s.projectPath);
|
|
1556
1812
|
const timeMs = realMtime || s.fileMtime || new Date(s.modified).getTime();
|
|
1557
1813
|
const ago = formatRelativeTime(new Date(timeMs).toISOString());
|
|
1558
|
-
const
|
|
1814
|
+
const sessionTags = loadSessionTags();
|
|
1815
|
+
const tagEntry = sessionTags[s.sessionId] || {};
|
|
1816
|
+
const tagName = tagEntry.name || '';
|
|
1817
|
+
const tags = (tagEntry.tags || []).slice(0, 5);
|
|
1818
|
+
const title = s.customTitle || tagName || '';
|
|
1559
1819
|
const summary = s.summary || '';
|
|
1560
1820
|
const firstMsg = (s.firstPrompt || '').replace(/^<[^>]+>.*?<\/[^>]+>\s*/s, '');
|
|
1561
1821
|
const msgs = s.messageCount || '?';
|
|
@@ -1563,6 +1823,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
1563
1823
|
let detail = `📋 Session Detail\n`;
|
|
1564
1824
|
detail += `━━━━━━━━━━━━━━━━━━━━\n`;
|
|
1565
1825
|
if (title) detail += `📝 Title: ${title}\n`;
|
|
1826
|
+
if (tags.length) detail += `🏷 Tags: ${tags.map(t => '#' + t).join(' ')}\n`;
|
|
1566
1827
|
if (summary) detail += `💡 Summary: ${summary}\n`;
|
|
1567
1828
|
detail += `📁 Project: ${projName}\n`;
|
|
1568
1829
|
detail += `📂 Path: ${proj}\n`;
|
|
@@ -1575,6 +1836,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
1575
1836
|
// Build rich detail as markdown body + buttons
|
|
1576
1837
|
let body = '';
|
|
1577
1838
|
if (title) body += `**📝 ${title}**\n`;
|
|
1839
|
+
if (tags.length) body += `${tags.map(t => `\`${t}\``).join(' ')}\n`;
|
|
1578
1840
|
if (summary) body += `💡 ${summary}\n`;
|
|
1579
1841
|
body += `📁 ${projName} · 📂 ${proj}\n`;
|
|
1580
1842
|
body += `💬 ${msgs} messages · 🕐 ${ago}\n`;
|
|
@@ -1583,12 +1845,14 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
1583
1845
|
const elements = [
|
|
1584
1846
|
{ tag: 'div', text: { tag: 'lark_md', content: body } },
|
|
1585
1847
|
{ tag: 'hr' },
|
|
1586
|
-
{
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1848
|
+
{
|
|
1849
|
+
tag: 'action', actions: [
|
|
1850
|
+
{ tag: 'button', text: { tag: 'plain_text', content: '▶️ Switch to this session' }, type: 'primary', value: { cmd: `/resume ${s.sessionId}` } },
|
|
1851
|
+
{ tag: 'button', text: { tag: 'plain_text', content: '⬅️ Back to list' }, type: 'default', value: { cmd: '/sessions' } },
|
|
1852
|
+
]
|
|
1853
|
+
},
|
|
1590
1854
|
];
|
|
1591
|
-
await bot.
|
|
1855
|
+
await bot.sendRawCard(chatId, '📋 Session Detail', elements);
|
|
1592
1856
|
} else if (bot.sendButtons) {
|
|
1593
1857
|
await bot.sendButtons(chatId, detail, [
|
|
1594
1858
|
[{ text: '▶️ Switch to this session', callback_data: `/resume ${s.sessionId}` }],
|
|
@@ -1614,17 +1878,18 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
1614
1878
|
return;
|
|
1615
1879
|
}
|
|
1616
1880
|
const headerTitle = curCwd ? `📋 Sessions in ${path.basename(curCwd)}` : '📋 Recent Sessions';
|
|
1617
|
-
if (bot.
|
|
1618
|
-
await bot.
|
|
1881
|
+
if (bot.sendRawCard) {
|
|
1882
|
+
await bot.sendRawCard(chatId, headerTitle, buildSessionCardElements(recentSessions));
|
|
1619
1883
|
} else if (bot.sendButtons) {
|
|
1620
1884
|
const buttons = recentSessions.map(s => {
|
|
1621
1885
|
return [{ text: sessionLabel(s), callback_data: `/resume ${s.sessionId}` }];
|
|
1622
1886
|
});
|
|
1623
1887
|
await bot.sendButtons(chatId, headerTitle, buttons);
|
|
1624
1888
|
} else {
|
|
1889
|
+
const _tags2 = loadSessionTags();
|
|
1625
1890
|
let msg = `${title}\n\n`;
|
|
1626
1891
|
recentSessions.forEach((s, i) => {
|
|
1627
|
-
msg += sessionRichLabel(s, i + 1) + '\n';
|
|
1892
|
+
msg += sessionRichLabel(s, i + 1, _tags2) + '\n';
|
|
1628
1893
|
});
|
|
1629
1894
|
await bot.sendMessage(chatId, msg);
|
|
1630
1895
|
}
|
|
@@ -2030,22 +2295,25 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
2030
2295
|
}
|
|
2031
2296
|
|
|
2032
2297
|
if (text === '/tasks') {
|
|
2298
|
+
const { general, project } = getAllTasks(config);
|
|
2033
2299
|
let msg = '';
|
|
2034
|
-
|
|
2035
|
-
const legacyTasks = (config.heartbeat && config.heartbeat.tasks) || [];
|
|
2036
|
-
if (legacyTasks.length > 0) {
|
|
2300
|
+
if (general.length > 0) {
|
|
2037
2301
|
msg += '📋 General:\n';
|
|
2038
|
-
for (const t of
|
|
2302
|
+
for (const t of general) {
|
|
2039
2303
|
const ts = state.tasks[t.name] || {};
|
|
2040
2304
|
msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
|
|
2041
2305
|
}
|
|
2042
2306
|
}
|
|
2043
|
-
// Project tasks grouped
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
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) {
|
|
2049
2317
|
const ts = state.tasks[t.name] || {};
|
|
2050
2318
|
msg += `${t.enabled !== false ? '✅' : '⏸'} ${t.name} (${t.interval}) ${ts.status || 'never_run'}\n`;
|
|
2051
2319
|
}
|
|
@@ -2124,16 +2392,15 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
2124
2392
|
const projInfo = config.projects[targetKey] || {};
|
|
2125
2393
|
// Find the target project's own Feishu chat (reverse lookup of chat_agent_map)
|
|
2126
2394
|
const feishuChatAgentMap = (config.feishu && config.feishu.chat_agent_map) || {};
|
|
2127
|
-
const targetChatId = Object.entries(feishuChatAgentMap).find(([, v]) => v === targetKey)?.[0] ||
|
|
2128
|
-
|
|
2129
|
-
|
|
2395
|
+
const targetChatId = Object.entries(feishuChatAgentMap).find(([, v]) => v === targetKey)?.[0] || null;
|
|
2396
|
+
// Stream work directly to target's channel if available; otherwise fallback replyFn
|
|
2397
|
+
const dispatchStreamOptions = targetChatId ? { bot, chatId: targetChatId } : null;
|
|
2398
|
+
const replyFn = targetChatId ? null : (output) => {
|
|
2130
2399
|
const text = `${projInfo.icon || '📬'} **${projInfo.name || targetKey}**\n\n${output.slice(0, 2000)}`;
|
|
2131
|
-
bot.sendMarkdown(
|
|
2132
|
-
.then(() => log('INFO', `Dispatch reply sent to ${targetChatId}`))
|
|
2400
|
+
bot.sendMarkdown(chatId, text)
|
|
2133
2401
|
.catch(e => {
|
|
2134
2402
|
log('WARN', `Dispatch sendMarkdown failed: ${e.message}, trying sendMessage`);
|
|
2135
|
-
bot.sendMessage(
|
|
2136
|
-
.catch(e2 => log('ERROR', `Dispatch reply failed: ${e2.message}`));
|
|
2403
|
+
bot.sendMessage(chatId, text).catch(e2 => log('ERROR', `Dispatch reply failed: ${e2.message}`));
|
|
2137
2404
|
});
|
|
2138
2405
|
};
|
|
2139
2406
|
|
|
@@ -2143,7 +2410,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
2143
2410
|
priority: 'normal',
|
|
2144
2411
|
payload: { title: prompt.slice(0, 60), prompt },
|
|
2145
2412
|
callback: false,
|
|
2146
|
-
}, config, replyFn);
|
|
2413
|
+
}, config, replyFn, dispatchStreamOptions);
|
|
2147
2414
|
|
|
2148
2415
|
if (result.success) {
|
|
2149
2416
|
await bot.sendMessage(chatId, `✅ 已派发给 ${projInfo.name || targetName},执行中…`);
|
|
@@ -2165,13 +2432,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
2165
2432
|
return;
|
|
2166
2433
|
}
|
|
2167
2434
|
const taskName = text.slice(5).trim();
|
|
2168
|
-
const
|
|
2169
|
-
for (const [key, proj] of Object.entries(config.projects || {})) {
|
|
2170
|
-
for (const t of (proj.heartbeat_tasks || [])) {
|
|
2171
|
-
allRunTasks.push({ ...t, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } });
|
|
2172
|
-
}
|
|
2173
|
-
}
|
|
2174
|
-
const task = allRunTasks.find(t => t.name === taskName);
|
|
2435
|
+
const task = findTask(config, taskName);
|
|
2175
2436
|
if (!task) { await bot.sendMessage(chatId, `❌ Task "${taskName}" not found`); return; }
|
|
2176
2437
|
|
|
2177
2438
|
// Script tasks: quick, run inline
|
|
@@ -2438,7 +2699,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
2438
2699
|
}
|
|
2439
2700
|
|
|
2440
2701
|
const session = getSession(chatId);
|
|
2441
|
-
if (!session || !session.id
|
|
2702
|
+
if (!session || !session.id) {
|
|
2442
2703
|
await bot.sendMessage(chatId, 'No active session to undo.');
|
|
2443
2704
|
return;
|
|
2444
2705
|
}
|
|
@@ -2446,107 +2707,190 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
2446
2707
|
const cwd = session.cwd;
|
|
2447
2708
|
const arg = text.slice(5).trim();
|
|
2448
2709
|
|
|
2449
|
-
//
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
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
|
+
}
|
|
2460
2740
|
return;
|
|
2461
2741
|
}
|
|
2462
2742
|
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
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();
|
|
2466
2787
|
if (bot.sendButtons) {
|
|
2467
|
-
const buttons = recent.map((
|
|
2468
|
-
const
|
|
2469
|
-
|
|
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}` }];
|
|
2470
2796
|
});
|
|
2471
|
-
await bot.sendButtons(chatId,
|
|
2797
|
+
await bot.sendButtons(chatId, `↩️ 回退到哪条消息之前?(共 ${userMsgs.length} 轮)`, buttons);
|
|
2472
2798
|
} else {
|
|
2473
|
-
let msg = '
|
|
2474
|
-
recent.forEach(
|
|
2475
|
-
msg +=
|
|
2799
|
+
let msg = '回退到哪条消息之前?回复 /undo_to <序号>\n\n';
|
|
2800
|
+
recent.forEach(({ idx, text }) => {
|
|
2801
|
+
msg += `[${idx}] ${text.slice(0, 40)}\n`;
|
|
2476
2802
|
});
|
|
2477
2803
|
await bot.sendMessage(chatId, msg);
|
|
2478
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.');
|
|
2479
2834
|
return;
|
|
2480
2835
|
}
|
|
2481
2836
|
|
|
2482
|
-
// /undo <hash> — execute git reset
|
|
2483
2837
|
try {
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
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');
|
|
2488
2844
|
return;
|
|
2489
2845
|
}
|
|
2490
2846
|
|
|
2491
|
-
// Get
|
|
2492
|
-
let
|
|
2847
|
+
// Get target message text + timestamp for display and git matching
|
|
2848
|
+
let targetMsg = '', targetTs = 0;
|
|
2493
2849
|
try {
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
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 { }
|
|
2499
2856
|
|
|
2500
|
-
//
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
//
|
|
2508
|
-
const
|
|
2509
|
-
const
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
break; // Found a message before checkpoint, stop
|
|
2522
|
-
}
|
|
2523
|
-
}
|
|
2524
|
-
} catch { }
|
|
2525
|
-
}
|
|
2526
|
-
if (cutIdx > 0) {
|
|
2527
|
-
const kept = lines.slice(0, cutIdx);
|
|
2528
|
-
fs.writeFileSync(sessionFile, kept.join('\n') + '\n', 'utf8');
|
|
2529
|
-
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);
|
|
2530
2878
|
}
|
|
2531
2879
|
}
|
|
2532
2880
|
}
|
|
2533
|
-
} catch (truncErr) {
|
|
2534
|
-
log('WARN', `Session truncation failed (non-fatal): ${truncErr.message}`);
|
|
2535
2881
|
}
|
|
2536
2882
|
|
|
2537
|
-
|
|
2538
|
-
const
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
msg += `\n📁 ${fileCount} 个文件恢复: ${fileList}`;
|
|
2543
|
-
}
|
|
2544
|
-
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;
|
|
2545
2888
|
|
|
2546
|
-
|
|
2547
|
-
|
|
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}`);
|
|
2548
2892
|
} catch (e) {
|
|
2549
|
-
await bot.sendMessage(chatId, `❌
|
|
2893
|
+
await bot.sendMessage(chatId, `❌ 回退失败: ${e.message}`);
|
|
2550
2894
|
}
|
|
2551
2895
|
return;
|
|
2552
2896
|
}
|
|
@@ -2778,10 +3122,12 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
|
|
|
2778
3122
|
'/cd <path> — 切换工作目录',
|
|
2779
3123
|
'/session — 查看当前会话',
|
|
2780
3124
|
'/stop — 中断当前任务 (ESC)',
|
|
2781
|
-
'/undo —
|
|
3125
|
+
'/undo — 选择历史消息,点击回退到该条之前',
|
|
3126
|
+
'/undo <hash> — 回退到指定 git checkpoint',
|
|
2782
3127
|
'/quit — 结束会话,重新加载 MCP/配置',
|
|
2783
3128
|
'',
|
|
2784
3129
|
`⚙️ /model [${currentModel}] /provider [${currentProvider}] /status /tasks /run /budget /reload`,
|
|
3130
|
+
`🧠 /memory — 记忆统计 · /memory <关键词> — 搜索事实`,
|
|
2785
3131
|
`🔧 /doctor /fix /reset /sh <cmd> /nosleep [${caffeinateProcess ? 'ON' : 'OFF'}]`,
|
|
2786
3132
|
'',
|
|
2787
3133
|
'直接打字即可对话 💬',
|
|
@@ -2878,6 +3224,80 @@ function findSessionFile(sessionId) {
|
|
|
2878
3224
|
return null;
|
|
2879
3225
|
}
|
|
2880
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
|
+
|
|
2881
3301
|
/**
|
|
2882
3302
|
* Scan all project session indexes, return most recent N sessions.
|
|
2883
3303
|
* Results cached for 10 seconds to avoid repeated directory scans.
|
|
@@ -3014,6 +3434,13 @@ function listRecentSessions(limit, cwd) {
|
|
|
3014
3434
|
return all.slice(0, limit || 10);
|
|
3015
3435
|
}
|
|
3016
3436
|
|
|
3437
|
+
/** Load session_tags.json — returns {} if missing or malformed */
|
|
3438
|
+
function loadSessionTags() {
|
|
3439
|
+
try {
|
|
3440
|
+
return JSON.parse(fs.readFileSync(path.join(HOME, '.metame', 'session_tags.json'), 'utf8'));
|
|
3441
|
+
} catch { return {}; }
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3017
3444
|
/**
|
|
3018
3445
|
* Get the actual file mtime of a session's .jsonl file (most accurate)
|
|
3019
3446
|
*/
|
|
@@ -3062,7 +3489,7 @@ function sessionLabel(s) {
|
|
|
3062
3489
|
/**
|
|
3063
3490
|
* Get the display title for a session using fallback chain: name → summary → firstPrompt
|
|
3064
3491
|
*/
|
|
3065
|
-
function sessionDisplayTitle(s, maxLen) {
|
|
3492
|
+
function sessionDisplayTitle(s, maxLen, sessionTags) {
|
|
3066
3493
|
maxLen = maxLen || 50;
|
|
3067
3494
|
// Newlines → space; strip null bytes, surrogates, replacement char, other non-printable control chars
|
|
3068
3495
|
const sanitize = (t) => t
|
|
@@ -3070,18 +3497,24 @@ function sessionDisplayTitle(s, maxLen) {
|
|
|
3070
3497
|
.replace(/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F\uFFFD\uD800-\uDFFF]/g, '')
|
|
3071
3498
|
.replace(/\s+/g, ' ')
|
|
3072
3499
|
.trim();
|
|
3500
|
+
// Priority: user name > our P2-A name > user's first message > Claude native summary
|
|
3501
|
+
// firstPrompt is authentic user content; s.summary is Claude's auto-generated index field (unreliable)
|
|
3073
3502
|
if (s.customTitle) return sanitize(s.customTitle).slice(0, maxLen);
|
|
3074
|
-
|
|
3503
|
+
// P2-A: use Haiku-generated session name (only if memory-extract has processed this session)
|
|
3504
|
+
const tagEntry = sessionTags && sessionTags[s.sessionId];
|
|
3505
|
+
if (tagEntry && tagEntry.name) return sanitize(tagEntry.name).slice(0, maxLen);
|
|
3506
|
+
// Not yet processed by P2-A: show the user's actual first message
|
|
3075
3507
|
if (s.firstPrompt) {
|
|
3076
3508
|
const clean = s.firstPrompt
|
|
3077
3509
|
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
|
|
3078
3510
|
.replace(/^<[^>]+>.*?<\/[^>]+>\s*/s, '')
|
|
3079
3511
|
.replace(/\[System hints[\s\S]*/i, '');
|
|
3080
|
-
// Take first non-empty line after stripping noise
|
|
3081
3512
|
const firstLine = clean.split('\n').map(l => l.trim()).find(l => l.length > 2) || '';
|
|
3082
3513
|
const sanitized = sanitize(firstLine);
|
|
3083
3514
|
if (sanitized && sanitized.length > 2) return sanitized.slice(0, maxLen);
|
|
3084
3515
|
}
|
|
3516
|
+
// Last resort: Claude's native auto-summary from sessions-index.json
|
|
3517
|
+
if (s.summary) return sanitize(s.summary).slice(0, maxLen);
|
|
3085
3518
|
return '';
|
|
3086
3519
|
}
|
|
3087
3520
|
|
|
@@ -3089,17 +3522,20 @@ function sessionDisplayTitle(s, maxLen) {
|
|
|
3089
3522
|
* Format a session entry into a rich text block for non-button contexts (Feishu text).
|
|
3090
3523
|
* Shows: name, title/summary, project, time, and /resume shortcut.
|
|
3091
3524
|
*/
|
|
3092
|
-
function sessionRichLabel(s, index) {
|
|
3093
|
-
|
|
3525
|
+
function sessionRichLabel(s, index, sessionTags) {
|
|
3526
|
+
sessionTags = sessionTags || loadSessionTags();
|
|
3527
|
+
const title = sessionDisplayTitle(s, 50, sessionTags);
|
|
3094
3528
|
const proj = s.projectPath ? path.basename(s.projectPath) : '~';
|
|
3095
3529
|
const realMtime = getSessionFileMtime(s.sessionId, s.projectPath);
|
|
3096
3530
|
const timeMs = realMtime || s.fileMtime || new Date(s.modified).getTime();
|
|
3097
3531
|
const ago = formatRelativeTime(new Date(timeMs).toISOString());
|
|
3098
3532
|
const shortId = s.sessionId.slice(0, 8);
|
|
3533
|
+
const tags = (sessionTags[s.sessionId] && sessionTags[s.sessionId].tags || []).slice(0, 3);
|
|
3099
3534
|
|
|
3100
3535
|
let line = `${index}. `;
|
|
3101
3536
|
if (title) line += `${title}${title.length >= 50 ? '..' : ''}`;
|
|
3102
3537
|
else line += `(unnamed)`;
|
|
3538
|
+
if (tags.length) line += ` ${tags.map(t => `#${t}`).join(' ')}`;
|
|
3103
3539
|
line += `\n 📁${proj} · ${ago}`;
|
|
3104
3540
|
line += `\n /resume ${shortId}`;
|
|
3105
3541
|
return line;
|
|
@@ -3109,16 +3545,19 @@ function sessionRichLabel(s, index) {
|
|
|
3109
3545
|
* Build Feishu card elements for a list of sessions (used by /sessions and /resume)
|
|
3110
3546
|
*/
|
|
3111
3547
|
function buildSessionCardElements(sessions) {
|
|
3548
|
+
const sessionTags = loadSessionTags();
|
|
3112
3549
|
const elements = [];
|
|
3113
3550
|
sessions.forEach((s, i) => {
|
|
3114
3551
|
if (i > 0) elements.push({ tag: 'hr' });
|
|
3115
|
-
const title = sessionDisplayTitle(s, 60);
|
|
3552
|
+
const title = sessionDisplayTitle(s, 60, sessionTags);
|
|
3116
3553
|
const proj = s.projectPath ? path.basename(s.projectPath) : '~';
|
|
3117
3554
|
const realMtime = getSessionFileMtime(s.sessionId, s.projectPath);
|
|
3118
3555
|
const timeMs = realMtime || s.fileMtime || new Date(s.modified).getTime();
|
|
3119
3556
|
const ago = formatRelativeTime(new Date(timeMs).toISOString());
|
|
3120
3557
|
const shortId = s.sessionId.slice(0, 6);
|
|
3558
|
+
const tags = (sessionTags[s.sessionId] && sessionTags[s.sessionId].tags || []).slice(0, 4);
|
|
3121
3559
|
let desc = `**${i + 1}. ${title || '(unnamed)'}**\n📁${proj} · ${ago}`;
|
|
3560
|
+
if (tags.length) desc += `\n${tags.map(t => `\`${t}\``).join(' ')}`;
|
|
3122
3561
|
elements.push({ tag: 'div', text: { tag: 'lark_md', content: desc } });
|
|
3123
3562
|
elements.push({ tag: 'action', actions: [{ tag: 'button', text: { tag: 'plain_text', content: `▶️ Switch #${shortId}` }, type: 'primary', value: { cmd: `/resume ${s.sessionId}` } }] });
|
|
3124
3563
|
});
|
|
@@ -3340,6 +3779,33 @@ const CONTENT_EXTENSIONS = new Set([
|
|
|
3340
3779
|
// Active Claude processes per chat (for /stop)
|
|
3341
3780
|
const activeProcesses = new Map(); // chatId -> { child, aborted }
|
|
3342
3781
|
|
|
3782
|
+
// Activity tracking for idle/sleep detection
|
|
3783
|
+
let lastInteractionTime = Date.now(); // updated on every incoming message
|
|
3784
|
+
let _inSleepMode = false; // tracks current sleep state for log transitions
|
|
3785
|
+
|
|
3786
|
+
const IDLE_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
|
|
3787
|
+
const LOCAL_ACTIVE_FILE = path.join(METAME_DIR, 'local_active');
|
|
3788
|
+
|
|
3789
|
+
/**
|
|
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).
|
|
3793
|
+
* Dream tasks (require_idle: true) only execute in this state.
|
|
3794
|
+
*/
|
|
3795
|
+
function isUserIdle() {
|
|
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;
|
|
3807
|
+
}
|
|
3808
|
+
|
|
3343
3809
|
// Fix3: persist child PIDs so next daemon startup can kill orphans
|
|
3344
3810
|
const ACTIVE_PIDS_FILE = path.join(HOME, '.metame', 'active_claude_pids.json');
|
|
3345
3811
|
function saveActivePids() {
|
|
@@ -3736,24 +4202,6 @@ function trackMsgSession(messageId, session) {
|
|
|
3736
4202
|
saveState(st);
|
|
3737
4203
|
}
|
|
3738
4204
|
|
|
3739
|
-
function lazyDistill() {
|
|
3740
|
-
const now = Date.now();
|
|
3741
|
-
const st = loadState();
|
|
3742
|
-
const lastDistillTime = st.last_distill_time || 0;
|
|
3743
|
-
if (now - lastDistillTime < 4 * 60 * 60 * 1000) return; // 4h cooldown
|
|
3744
|
-
const distillPath = path.join(HOME, '.metame', 'distill.js');
|
|
3745
|
-
const signalsPath = path.join(HOME, '.metame', 'raw_signals.jsonl');
|
|
3746
|
-
if (!fs.existsSync(distillPath)) return;
|
|
3747
|
-
if (!fs.existsSync(signalsPath)) return;
|
|
3748
|
-
const content = fs.readFileSync(signalsPath, 'utf8').trim();
|
|
3749
|
-
if (!content) return;
|
|
3750
|
-
st.last_distill_time = now;
|
|
3751
|
-
saveState(st);
|
|
3752
|
-
const lines = content.split('\n').filter(l => l.trim()).length;
|
|
3753
|
-
log('INFO', `Distilling ${lines} signal(s) in background...`);
|
|
3754
|
-
const bg = spawn('node', [distillPath], { detached: true, stdio: 'ignore' });
|
|
3755
|
-
bg.unref();
|
|
3756
|
-
}
|
|
3757
4205
|
|
|
3758
4206
|
/**
|
|
3759
4207
|
* Shared ask logic — full Claude Code session (stateful, with tools)
|
|
@@ -3761,8 +4209,20 @@ function lazyDistill() {
|
|
|
3761
4209
|
*/
|
|
3762
4210
|
async function askClaude(bot, chatId, prompt, config, readOnly = false) {
|
|
3763
4211
|
log('INFO', `askClaude for ${chatId}: ${prompt.slice(0, 50)}`);
|
|
3764
|
-
//
|
|
3765
|
-
|
|
4212
|
+
// Track interaction time for idle/sleep detection
|
|
4213
|
+
lastInteractionTime = Date.now();
|
|
4214
|
+
// Track per-session last_active for summary generation (P2-B)
|
|
4215
|
+
try {
|
|
4216
|
+
const _st = loadState();
|
|
4217
|
+
if (_st.sessions && _st.sessions[chatId]) {
|
|
4218
|
+
_st.sessions[chatId].last_active = Date.now();
|
|
4219
|
+
saveState(_st);
|
|
4220
|
+
}
|
|
4221
|
+
} catch { /* non-critical */ }
|
|
4222
|
+
if (_inSleepMode) {
|
|
4223
|
+
_inSleepMode = false;
|
|
4224
|
+
log('INFO', '[DAEMON] Exiting Sleep Mode — user active');
|
|
4225
|
+
}
|
|
3766
4226
|
// Send a single status message, updated in-place, deleted on completion
|
|
3767
4227
|
let statusMsgId = null;
|
|
3768
4228
|
try {
|
|
@@ -3864,6 +4324,42 @@ async function askClaude(bot, chatId, prompt, config, readOnly = false) {
|
|
|
3864
4324
|
args.push('--session-id', session.id);
|
|
3865
4325
|
}
|
|
3866
4326
|
|
|
4327
|
+
// Memory & Knowledge Injection (RAG)
|
|
4328
|
+
let memoryHint = '';
|
|
4329
|
+
try {
|
|
4330
|
+
const memory = require('./memory');
|
|
4331
|
+
const _cid = String(chatId);
|
|
4332
|
+
const _cfg = loadConfig();
|
|
4333
|
+
const _agentMap = { ...(_cfg.telegram ? _cfg.telegram.chat_agent_map : {}), ...(_cfg.feishu ? _cfg.feishu.chat_agent_map : {}) };
|
|
4334
|
+
const projectKey = _agentMap[_cid] || (_cid.startsWith('_agent_') ? _cid.slice(7) : null);
|
|
4335
|
+
|
|
4336
|
+
// 1. Inject recent session memories ONLY on first message of a session
|
|
4337
|
+
if (!session.started) {
|
|
4338
|
+
const recent = memory.recentSessions({ limit: 3, project: projectKey || undefined });
|
|
4339
|
+
if (recent.length > 0) {
|
|
4340
|
+
const items = recent.map(r => `- [${r.created_at}] ${r.summary}${r.keywords ? ' (keywords: ' + r.keywords + ')' : ''}`).join('\n');
|
|
4341
|
+
memoryHint += `\n\n<!-- MEMORY:START -->\n[Session memory - recent context from past sessions, use to inform your responses:\n${items}]\n<!-- MEMORY:END -->`;
|
|
4342
|
+
}
|
|
4343
|
+
}
|
|
4344
|
+
|
|
4345
|
+
// 2. Dynamic Fact Injection (RAG) — first message only
|
|
4346
|
+
// Facts stay in Claude's context for the rest of the session; no need to repeat.
|
|
4347
|
+
// Uses QMD hybrid search if available, falls back to FTS5.
|
|
4348
|
+
if (!session.started) {
|
|
4349
|
+
const searchFn = memory.searchFactsAsync || memory.searchFacts;
|
|
4350
|
+
const facts = await Promise.resolve(searchFn(prompt, { limit: 5, project: projectKey || undefined }));
|
|
4351
|
+
if (facts.length > 0) {
|
|
4352
|
+
const factItems = facts.map(f => `- [${f.relation}] ${f.value}`).join('\n');
|
|
4353
|
+
memoryHint += `\n\n<!-- FACTS:START -->\n[Relevant knowledge and user preferences retrieved for this query. Follow these constraints implicitly:\n${factItems}]\n<!-- FACTS:END -->`;
|
|
4354
|
+
log('INFO', `[MEMORY] Injected ${facts.length} facts based on prompt`);
|
|
4355
|
+
}
|
|
4356
|
+
}
|
|
4357
|
+
|
|
4358
|
+
memory.close();
|
|
4359
|
+
} catch (e) {
|
|
4360
|
+
if (e.code !== 'MODULE_NOT_FOUND') log('WARN', `Memory injection failed: ${e.message}`);
|
|
4361
|
+
}
|
|
4362
|
+
|
|
3867
4363
|
// Inject daemon hints only on first message of a session
|
|
3868
4364
|
const daemonHint = !session.started ? `\n\n[System hints - DO NOT mention these to user:
|
|
3869
4365
|
1. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
|
|
@@ -3875,7 +4371,27 @@ async function askClaude(bot, chatId, prompt, config, readOnly = false) {
|
|
|
3875
4371
|
- Multiple files: use multiple [[FILE:...]] tags]` : '';
|
|
3876
4372
|
|
|
3877
4373
|
const routedPrompt = skill ? `/${skill} ${prompt}` : prompt;
|
|
3878
|
-
|
|
4374
|
+
|
|
4375
|
+
// P2-B: inject session summary when resuming after a 2h+ gap
|
|
4376
|
+
let summaryHint = '';
|
|
4377
|
+
if (session.started) {
|
|
4378
|
+
try {
|
|
4379
|
+
const _stSum = loadState();
|
|
4380
|
+
const _sess = _stSum.sessions && _stSum.sessions[chatId];
|
|
4381
|
+
if (_sess && _sess.last_summary && _sess.last_summary_at) {
|
|
4382
|
+
const _idleMs = Date.now() - (_sess.last_active || 0);
|
|
4383
|
+
const _summaryAgeH = (Date.now() - _sess.last_summary_at) / 3600000;
|
|
4384
|
+
if (_idleMs > 2 * 60 * 60 * 1000 && _summaryAgeH < 168) {
|
|
4385
|
+
summaryHint = `
|
|
4386
|
+
|
|
4387
|
+
[上次对话摘要,供参考]: ${_sess.last_summary}`;
|
|
4388
|
+
log('INFO', `[DAEMON] Injected session summary for ${chatId} (idle ${Math.round(_idleMs / 3600000)}h)`);
|
|
4389
|
+
}
|
|
4390
|
+
}
|
|
4391
|
+
} catch { /* non-critical */ }
|
|
4392
|
+
}
|
|
4393
|
+
|
|
4394
|
+
const fullPrompt = routedPrompt + daemonHint + summaryHint + memoryHint;
|
|
3879
4395
|
|
|
3880
4396
|
// Git checkpoint before Claude modifies files (for /undo)
|
|
3881
4397
|
// Pass the user prompt as label so checkpoint list is human-readable
|
|
@@ -4248,6 +4764,29 @@ async function main() {
|
|
|
4248
4764
|
|
|
4249
4765
|
log('INFO', `MetaMe daemon started (PID: ${process.pid})`);
|
|
4250
4766
|
killOrphanPids(); // Fix3: kill any claude processes left by previous daemon
|
|
4767
|
+
|
|
4768
|
+
// Pre-initialize memory DB at startup so the file exists before any agent session needs it.
|
|
4769
|
+
// This prevents Claude Code from showing a "new file" permission dialog mid-task on the desktop.
|
|
4770
|
+
try {
|
|
4771
|
+
const memMod = require('./memory');
|
|
4772
|
+
memMod.stats(); // triggers DB + schema creation
|
|
4773
|
+
memMod.close();
|
|
4774
|
+
log('INFO', `Memory DB ready: ${memMod.DB_PATH}`);
|
|
4775
|
+
} catch (e) {
|
|
4776
|
+
log('WARN', `Memory DB pre-init failed (non-fatal, will retry on first use): ${e.message}`);
|
|
4777
|
+
}
|
|
4778
|
+
|
|
4779
|
+
// Start QMD semantic search daemon if available (optional, non-fatal)
|
|
4780
|
+
try {
|
|
4781
|
+
const qmd = require('./qmd-client');
|
|
4782
|
+
if (qmd.isAvailable()) {
|
|
4783
|
+
qmd.ensureCollection();
|
|
4784
|
+
qmd.startDaemon().then(running => {
|
|
4785
|
+
if (running) log('INFO', '[QMD] Semantic search daemon started (localhost:8181)');
|
|
4786
|
+
else log('INFO', '[QMD] Available but daemon not started — will use CLI fallback');
|
|
4787
|
+
}).catch(() => { });
|
|
4788
|
+
}
|
|
4789
|
+
} catch { /* qmd-client not available, skip */ }
|
|
4251
4790
|
// Hourly heartbeat so daemon.log stays fresh even when idle (visible aliveness check)
|
|
4252
4791
|
setInterval(() => {
|
|
4253
4792
|
log('INFO', `Daemon heartbeat — uptime: ${Math.round(process.uptime() / 60)}m, active sessions: ${activeProcesses.size}`);
|
|
@@ -4255,14 +4794,7 @@ async function main() {
|
|
|
4255
4794
|
|
|
4256
4795
|
// Task executor lookup (always reads fresh config)
|
|
4257
4796
|
function executeTaskByName(name) {
|
|
4258
|
-
const
|
|
4259
|
-
let task = legacy.find(t => t.name === name);
|
|
4260
|
-
if (!task) {
|
|
4261
|
-
for (const [key, proj] of Object.entries(config.projects || {})) {
|
|
4262
|
-
const found = (proj.heartbeat_tasks || []).find(t => t.name === name);
|
|
4263
|
-
if (found) { task = { ...found, _project: { key, name: proj.name || key, color: proj.color || 'blue', icon: proj.icon || '🤖' } }; break; }
|
|
4264
|
-
}
|
|
4265
|
-
}
|
|
4797
|
+
const task = findTask(config, name);
|
|
4266
4798
|
if (!task) return { success: false, error: `Task "${name}" not found` };
|
|
4267
4799
|
return executeTask(task, config);
|
|
4268
4800
|
}
|
|
@@ -4327,6 +4859,9 @@ async function main() {
|
|
|
4327
4859
|
}
|
|
4328
4860
|
};
|
|
4329
4861
|
|
|
4862
|
+
// Start dispatch socket server (low-latency IPC, fallback: file polling still works)
|
|
4863
|
+
const dispatchSocket = startDispatchSocket(config);
|
|
4864
|
+
|
|
4330
4865
|
// Start heartbeat scheduler
|
|
4331
4866
|
let heartbeatTimer = startHeartbeat(config, notifyFn);
|
|
4332
4867
|
|
|
@@ -4338,10 +4873,9 @@ async function main() {
|
|
|
4338
4873
|
refreshLogMaxSize(config);
|
|
4339
4874
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
4340
4875
|
heartbeatTimer = startHeartbeat(config, notifyFn);
|
|
4341
|
-
const
|
|
4342
|
-
const
|
|
4343
|
-
|
|
4344
|
-
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)`);
|
|
4345
4879
|
return { success: true, tasks: totalCount };
|
|
4346
4880
|
}
|
|
4347
4881
|
// Expose reloadConfig to handleCommand via closure
|
|
@@ -4389,7 +4923,7 @@ async function main() {
|
|
|
4389
4923
|
});
|
|
4390
4924
|
// Hook: after every Claude task completes, check if restart is pending
|
|
4391
4925
|
const _origDelete = activeProcesses.delete.bind(activeProcesses);
|
|
4392
|
-
activeProcesses.delete = function(key) {
|
|
4926
|
+
activeProcesses.delete = function (key) {
|
|
4393
4927
|
const result = _origDelete(key);
|
|
4394
4928
|
if (_pendingRestart && activeProcesses.size === 0) {
|
|
4395
4929
|
log('INFO', 'All tasks completed — executing deferred restart...');
|
|
@@ -4413,8 +4947,12 @@ async function main() {
|
|
|
4413
4947
|
fs.unwatchFile(CONFIG_FILE);
|
|
4414
4948
|
fs.unwatchFile(DAEMON_SCRIPT);
|
|
4415
4949
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
4950
|
+
if (dispatchSocket) try { dispatchSocket.close(); } catch { }
|
|
4951
|
+
try { fs.unlinkSync(SOCK_PATH); } catch { }
|
|
4416
4952
|
if (telegramBridge) telegramBridge.stop();
|
|
4417
4953
|
if (feishuBridge) feishuBridge.stop();
|
|
4954
|
+
// Stop QMD semantic search daemon if it was started
|
|
4955
|
+
try { require('./qmd-client').stopDaemon(); } catch { /* ignore */ }
|
|
4418
4956
|
// Kill all tracked claude process groups before exiting (covers sub-agents too)
|
|
4419
4957
|
for (const [cid, proc] of activeProcesses) {
|
|
4420
4958
|
try { process.kill(-proc.child.pid, 'SIGKILL'); } catch { try { proc.child.kill('SIGKILL'); } catch { } }
|
|
@@ -4445,11 +4983,11 @@ if (process.argv.includes('--run')) {
|
|
|
4445
4983
|
process.exit(1);
|
|
4446
4984
|
}
|
|
4447
4985
|
const config = loadConfig();
|
|
4448
|
-
const
|
|
4449
|
-
const task = tasks.find(t => t.name === taskName);
|
|
4986
|
+
const task = findTask(config, taskName);
|
|
4450
4987
|
if (!task) {
|
|
4988
|
+
const { all } = getAllTasks(config);
|
|
4451
4989
|
console.error(`Task "${taskName}" not found in daemon.yaml`);
|
|
4452
|
-
console.error(`Available: ${
|
|
4990
|
+
console.error(`Available: ${all.map(t => t.name).join(', ') || '(none)'}`);
|
|
4453
4991
|
process.exit(1);
|
|
4454
4992
|
}
|
|
4455
4993
|
const result = executeTask(task, config);
|