@bolloon/bolloon-agent 0.4.6 → 0.4.8
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/dist/agents/memory-recall.js +115 -0
- package/dist/agents/pi-sdk-tools.js +69 -4
- package/dist/agents/pi-sdk.js +10 -0
- package/dist/agents/process-runner.js +105 -0
- package/dist/agents/write-staging.js +85 -0
- package/dist/index.js +43 -7
- package/package.json +1 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory-recall.ts — 运行时记忆召回 (2026-08-12, TaskM1)
|
|
3
|
+
*
|
|
4
|
+
* 借鉴 hermes MemoryManager.prefetch_all: 每轮对话开始前, 根据用户消息自动检索
|
|
5
|
+
* 历史 memory 摘要, 注入 system prompt — 让 agent 运行时能"回忆起"之前的 session 记忆,
|
|
6
|
+
* 而不是只靠启动时批量压缩摘要.
|
|
7
|
+
*
|
|
8
|
+
* 机制:
|
|
9
|
+
* - 从 ~/.bolloon/memory/<agentId>/sessions/*.summary.md 读历史摘要 (memory-compressor 落盘)
|
|
10
|
+
* - 用关键词 + BM25 简单打分, 召回与用户消息最相关的 N 条
|
|
11
|
+
* - 拼成 hermes 式 <memory-context> 围栏块注入 (含 sanitize, 防模型当新用户输入)
|
|
12
|
+
* - 失败静默 (召回是增强层, 不阻塞对话主路径)
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from 'fs/promises';
|
|
15
|
+
import * as os from 'os';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import { getMemoryDir } from '../bootstrap/memory-compressor.js';
|
|
18
|
+
const home = () => process.env.HOME || os.homedir() || '/tmp';
|
|
19
|
+
/** 提取查询关键词 (去掉停用词, 中文按 2-gram, 英文按词) */
|
|
20
|
+
export function tokenizeQuery(query) {
|
|
21
|
+
const clean = String(query || '').trim().toLowerCase();
|
|
22
|
+
if (!clean)
|
|
23
|
+
return [];
|
|
24
|
+
const STOP = new Set(['的', '了', '是', '我', '你', '他', '她', '它', '我们', '你们', '在', '和', '与', '吗', '呢', '吧', '这', '那', '个', '请', '帮', '一下', '一个', '怎么', '如何', 'the', 'a', 'an', 'is', 'are', 'to', 'of', 'for']);
|
|
25
|
+
const tokens = new Set();
|
|
26
|
+
// 英文单词
|
|
27
|
+
for (const m of clean.match(/[a-z][a-z0-9_]*/g) || []) {
|
|
28
|
+
if (!STOP.has(m) && m.length > 1)
|
|
29
|
+
tokens.add(m);
|
|
30
|
+
}
|
|
31
|
+
// 中文 2-gram
|
|
32
|
+
const cjk = clean.replace(/[^\u4e00-\u9fff]/g, '');
|
|
33
|
+
for (let i = 0; i + 1 < cjk.length; i++) {
|
|
34
|
+
const big = cjk.slice(i, i + 2);
|
|
35
|
+
if (!STOP.has(big))
|
|
36
|
+
tokens.add(big);
|
|
37
|
+
}
|
|
38
|
+
return Array.from(tokens);
|
|
39
|
+
}
|
|
40
|
+
/** BM25 风格: 摘要中出现查询 token 的次数打分 (简化: 命中数 + 稀有度) */
|
|
41
|
+
export function scoreSummary(text, tokens) {
|
|
42
|
+
if (tokens.length === 0)
|
|
43
|
+
return 0;
|
|
44
|
+
const lower = text.toLowerCase();
|
|
45
|
+
let score = 0;
|
|
46
|
+
for (const t of tokens) {
|
|
47
|
+
if (lower.includes(t))
|
|
48
|
+
score += 1;
|
|
49
|
+
}
|
|
50
|
+
return score;
|
|
51
|
+
}
|
|
52
|
+
/** 扫描 memory 目录的摘要文件 */
|
|
53
|
+
async function listSummaryFiles(agentId, homeDir) {
|
|
54
|
+
const dir = path.join(getMemoryDir(agentId, homeDir), 'sessions');
|
|
55
|
+
let files;
|
|
56
|
+
try {
|
|
57
|
+
files = await fs.readdir(dir);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
return files.filter((f) => f.endsWith('.summary.md')).sort();
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* 运行时召回记忆: 按用户消息检索历史 memory 摘要, 返回注入块.
|
|
66
|
+
* 无相关记忆/失败 → 返回 ''.
|
|
67
|
+
*/
|
|
68
|
+
export async function recallMemory(opts) {
|
|
69
|
+
try {
|
|
70
|
+
const { query, agentId, limit = 3, maxCharsPerSummary = 800, minScore = 1, homeDir } = opts;
|
|
71
|
+
if (!query || !query.trim())
|
|
72
|
+
return '';
|
|
73
|
+
if (!agentId)
|
|
74
|
+
return '';
|
|
75
|
+
const tokens = tokenizeQuery(query);
|
|
76
|
+
if (tokens.length === 0)
|
|
77
|
+
return '';
|
|
78
|
+
const files = await listSummaryFiles(agentId, homeDir || home());
|
|
79
|
+
if (files.length === 0)
|
|
80
|
+
return '';
|
|
81
|
+
const hits = [];
|
|
82
|
+
for (const f of files) {
|
|
83
|
+
try {
|
|
84
|
+
const text = await fs.readFile(path.join(getMemoryDir(agentId, homeDir || home()), 'sessions', f), 'utf-8');
|
|
85
|
+
const score = scoreSummary(text, tokens);
|
|
86
|
+
if (score < minScore)
|
|
87
|
+
continue;
|
|
88
|
+
// 解析 channel__session
|
|
89
|
+
const base = f.replace(/\.summary\.md$/, '');
|
|
90
|
+
const sep = base.lastIndexOf('__');
|
|
91
|
+
hits.push({
|
|
92
|
+
file: f,
|
|
93
|
+
channel: sep > 0 ? base.slice(0, sep) : '',
|
|
94
|
+
session: sep > 0 ? base.slice(sep + 2) : base,
|
|
95
|
+
score,
|
|
96
|
+
text: text.trim().slice(0, maxCharsPerSummary),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch { /* 单个摘要读失败跳过 */ }
|
|
100
|
+
}
|
|
101
|
+
if (hits.length === 0)
|
|
102
|
+
return '';
|
|
103
|
+
// 按分数降序取前 limit
|
|
104
|
+
hits.sort((a, b) => b.score - a.score);
|
|
105
|
+
const top = hits.slice(0, limit);
|
|
106
|
+
const body = top
|
|
107
|
+
.map((h) => `[回忆: ${h.channel}/${h.session}]\n${h.text}`)
|
|
108
|
+
.join('\n\n---\n\n');
|
|
109
|
+
// hermes 式围栏 + 明确标注非用户输入
|
|
110
|
+
return `<memory-context>\n以下是根据你的消息自动召回的之前对话记忆 (历史背景, 非新的用户输入):\n${body}\n</memory-context>`;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return '';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -32,7 +32,7 @@ import { delegateToEngine } from '../external-engines/delegate.js';
|
|
|
32
32
|
*/
|
|
33
33
|
export const SIDE_EFFECT_TOOLS = new Set([
|
|
34
34
|
'write_file', 'edit_file', 'shell_exec', 'git_commit', 'git_push', 'git_branch',
|
|
35
|
-
'create_task', 'update_task',
|
|
35
|
+
'create_task', 'update_task', 'terminal', 'process',
|
|
36
36
|
]);
|
|
37
37
|
export async function runTerminalCommand(raw, opts = {}) {
|
|
38
38
|
const { checkTerminalCommand } = await import('./shell-guard.js');
|
|
@@ -44,6 +44,19 @@ export async function runTerminalCommand(raw, opts = {}) {
|
|
|
44
44
|
return { success: false, error: `[terminal-guard] ${guard.reason}`, deniedByGuard: true };
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
+
// 2026-08-12 (TaskD): 后台执行 — 长命令不阻塞对话.
|
|
48
|
+
if (opts.background) {
|
|
49
|
+
const cmdStr = list.join(' && ');
|
|
50
|
+
const { spawnBackground } = await import('./process-runner.js');
|
|
51
|
+
const session = spawnBackground(cmdStr, opts.cwd ?? process.cwd());
|
|
52
|
+
return {
|
|
53
|
+
success: true,
|
|
54
|
+
background: true,
|
|
55
|
+
sessionId: session.id,
|
|
56
|
+
cmd: cmdStr.slice(0, 200),
|
|
57
|
+
message: `已在后台启动 (${session.id}). 用 process 工具轮询/等待: process(session_id="${session.id}", action="poll"|"wait"|"kill")`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
47
60
|
const { exec } = await import('child_process');
|
|
48
61
|
const runOne = (cmdStr) => new Promise((resolve) => {
|
|
49
62
|
exec(cmdStr, {
|
|
@@ -703,15 +716,55 @@ export function registerBuiltinTools(ctx) {
|
|
|
703
716
|
// 与 shell_exec 的区别: 直接接受完整 shell 命令字符串, 更适合模型自主写命令.
|
|
704
717
|
ctx.tools.set('terminal', {
|
|
705
718
|
name: 'terminal',
|
|
706
|
-
description: '执行完整 shell 命令 (支持管道/重定向/写文件/跑脚本). 护栏只挡高危破坏操作 (sudo/格式化/rm -rf 根目录/写 ~/.bolloon 数据), 其余灵活放行. 适合: 写 HTML 文件、跑 python/node 脚本、查系统状态、装依赖. 多条命令用 commands 数组并行执行.',
|
|
707
|
-
parameters: { command: '完整 shell 命令 (必填, 如: echo "<html>" > /tmp/site/index.html && ls /tmp/site)', commands: '可选: 多条命令数组 (并行执行), 每条独立字符串', timeoutMs: '超时毫秒, 默认 30000' },
|
|
719
|
+
description: '执行完整 shell 命令 (支持管道/重定向/写文件/跑脚本). 护栏只挡高危破坏操作 (sudo/格式化/rm -rf 根目录/写 ~/.bolloon 数据), 其余灵活放行. 适合: 写 HTML 文件、跑 python/node 脚本、查系统状态、装依赖. 多条命令用 commands 数组并行执行. 长命令 (服务器/构建/后台任务) 设 background=true 后台执行不阻塞对话.',
|
|
720
|
+
parameters: { command: '完整 shell 命令 (必填, 如: echo "<html>" > /tmp/site/index.html && ls /tmp/site)', commands: '可选: 多条命令数组 (并行执行), 每条独立字符串', timeoutMs: '超时毫秒, 默认 30000', background: '可选: true 后台执行, 立即返回 session_id (用 process 工具 poll/wait/kill)' },
|
|
708
721
|
execute: async (args) => {
|
|
709
722
|
const raw = String(args.command || '').trim();
|
|
710
723
|
const commands = Array.isArray(args.commands) ? args.commands.map((c) => String(c || '').trim()).filter(Boolean) : [];
|
|
711
724
|
if (!raw && commands.length === 0)
|
|
712
725
|
return { success: false, error: 'command 或 commands 必填' };
|
|
713
726
|
const timeoutMs = Number(args.timeoutMs) || 30000;
|
|
714
|
-
return await runTerminalCommand(raw, { timeoutMs, cwd: ctx.cwd, commands: commands.length > 0 ? commands : undefined });
|
|
727
|
+
return await runTerminalCommand(raw, { timeoutMs, cwd: ctx.cwd, commands: commands.length > 0 ? commands : undefined, background: String(args.background).toLowerCase() === 'true' });
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
// 2026-08-12 (TaskD): process — 后台进程管理 (学 hermes terminal background session + poll/wait/kill).
|
|
731
|
+
// 长命令不阻塞对话: terminal(background=true) 启动 → process 工具轮询/等待/终止.
|
|
732
|
+
ctx.tools.set('process', {
|
|
733
|
+
name: 'process',
|
|
734
|
+
description: '管理后台进程 (terminal background=true 启动的). action: poll(查状态, 不阻塞) / wait(等结束, 最多 timeoutMs) / kill(终止) / list(列全部). 长期运行命令的阻塞问题用它解决.',
|
|
735
|
+
parameters: { session_id: '后台进程 session_id (必填, poll/wait/kill 用)', action: 'poll | wait | kill | list (默认 poll)', timeoutMs: 'wait 模式等待上限毫秒, 默认 30000' },
|
|
736
|
+
execute: async (args) => {
|
|
737
|
+
const action = String(args.action || 'poll').trim().toLowerCase();
|
|
738
|
+
try {
|
|
739
|
+
const { pollSession, waitSession, killSession, listSessions, isValidSessionId } = await import('./process-runner.js');
|
|
740
|
+
if (action === 'list') {
|
|
741
|
+
const all = listSessions();
|
|
742
|
+
return { success: true, output: all.length === 0 ? '(无后台进程)' : all.map((s) => ` [${s.status}] ${s.id} ${s.cmd}`).join('\n') };
|
|
743
|
+
}
|
|
744
|
+
const sid = String(args.session_id || '').trim();
|
|
745
|
+
if (!sid)
|
|
746
|
+
return { success: false, error: 'session_id 必填' };
|
|
747
|
+
if (!isValidSessionId(sid))
|
|
748
|
+
return { success: false, error: 'session_id 非法' };
|
|
749
|
+
if (action === 'kill') {
|
|
750
|
+
const r = killSession(sid);
|
|
751
|
+
return { success: r.ok, output: r.reason ?? `已终止 ${sid}` };
|
|
752
|
+
}
|
|
753
|
+
if (action === 'wait') {
|
|
754
|
+
const t = Number(args.timeoutMs) || 30000;
|
|
755
|
+
const r = await waitSession(sid, t);
|
|
756
|
+
return { success: r.ok, output: r.session ? `${r.session.status} exit=${r.session.exitCode}${r.session.timedOut ? ' (超时)' : ''}\n${r.session.output || '(无输出)'}` : '未知 session' };
|
|
757
|
+
}
|
|
758
|
+
// poll
|
|
759
|
+
const r = pollSession(sid);
|
|
760
|
+
if (!r.ok || !r.session)
|
|
761
|
+
return { success: false, error: `未知 session ${sid}` };
|
|
762
|
+
const s = r.session;
|
|
763
|
+
return { success: true, output: `[${s.status}] exit=${s.exitCode}${s.status === 'running' ? ' (运行中)' : ''}\n${s.output || '(无输出)'}` };
|
|
764
|
+
}
|
|
765
|
+
catch (e) {
|
|
766
|
+
return { success: false, error: `process ${action} 失败: ${String(e?.message || e).slice(0, 200)}` };
|
|
767
|
+
}
|
|
715
768
|
}
|
|
716
769
|
});
|
|
717
770
|
// self_improve
|
|
@@ -746,6 +799,15 @@ export function registerBuiltinTools(ctx) {
|
|
|
746
799
|
}
|
|
747
800
|
try {
|
|
748
801
|
const absPath = path.resolve(ctx.cwd, relPath);
|
|
802
|
+
// 2026-08-12 (TaskC): 写前暂存 (准备阶段) — 记录变更前快照, 支持审计/撤销. 失败静默.
|
|
803
|
+
let before = '';
|
|
804
|
+
try {
|
|
805
|
+
before = await fs.readFile(absPath, 'utf-8');
|
|
806
|
+
}
|
|
807
|
+
catch { /* 新文件 */ }
|
|
808
|
+
const action = before.length > 0 ? 'overwrite' : 'create';
|
|
809
|
+
const { stageWrite } = await import('./write-staging.js');
|
|
810
|
+
await stageWrite(relPath, before, content, action, ctx.cwd).catch(() => { });
|
|
749
811
|
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
|
750
812
|
await fs.writeFile(absPath, content, 'utf-8');
|
|
751
813
|
return { success: true, output: `✅ wrote ${relPath} (${content.length} bytes)` };
|
|
@@ -778,6 +840,9 @@ export function registerBuiltinTools(ctx) {
|
|
|
778
840
|
return { success: false, error: `old_text 在 ${relPath} 中未找到, 拒绝静默写入. 请先用 read_document 读最新内容.` };
|
|
779
841
|
}
|
|
780
842
|
const updated = original.replace(oldText, newText);
|
|
843
|
+
// 2026-08-12 (TaskC): 写前暂存 (准备阶段) — 记录变更前快照, 支持审计/撤销. 失败静默.
|
|
844
|
+
const { stageWrite } = await import('./write-staging.js');
|
|
845
|
+
await stageWrite(relPath, original, updated, 'edit', ctx.cwd).catch(() => { });
|
|
781
846
|
await fs.writeFile(absPath, updated, 'utf-8');
|
|
782
847
|
return { success: true, output: `✅ edited ${relPath} (${oldText.length} → ${newText.length} 字节)` };
|
|
783
848
|
}
|
package/dist/agents/pi-sdk.js
CHANGED
|
@@ -760,6 +760,16 @@ export class PiAgentSession {
|
|
|
760
760
|
});
|
|
761
761
|
// 2026-06-18: web server 喂的 markedPrompt 外的 contextHint 拼到 system 末尾 (而不是当 user message)
|
|
762
762
|
this.contextHintAddition = contextHint;
|
|
763
|
+
// 2026-08-12 (TaskM1, hermes prefetch 模式): 运行时按用户消息召回历史记忆, 注入 system prompt.
|
|
764
|
+
// 让 agent 能"回忆起"之前 session 的记忆 (自动获取之前 session), 而非只靠启动时批量压缩.
|
|
765
|
+
try {
|
|
766
|
+
const { recallMemory } = await import('./memory-recall.js');
|
|
767
|
+
const recalled = await recallMemory({ query: userText, agentId: this.currentAgentId || this.peerId || '' });
|
|
768
|
+
if (recalled) {
|
|
769
|
+
this.contextHintAddition = [this.contextHintAddition, recalled].filter(Boolean).join('\n\n');
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
catch { /* 记忆召回失败静默 (增强层) */ }
|
|
763
773
|
onStream({ type: 'thinking', content: '🤔 开始思考...' });
|
|
764
774
|
if (!this.minimaxAvailable) {
|
|
765
775
|
const response = await this.handleFallback(userText);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* process-runner.ts — 后台进程管理 (2026-08-12, TaskD)
|
|
3
|
+
*
|
|
4
|
+
* 借鉴 hermes terminal_tool: 长期运行命令不应阻塞对话.
|
|
5
|
+
* - foreground: 命令结束立即返回 (即使超时设长, 不 sleep 阻塞)
|
|
6
|
+
* - background: spawn 后台执行, 返回 session_id, 用 process(action=poll/wait/kill) 管理
|
|
7
|
+
* - 禁止 nohup/setsid/trailing '&' — 用 background 让系统跟踪进程
|
|
8
|
+
*
|
|
9
|
+
* 进程注册表: 模块级 Map<sessionId, {proc, cmd, startedAt, output[]}>, 供 process 工具查询.
|
|
10
|
+
*/
|
|
11
|
+
import { spawn } from 'child_process';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
const sessions = new Map();
|
|
14
|
+
let seq = 0;
|
|
15
|
+
function genId() {
|
|
16
|
+
seq++;
|
|
17
|
+
return `proc-${Date.now().toString(36)}-${seq}`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 后台启动一个 shell 命令. 立即返回 session 记录 (不阻塞).
|
|
21
|
+
* 命令字符串经调用方 (runTerminalCommand) 护栏检查.
|
|
22
|
+
*/
|
|
23
|
+
export function spawnBackground(raw, cwd = process.cwd()) {
|
|
24
|
+
const id = genId();
|
|
25
|
+
const session = { id, cmd: raw.slice(0, 300), cwd, startedAt: Date.now(), exitCode: null, status: 'running', output: '', proc: null };
|
|
26
|
+
sessions.set(id, session);
|
|
27
|
+
// Windows 用 cmd /c; POSIX 用 /bin/sh -c
|
|
28
|
+
const shell = process.platform === 'win32' ? 'cmd' : '/bin/sh';
|
|
29
|
+
const shellArgs = process.platform === 'win32' ? ['/c', raw] : ['-c', raw];
|
|
30
|
+
try {
|
|
31
|
+
const proc = spawn(shell, shellArgs, { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, windowsHide: true });
|
|
32
|
+
session.proc = proc;
|
|
33
|
+
proc.stdout.on('data', (d) => { session.output = (session.output + d.toString()).slice(-16000); });
|
|
34
|
+
proc.stderr.on('data', (d) => { session.output = (session.output + d.toString()).slice(-16000); });
|
|
35
|
+
proc.on('close', (code) => { session.exitCode = code; session.status = 'exited'; });
|
|
36
|
+
proc.on('error', (e) => { session.status = 'error'; session.error = e.message; });
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
session.status = 'error';
|
|
40
|
+
session.error = e?.message;
|
|
41
|
+
}
|
|
42
|
+
return session;
|
|
43
|
+
}
|
|
44
|
+
/** 查询后台进程状态 (不阻塞). 返回脱敏视图. */
|
|
45
|
+
export function pollSession(id) {
|
|
46
|
+
const s = sessions.get(id);
|
|
47
|
+
if (!s)
|
|
48
|
+
return { ok: false, found: false };
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
found: true,
|
|
52
|
+
session: {
|
|
53
|
+
id: s.id, cmd: s.cmd, startedAt: s.startedAt, exitCode: s.exitCode,
|
|
54
|
+
status: s.status, output: s.output.slice(-4000), error: s.error,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** 等待后台进程结束 (最多 timeoutMs). 返回最终状态. */
|
|
59
|
+
export async function waitSession(id, timeoutMs = 30000) {
|
|
60
|
+
const s = sessions.get(id);
|
|
61
|
+
if (!s)
|
|
62
|
+
return { ok: false, error: `未知 session ${id}` };
|
|
63
|
+
if (s.status !== 'running') {
|
|
64
|
+
return { ok: true, session: { id: s.id, status: s.status, exitCode: s.exitCode, output: s.output.slice(-4000) } };
|
|
65
|
+
}
|
|
66
|
+
const deadline = Date.now() + timeoutMs;
|
|
67
|
+
while (Date.now() < deadline && s.status === 'running') {
|
|
68
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
ok: s.status !== 'running',
|
|
72
|
+
session: { id: s.id, status: s.status, exitCode: s.exitCode, output: s.output.slice(-4000), timedOut: s.status === 'running' },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** 杀死后台进程. 返回是否已终止. */
|
|
76
|
+
export function killSession(id) {
|
|
77
|
+
const s = sessions.get(id);
|
|
78
|
+
if (!s)
|
|
79
|
+
return { ok: false, reason: `未知 session ${id}` };
|
|
80
|
+
if (s.status !== 'running' || !s.proc) {
|
|
81
|
+
return { ok: true, reason: `进程已 ${s.status}` };
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
if (process.platform === 'win32')
|
|
85
|
+
s.proc.kill('SIGTERM');
|
|
86
|
+
else
|
|
87
|
+
s.proc.kill('SIGTERM');
|
|
88
|
+
s.status = 'killed';
|
|
89
|
+
return { ok: true };
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
return { ok: false, reason: `kill 失败: ${e?.message}` };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** 列出所有后台进程 */
|
|
96
|
+
export function listSessions() {
|
|
97
|
+
return Array.from(sessions.values()).map((s) => ({ id: s.id, cmd: s.cmd, status: s.status, exitCode: s.exitCode }));
|
|
98
|
+
}
|
|
99
|
+
/** session id 合法性 (防注入) */
|
|
100
|
+
export function isValidSessionId(id) {
|
|
101
|
+
return /^proc-[a-z0-9]+-\d+$/.test(id || '');
|
|
102
|
+
}
|
|
103
|
+
export function sessionIdFromPath(p) {
|
|
104
|
+
return path.basename(String(p || '').trim());
|
|
105
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* write-staging.ts — 写操作准备阶段适配 (2026-08-12, TaskC)
|
|
3
|
+
*
|
|
4
|
+
* 借鉴 hermes write_approval.py 的 staging gate: 写操作先 stage (暂存) 再 commit,
|
|
5
|
+
* 保留完整 payload (可重放/回滚) 用于审计与撤销 — 但不强制人工审批 (bolloon 是自主 agent,
|
|
6
|
+
* 写操作直接提交, 只是保留 stage 记录做"准备阶段"审计, 不打断自主循环).
|
|
7
|
+
*
|
|
8
|
+
* 设计:
|
|
9
|
+
* - 每次 write_file / edit_file 写盘前, 记录一条 stage 到 ~/.bolloon/write-log/<ts>-<rand>.json
|
|
10
|
+
* - stage 记录含: 相对路径 / 绝对路径 / action (create|overwrite|edit) / 变更前内容快照 / 变更意图 / 时间
|
|
11
|
+
* - 提供 listStagedWrites / undoLastWrite (撤销最近一次写) / getStagedWrite
|
|
12
|
+
* - 失败静默 (写日志不影响工具主路径)
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from 'fs/promises';
|
|
15
|
+
import * as os from 'os';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
const home = () => process.env.HOME || os.homedir() || '/tmp';
|
|
18
|
+
export function writeLogDir(homeDir = home()) {
|
|
19
|
+
return path.join(homeDir, '.bolloon', 'write-log');
|
|
20
|
+
}
|
|
21
|
+
/** 生成唯一 stage id */
|
|
22
|
+
function genId() {
|
|
23
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 写前暂存: 记录一次写操作 (准备阶段). 返回记录; 失败静默返回 null.
|
|
27
|
+
* @param relPath 相对路径
|
|
28
|
+
* @param beforeContent 变更前内容 (读盘或空)
|
|
29
|
+
* @param afterContent 变更后内容
|
|
30
|
+
* @param action 动作类型
|
|
31
|
+
*/
|
|
32
|
+
export async function stageWrite(relPath, beforeContent, afterContent, action, cwd = process.cwd(), homeDir = home()) {
|
|
33
|
+
try {
|
|
34
|
+
const id = genId();
|
|
35
|
+
const absPath = path.resolve(cwd, relPath);
|
|
36
|
+
const rec = { id, absPath, relPath, action, beforeContent, afterContent, createdAt: Date.now() };
|
|
37
|
+
const dir = writeLogDir(homeDir);
|
|
38
|
+
await fs.mkdir(dir, { recursive: true });
|
|
39
|
+
await fs.writeFile(path.join(dir, `${id}.json`), JSON.stringify(rec, null, 2), 'utf-8');
|
|
40
|
+
return rec;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** 列出最近的暂存写记录 (新→旧) */
|
|
47
|
+
export async function listStagedWrites(homeDir = home()) {
|
|
48
|
+
const dir = writeLogDir(homeDir);
|
|
49
|
+
let files;
|
|
50
|
+
try {
|
|
51
|
+
files = await fs.readdir(dir);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const f of files.filter(f => f.endsWith('.json')).sort().reverse()) {
|
|
58
|
+
try {
|
|
59
|
+
out.push(JSON.parse(await fs.readFile(path.join(dir, f), 'utf-8')));
|
|
60
|
+
}
|
|
61
|
+
catch { /* 坏文件跳过 */ }
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
/** 撤销最近一次写 (若文件内容仍等于 afterContent → 恢复 beforeContent). 返回是否撤销. */
|
|
66
|
+
export async function undoLastWrite(homeDir = home()) {
|
|
67
|
+
const staged = await listStagedWrites(homeDir);
|
|
68
|
+
if (staged.length === 0)
|
|
69
|
+
return { ok: false, reason: '无暂存写记录' };
|
|
70
|
+
const rec = staged[0];
|
|
71
|
+
try {
|
|
72
|
+
// 仅当文件未被后续修改 (内容仍 = afterContent) 时才安全撤销
|
|
73
|
+
const current = await fs.readFile(rec.absPath, 'utf-8').catch(() => null);
|
|
74
|
+
if (current === rec.afterContent) {
|
|
75
|
+
await fs.writeFile(rec.absPath, rec.beforeContent, 'utf-8');
|
|
76
|
+
// 清理该 stage 记录
|
|
77
|
+
await fs.rm(path.join(writeLogDir(homeDir), `${rec.id}.json`), { force: true }).catch(() => { });
|
|
78
|
+
return { ok: true };
|
|
79
|
+
}
|
|
80
|
+
return { ok: false, reason: '文件已被后续修改, 跳过撤销' };
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
return { ok: false, reason: `撤销失败: ${e?.message}` };
|
|
84
|
+
}
|
|
85
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -16,7 +16,7 @@ import { getGlobalSharedContext } from './social/global-shared-context.js';
|
|
|
16
16
|
import { createBollharnessIntegration } from './bollharness-integration/index.js';
|
|
17
17
|
import * as readline from 'readline';
|
|
18
18
|
import { printBanner, renderDialog, renderUserMessage, renderAgentMessage, renderMessageBox, renderToolCallListItem, termWidth } from './cli/loading-tui.js';
|
|
19
|
-
import { startInk, stopInk, inkAppendLine as appendLine,
|
|
19
|
+
import { startInk, stopInk, inkAppendLine as appendLine, inkSetStatus, inkSetThinking, inkSetTransient } from './cli/ink-app.js';
|
|
20
20
|
// 启动自动检查更新:后台、节流、检测到新版本自动安装(可被 --no-update / BOLLOON_SKIP_UPDATE 关闭)
|
|
21
21
|
import { createRequire } from 'module';
|
|
22
22
|
const _require = createRequire(import.meta.url);
|
|
@@ -1850,10 +1850,12 @@ async function processInput(input, comm) {
|
|
|
1850
1850
|
tuiToolCounter++;
|
|
1851
1851
|
const toolName = e.tool || '?';
|
|
1852
1852
|
tuiToolCalls.push({ tool: toolName, args: e.args, _t: Date.now() });
|
|
1853
|
-
// 2026-08-12 (
|
|
1854
|
-
//
|
|
1853
|
+
// 2026-08-12 (TaskA): 工具命中要干净 — step_start 不 appendLine 到消息流 (避免重复),
|
|
1854
|
+
// 改用 transient 行显示"正在执行"(消息流只在 done 时出现一次完成行).
|
|
1855
1855
|
if (toolName !== 'system' && toolName !== 'loop' && toolName !== '?') {
|
|
1856
|
-
|
|
1856
|
+
const activeNames = tuiToolCalls.map(c => c.tool).filter(t => t !== 'system' && t !== 'loop' && t !== '?');
|
|
1857
|
+
const label = activeNames.length > 1 ? `执行 ${activeNames.length} 个工具: ${activeNames.join(', ')}` : `🔧 ${toolName}`;
|
|
1858
|
+
inkSetTransient(`${C_DIM}${label} 运行中...${RESET}`);
|
|
1857
1859
|
}
|
|
1858
1860
|
}
|
|
1859
1861
|
else if (e.type === 'step_done' || e.type === 'step_error') {
|
|
@@ -1872,13 +1874,20 @@ async function processInput(input, comm) {
|
|
|
1872
1874
|
runEndOkSteps.push({ status: 'ok', name: t, output: e.output });
|
|
1873
1875
|
}
|
|
1874
1876
|
}
|
|
1875
|
-
// 2026-08-12 (
|
|
1877
|
+
// 2026-08-12 (TaskA): 每个工具只在消息流出现一次 (done 时 appendLine 完成行).
|
|
1878
|
+
// 不 replaceLastLine (并行/thinking 交错会替换错行); 完成行固定追加.
|
|
1876
1879
|
const doneTool = e.tool ?? p?.tool;
|
|
1877
1880
|
if (doneTool !== 'system' && doneTool !== 'loop' && doneTool !== '?') {
|
|
1878
|
-
|
|
1881
|
+
appendLine(renderToolCallListItem(doneItem, tuiToolCalls.length + 1, tuiToolCounter));
|
|
1882
|
+
}
|
|
1883
|
+
// 更新 transient: 还有进行中的工具 → 显示下一个; 否则清空 (交回 thinking 动画)
|
|
1884
|
+
const remaining = tuiToolCalls.filter(c => c.tool !== 'system' && c.tool !== 'loop' && c.tool !== '?');
|
|
1885
|
+
if (remaining.length > 0) {
|
|
1886
|
+
const label = remaining.length > 1 ? `执行 ${remaining.length} 个工具: ${remaining.map(c => c.tool).join(', ')}` : `🔧 ${remaining[0].tool}`;
|
|
1887
|
+
inkSetTransient(`${C_DIM}${label} 运行中...${RESET}`);
|
|
1879
1888
|
}
|
|
1880
1889
|
else {
|
|
1881
|
-
|
|
1890
|
+
inkSetTransient(null);
|
|
1882
1891
|
}
|
|
1883
1892
|
}
|
|
1884
1893
|
}
|
|
@@ -1903,6 +1912,33 @@ async function processInput(input, comm) {
|
|
|
1903
1912
|
}
|
|
1904
1913
|
});
|
|
1905
1914
|
}
|
|
1915
|
+
// 2026-08-12 (TaskM2, hermes sync 模式): CLI 对话结束后同步记忆 — 每轮 compressSessionToMemory
|
|
1916
|
+
// 把本会话消息压缩成摘要 (≥4 新消息触发), 供后续运行时 recallMemory 自动召回 (跨 session 记忆).
|
|
1917
|
+
// Web 模式 server.ts 已有, CLI 之前缺失 → CLI 下无摘要可召回. 失败静默, 不阻塞对话.
|
|
1918
|
+
if (cliActiveChannelId) {
|
|
1919
|
+
setImmediate(async () => {
|
|
1920
|
+
try {
|
|
1921
|
+
const { compressSessionToMemory } = await import('./bootstrap/memory-compressor.js');
|
|
1922
|
+
const channelForMem = String(cliActiveChannelId || '');
|
|
1923
|
+
let sessionId = 'default';
|
|
1924
|
+
try {
|
|
1925
|
+
const { getIdentityStore } = await import('./agents/agent-identity-store.js');
|
|
1926
|
+
const store = getIdentityStore();
|
|
1927
|
+
await store.load();
|
|
1928
|
+
const ch = store.rawChannels.find((c) => c.id === channelForMem);
|
|
1929
|
+
if (ch && ch.currentSessionId)
|
|
1930
|
+
sessionId = String(ch.currentSessionId);
|
|
1931
|
+
}
|
|
1932
|
+
catch { /* 读 sessionId 失败用 default */ }
|
|
1933
|
+
await compressSessionToMemory({
|
|
1934
|
+
agentId: getCliAgentId(),
|
|
1935
|
+
channelId: channelForMem,
|
|
1936
|
+
sessionId,
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
catch { /* 记忆压缩失败静默 */ }
|
|
1940
|
+
});
|
|
1941
|
+
}
|
|
1906
1942
|
// 更新状态栏: 上下文进度 (2026-08-06: 每轮按当前 messageHistory 重算并写回 ContextManager,
|
|
1907
1943
|
// 保证状态栏按需更新 — 不依赖 pi-sdk loop 内部上报, 1s 定时器读到的一定是最新值)
|
|
1908
1944
|
// 2026-08-07 修复: pi-sdk loop 每轮已用 estimateHistoryTokens() 上报 ContextManager (pi-sdk.ts:1223),
|