@bolloon/bolloon-agent 0.5.3 → 0.5.5
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/agent-identity-store.js +41 -5
- package/dist/agents/background-notices.js +124 -0
- package/dist/agents/channel-identity.js +54 -0
- package/dist/agents/code-write-gate.js +47 -0
- package/dist/agents/deny-pipeline.js +11 -2
- package/dist/agents/egress-sanitize.js +69 -0
- package/dist/agents/error-classifier.js +67 -5
- package/dist/agents/experience-review.js +226 -0
- package/dist/agents/gateway-group.js +153 -18
- package/dist/agents/iteration-budget.js +64 -0
- package/dist/agents/lesson-to-skill.js +211 -0
- package/dist/agents/managed-services.js +64 -0
- package/dist/agents/parse-tool-call.js +38 -32
- package/dist/agents/pi-sdk-session-manager.js +72 -3
- package/dist/agents/pi-sdk-tools.js +268 -47
- package/dist/agents/pi-sdk.js +470 -22
- package/dist/agents/plan-store.js +36 -0
- package/dist/agents/process-runner.js +64 -2
- package/dist/agents/session-store.js +144 -3
- package/dist/agents/shell-guard.js +109 -13
- package/dist/agents/skill-health.js +90 -0
- package/dist/agents/skill-ledger.js +129 -0
- package/dist/agents/tool-loop-guard.js +303 -0
- package/dist/agents/tool-path-args.js +53 -0
- package/dist/agents/tool-result-gate.js +50 -0
- package/dist/agents/tool-subset.js +109 -0
- package/dist/agents/tool-telemetry.js +97 -0
- package/dist/agents/wallet-tools.js +63 -0
- package/dist/bootstrap/context-collector.js +37 -7
- package/dist/bootstrap/lifecycle-hooks.js +12 -3
- package/dist/bootstrap/persona-init.js +174 -0
- package/dist/bootstrap/persona-loader.js +5 -1
- package/dist/cli/ink-app.js +231 -32
- package/dist/cli/input-history.js +119 -0
- package/dist/cli/input-paste.js +218 -0
- package/dist/cli/loading-tui.js +69 -7
- package/dist/cli/log-gate.js +46 -1
- package/dist/cli/mention-data.js +30 -0
- package/dist/cli/reasoning-view.js +103 -0
- package/dist/cli/session-summary.js +129 -0
- package/dist/cli/session-tree.js +141 -0
- package/dist/cli/startup-notice.js +25 -0
- package/dist/cli/status-segments.js +155 -0
- package/dist/cli/stores.js +16 -0
- package/dist/cli/trace-line.js +54 -0
- package/dist/cli/wallet-store.js +159 -0
- package/dist/cli/wallet-table.js +46 -0
- package/dist/cli-entry.js +22 -0
- package/dist/context-compaction/auto-compact.js +37 -2
- package/dist/electron-build/agents/agent-identity-store.js +43 -5
- package/dist/electron-build/agents/agent-identity-store.js.map +1 -1
- package/dist/electron-build/agents/background-notices.js +138 -0
- package/dist/electron-build/agents/background-notices.js.map +1 -0
- package/dist/electron-build/agents/channel-identity.js +64 -0
- package/dist/electron-build/agents/channel-identity.js.map +1 -0
- package/dist/electron-build/agents/code-write-gate.js +55 -0
- package/dist/electron-build/agents/code-write-gate.js.map +1 -0
- package/dist/electron-build/agents/deny-pipeline.js +11 -2
- package/dist/electron-build/agents/deny-pipeline.js.map +1 -1
- package/dist/electron-build/agents/egress-sanitize.js +76 -0
- package/dist/electron-build/agents/egress-sanitize.js.map +1 -0
- package/dist/electron-build/agents/error-classifier.js +68 -5
- package/dist/electron-build/agents/error-classifier.js.map +1 -1
- package/dist/electron-build/agents/experience-review.js +243 -0
- package/dist/electron-build/agents/experience-review.js.map +1 -0
- package/dist/electron-build/agents/iteration-budget.js +73 -0
- package/dist/electron-build/agents/iteration-budget.js.map +1 -0
- package/dist/electron-build/agents/lesson-to-skill.js +228 -0
- package/dist/electron-build/agents/lesson-to-skill.js.map +1 -0
- package/dist/electron-build/agents/managed-services.js +69 -0
- package/dist/electron-build/agents/managed-services.js.map +1 -0
- package/dist/electron-build/agents/parse-tool-call.js +38 -32
- package/dist/electron-build/agents/parse-tool-call.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk-session-manager.js +75 -3
- package/dist/electron-build/agents/pi-sdk-session-manager.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk-tools.js +268 -46
- package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk.js +471 -22
- package/dist/electron-build/agents/pi-sdk.js.map +1 -1
- package/dist/electron-build/agents/plan-store.js +37 -0
- package/dist/electron-build/agents/plan-store.js.map +1 -1
- package/dist/electron-build/agents/process-runner.js +71 -2
- package/dist/electron-build/agents/process-runner.js.map +1 -1
- package/dist/electron-build/agents/session-store.js +144 -3
- package/dist/electron-build/agents/session-store.js.map +1 -1
- package/dist/electron-build/agents/shell-guard.js +110 -14
- package/dist/electron-build/agents/shell-guard.js.map +1 -1
- package/dist/electron-build/agents/skill-health.js +103 -0
- package/dist/electron-build/agents/skill-health.js.map +1 -0
- package/dist/electron-build/agents/skill-ledger.js +146 -0
- package/dist/electron-build/agents/skill-ledger.js.map +1 -0
- package/dist/electron-build/agents/tool-loop-guard.js +319 -0
- package/dist/electron-build/agents/tool-loop-guard.js.map +1 -0
- package/dist/electron-build/agents/tool-path-args.js +63 -0
- package/dist/electron-build/agents/tool-path-args.js.map +1 -0
- package/dist/electron-build/agents/tool-result-gate.js +59 -0
- package/dist/electron-build/agents/tool-result-gate.js.map +1 -0
- package/dist/electron-build/agents/tool-subset.js +116 -0
- package/dist/electron-build/agents/tool-subset.js.map +1 -0
- package/dist/electron-build/agents/tool-telemetry.js +108 -0
- package/dist/electron-build/agents/tool-telemetry.js.map +1 -0
- package/dist/electron-build/agents/wallet-tools.js +102 -0
- package/dist/electron-build/agents/wallet-tools.js.map +1 -0
- package/dist/electron-build/bootstrap/context-collector.js +38 -7
- package/dist/electron-build/bootstrap/context-collector.js.map +1 -1
- package/dist/electron-build/bootstrap/lifecycle-hooks.js +12 -3
- package/dist/electron-build/bootstrap/lifecycle-hooks.js.map +1 -1
- package/dist/electron-build/bootstrap/persona-init.js +216 -0
- package/dist/electron-build/bootstrap/persona-init.js.map +1 -0
- package/dist/electron-build/bootstrap/persona-loader.js +5 -1
- package/dist/electron-build/bootstrap/persona-loader.js.map +1 -1
- package/dist/electron-build/cli/wallet-store.js +200 -0
- package/dist/electron-build/cli/wallet-store.js.map +1 -0
- package/dist/electron-build/context-compaction/auto-compact.js +38 -2
- package/dist/electron-build/context-compaction/auto-compact.js.map +1 -1
- package/dist/electron-build/external-engines/delegate.js +22 -0
- package/dist/electron-build/external-engines/delegate.js.map +1 -1
- package/dist/electron-build/llm/pi-ai.js +25 -0
- package/dist/electron-build/llm/pi-ai.js.map +1 -1
- package/dist/electron-build/orbitdb/cid-database.js +55 -0
- package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
- package/dist/electron-build/orbitdb/ipfs-node.js +36 -11
- package/dist/electron-build/orbitdb/ipfs-node.js.map +1 -1
- package/dist/electron-build/web/server-storage.js +26 -5
- package/dist/electron-build/web/server-storage.js.map +1 -1
- package/dist/external-engines/delegate.js +21 -0
- package/dist/index.js +1324 -173
- package/dist/llm/pi-ai.js +23 -0
- package/dist/orbitdb/cid-database.js +55 -0
- package/dist/orbitdb/event-index.js +370 -0
- package/dist/orbitdb/event-query.js +131 -0
- package/dist/orbitdb/event-shell.js +510 -0
- package/dist/orbitdb/group-access.js +381 -0
- package/dist/orbitdb/group-shards.js +140 -0
- package/dist/orbitdb/ipfs-node.js +36 -11
- package/dist/web/bolloon-web.json +3 -3
- package/dist/web/client.js +3 -2
- package/dist/web/mobile-core.js +7762 -6289
- package/dist/web/routes-mobile-tasks.js +2 -1
- package/dist/web/server-storage.js +26 -5
- package/dist/web/server.js +2 -0
- package/dist/web/sw.js +1 -1
- package/package.json +2 -1
|
@@ -39,6 +39,8 @@ export async function createPlan(input, home) {
|
|
|
39
39
|
const now = new Date().toISOString();
|
|
40
40
|
const planId = `plan_${Date.now()}_${crypto.randomBytes(3).toString('hex')}`;
|
|
41
41
|
const plan = {
|
|
42
|
+
// 初始版本 (落实④: 单调递增版本号, 供乐观并发)
|
|
43
|
+
rev: 1,
|
|
42
44
|
planId,
|
|
43
45
|
goal,
|
|
44
46
|
createdBy: input.createdBy || 'agent',
|
|
@@ -79,6 +81,13 @@ export async function updatePlan(planId, input, home) {
|
|
|
79
81
|
const plan = await loadPlan(planId, home);
|
|
80
82
|
if (!plan)
|
|
81
83
|
return { ok: false, error: `plan '${planId}' 不存在` };
|
|
84
|
+
// 2026-10-01 (落实④): 乐观并发校验 —— 带 expectedRev 且对不上 ⇒ **拒收**, 绝不默默覆盖
|
|
85
|
+
if (typeof input.expectedRev === 'number' && Number.isFinite(input.expectedRev)) {
|
|
86
|
+
const cur = Number(plan.rev ?? 1);
|
|
87
|
+
if (input.expectedRev !== cur) {
|
|
88
|
+
return { ok: false, error: `plan '${planId}' 已被更新 (期望 rev=${input.expectedRev}, 实际 rev=${cur}) —— 请重新读取后再改` };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
82
91
|
if (input.stepId && input.status) {
|
|
83
92
|
const step = plan.steps.find(s => s.id === input.stepId);
|
|
84
93
|
if (!step)
|
|
@@ -104,6 +113,9 @@ export async function updatePlan(planId, input, home) {
|
|
|
104
113
|
s.status = 'blocked';
|
|
105
114
|
}
|
|
106
115
|
}
|
|
116
|
+
// 2026-10-01 (落实④): 每次成功更新 ⇒ rev **单调递增** (乐观并发的基础; 也供 UI 丢弃过期快照)
|
|
117
|
+
plan.rev = Number(plan.rev ?? 1) + 1;
|
|
118
|
+
plan.updatedAt = new Date().toISOString();
|
|
107
119
|
try {
|
|
108
120
|
await savePlan(plan, home);
|
|
109
121
|
return { ok: true, plan };
|
|
@@ -165,3 +177,27 @@ export function planToContext(plan) {
|
|
|
165
177
|
}
|
|
166
178
|
return lines.join('\n');
|
|
167
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* 待办/计划重注入 (2026-10-01 落实④)。
|
|
182
|
+
*
|
|
183
|
+
* 为什么必须**每轮**注入: 上下文压缩会把早先注入的计划文本丢掉 —— 一旦丢了, 智能体就"忘了自己在做什么"。
|
|
184
|
+
* 所以注入不是一次性动作, 而是每轮带上**当前**计划(有界: 条数与字符都封顶, 免得计划本身吃爆上下文)。
|
|
185
|
+
* 与既有 `planToContext` 的分工: 那个给单条 plan 用; 这个给"当前活跃计划一览"用。
|
|
186
|
+
*/
|
|
187
|
+
export function formatPlansForPrompt(plans, opts = {}) {
|
|
188
|
+
const maxChars = Number.isFinite(opts.maxChars) && opts.maxChars > 0 ? Math.floor(opts.maxChars) : 1200;
|
|
189
|
+
const maxItems = Number.isFinite(opts.maxItems) && opts.maxItems > 0 ? Math.floor(opts.maxItems) : 3;
|
|
190
|
+
const active = (plans || []).filter((p) => p && p.status === 'active').slice(0, maxItems);
|
|
191
|
+
if (!active.length)
|
|
192
|
+
return '';
|
|
193
|
+
const lines = ['## 我在做的 (每轮重注入, 压缩后也不会丢)'];
|
|
194
|
+
for (const p of active) {
|
|
195
|
+
const done = p.steps.filter((x) => x.status === 'done').length;
|
|
196
|
+
lines.push(`- [${done}/${p.steps.length}] ${p.goal} (rev ${Number(p.rev ?? 1)})`);
|
|
197
|
+
for (const st of p.steps.filter((x) => x.status !== 'done').slice(0, 5)) {
|
|
198
|
+
lines.push(` · (${st.status}) ${st.description}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const out = lines.join('\n');
|
|
202
|
+
return out.length <= maxChars ? out : out.slice(0, maxChars) + '\n…[计划列表已截断]';
|
|
203
|
+
}
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* 进程注册表: 模块级 Map<sessionId, {proc, cmd, startedAt, output[]}>, 供 process 工具查询.
|
|
10
10
|
*/
|
|
11
11
|
import { spawn } from 'child_process';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import fs from 'node:fs';
|
|
12
14
|
import * as path from 'path';
|
|
13
15
|
const sessions = new Map();
|
|
14
16
|
let seq = 0;
|
|
@@ -30,10 +32,12 @@ export function spawnBackground(raw, cwd = process.cwd()) {
|
|
|
30
32
|
try {
|
|
31
33
|
const proc = spawn(shell, shellArgs, { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, windowsHide: true });
|
|
32
34
|
session.proc = proc;
|
|
35
|
+
session.pid = proc.pid;
|
|
36
|
+
persistSessions();
|
|
33
37
|
proc.stdout.on('data', (d) => { session.output = (session.output + d.toString()).slice(-16000); });
|
|
34
38
|
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; });
|
|
39
|
+
proc.on('close', (code) => { session.exitCode = code; session.status = 'exited'; persistSessions(); });
|
|
40
|
+
proc.on('error', (e) => { session.status = 'error'; session.error = e.message; persistSessions(); });
|
|
37
41
|
}
|
|
38
42
|
catch (e) {
|
|
39
43
|
session.status = 'error';
|
|
@@ -103,3 +107,61 @@ export function isValidSessionId(id) {
|
|
|
103
107
|
export function sessionIdFromPath(p) {
|
|
104
108
|
return path.basename(String(p || '').trim());
|
|
105
109
|
}
|
|
110
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
// 2026-10-01 (用户: 「bolloon 后台开进程后无法回来进行管理」):
|
|
112
|
+
// 实测根因: 会话表是**内存 Map** ✗ 且不落盘 ⇒ 重启/换一轮后 bolloon **忘了**后台进程 ✗
|
|
113
|
+
// ⇒ `process list` 空的 ⇒ "回不来管理" ✓。修: 落盘 `~/.bolloon/processes.json` +
|
|
114
|
+
// **懒恢复**(list/poll 时若表里没有就从盘上读 ✓) + 按 pid 探活(活着 ⇒ detached=true 仍可 kill ✓; 死了 ⇒ lost ✓)。
|
|
115
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
116
|
+
export function processesPath(home = os.homedir()) {
|
|
117
|
+
return path.join(home, '.bolloon', 'processes.json');
|
|
118
|
+
}
|
|
119
|
+
/** 探活: 只发 0 号信号, 真 kill 什么都不做 ✓ (EPERM 也算活着 ✓) */
|
|
120
|
+
export function isPidAlive(pid) {
|
|
121
|
+
if (!pid || !Number.isFinite(pid))
|
|
122
|
+
return false;
|
|
123
|
+
try {
|
|
124
|
+
process.kill(pid, 0);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
return e?.code === 'EPERM';
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** 把当前的会话表写到盘上 (best-effort, 绝不影响命令执行 ✓) */
|
|
132
|
+
export function persistSessions(home = os.homedir()) {
|
|
133
|
+
try {
|
|
134
|
+
const rows = [...sessions.values()].map((s) => ({
|
|
135
|
+
id: s.id, cmd: s.cmd, cwd: s.cwd, startedAt: s.startedAt,
|
|
136
|
+
status: s.status, exitCode: s.exitCode, pid: s.pid ?? null,
|
|
137
|
+
}));
|
|
138
|
+
fs.mkdirSync(path.dirname(processesPath(home)), { recursive: true });
|
|
139
|
+
fs.writeFileSync(processesPath(home), JSON.stringify(rows, null, 0), 'utf-8');
|
|
140
|
+
}
|
|
141
|
+
catch { /* 落盘失败不影响执行 */ }
|
|
142
|
+
}
|
|
143
|
+
/** 懒恢复: 表里没有的就从盘上读回来, 并按 pid 探活 ✓ */
|
|
144
|
+
export function restoreSessions(home = os.homedir()) {
|
|
145
|
+
let n = 0;
|
|
146
|
+
try {
|
|
147
|
+
const file = processesPath(home);
|
|
148
|
+
if (!fs.existsSync(file))
|
|
149
|
+
return 0;
|
|
150
|
+
const rows = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
151
|
+
for (const r of Array.isArray(rows) ? rows : []) {
|
|
152
|
+
if (!r?.id || sessions.has(r.id))
|
|
153
|
+
continue;
|
|
154
|
+
const alive = isPidAlive(Number(r.pid));
|
|
155
|
+
sessions.set(r.id, {
|
|
156
|
+
id: String(r.id), cmd: String(r.cmd || ''), cwd: String(r.cwd || process.cwd()),
|
|
157
|
+
startedAt: Number(r.startedAt) || Date.now(), exitCode: r.exitCode ?? null,
|
|
158
|
+
status: r.status === 'running' ? (alive ? 'running' : 'exited') : (r.status || 'exited'),
|
|
159
|
+
output: alive ? '(重启后恢复: 进程仍在跑, 输出不再采集 —— 需要结果就重新跑或用文件落盘)' : '',
|
|
160
|
+
proc: null, pid: Number(r.pid) || undefined, detached: true,
|
|
161
|
+
});
|
|
162
|
+
n++;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch { /* 坏文件忽略 */ }
|
|
166
|
+
return n;
|
|
167
|
+
}
|
|
@@ -102,6 +102,59 @@ export class SessionStore {
|
|
|
102
102
|
* fs.writeFile 抛 EINVAL. 修法: filename 层 escape `:` → `__`, key 保持不变
|
|
103
103
|
* (load/save/listKeys/deleteKey 全部透明).
|
|
104
104
|
*/
|
|
105
|
+
/**
|
|
106
|
+
* **写**的时候用这个 —— 与 resolveExisting 的区别: 不存在**不抛错**, 返回规范路径 (等于新建)。
|
|
107
|
+
*
|
|
108
|
+
* 2026-09-30 回归事故: 我把 resolveExisting (读语义, 不存在就抛 not found) 批量替换进了
|
|
109
|
+
* saveMessages ⇒ 退出存档对"还没落盘的新会话"直接抛 `session not found`
|
|
110
|
+
* (leo 贴的 `⚠ session 存档失败: SessionStore: session not found: 20260930_175348_870c70`)。
|
|
111
|
+
* 写路径必须容忍"还没有文件" —— 那正是它要创建的东西。
|
|
112
|
+
*/
|
|
113
|
+
async resolveWritePath(key) {
|
|
114
|
+
const canonical = this.pathFor(key);
|
|
115
|
+
try {
|
|
116
|
+
await fs.access(canonical);
|
|
117
|
+
return canonical;
|
|
118
|
+
}
|
|
119
|
+
catch { /* 试老名 */ }
|
|
120
|
+
const legacy = path.join(this.cacheDir, `${key}.json`);
|
|
121
|
+
try {
|
|
122
|
+
await fs.access(legacy);
|
|
123
|
+
return legacy;
|
|
124
|
+
}
|
|
125
|
+
catch { /* 新会话 */ }
|
|
126
|
+
return canonical;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* 读的时候用这个 —— 兼容**老文件名**。
|
|
130
|
+
*
|
|
131
|
+
* 2026-09-30 发现: 早期版本直接把 `channelId:sessionId` 当文件名写盘 (`real-123:default.json`),
|
|
132
|
+
* 后来 Windows 兼容改成 filenameEscape (`:` → `__`), 但**老文件没迁移** ⇒ 用 pathFor 去读
|
|
133
|
+
* 老会话一律 ENOENT (`/sessions` 里那些 `real-*:default` 全是 Preview `—`, 也续不上)。
|
|
134
|
+
* 这里: 先试新名, 不存在再试老名; 老名存在时**顺手迁移**成新名 (迁移失败就继续读老名)。
|
|
135
|
+
*/
|
|
136
|
+
async resolveExisting(key) {
|
|
137
|
+
const canonical = this.pathFor(key);
|
|
138
|
+
try {
|
|
139
|
+
await fs.access(canonical);
|
|
140
|
+
return canonical;
|
|
141
|
+
}
|
|
142
|
+
catch { /* 试老名 */ }
|
|
143
|
+
const legacy = path.join(this.cacheDir, `${key}.json`);
|
|
144
|
+
try {
|
|
145
|
+
await fs.access(legacy);
|
|
146
|
+
try {
|
|
147
|
+
await fs.rename(legacy, canonical);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return legacy;
|
|
151
|
+
} // 迁移失败也能读
|
|
152
|
+
return canonical;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
throw new Error(`SessionStore: session not found: ${key}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
105
158
|
pathFor(key) {
|
|
106
159
|
if (!key || key.includes('/') || key.includes('..')) {
|
|
107
160
|
throw new Error(`SessionStore: invalid key ${JSON.stringify(key)}`);
|
|
@@ -135,7 +188,7 @@ export class SessionStore {
|
|
|
135
188
|
totalCount: messages.length,
|
|
136
189
|
},
|
|
137
190
|
};
|
|
138
|
-
const filePath = this.
|
|
191
|
+
const filePath = await this.resolveWritePath(key); // 写路径: 新会话就建 (不抛 not found)
|
|
139
192
|
const tmpPath = `${filePath}.tmp`;
|
|
140
193
|
await fs.writeFile(tmpPath, JSON.stringify(payload, null, 2), 'utf-8');
|
|
141
194
|
await fs.rename(tmpPath, filePath);
|
|
@@ -177,7 +230,7 @@ export class SessionStore {
|
|
|
177
230
|
totalCount: messages.length,
|
|
178
231
|
},
|
|
179
232
|
};
|
|
180
|
-
const filePath = this.pathFor(key);
|
|
233
|
+
const filePath = this.pathFor(key); // 同步保存一律写规范文件名
|
|
181
234
|
const tmpPath = `${filePath}.tmp`;
|
|
182
235
|
fsSyncWrite(tmpPath, JSON.stringify(payload, null, 2));
|
|
183
236
|
fsSyncRename(tmpPath, filePath);
|
|
@@ -192,7 +245,11 @@ export class SessionStore {
|
|
|
192
245
|
if (!key || key.includes('/') || key.includes('..')) {
|
|
193
246
|
throw new Error(`SessionStore: invalid key ${JSON.stringify(key)}`);
|
|
194
247
|
}
|
|
195
|
-
|
|
248
|
+
// 2026-09-30 回归修复: 读路径**不许**在"会话不存在"时抛错 —— 仓里的契约是
|
|
249
|
+
// `loadMessages('cli:nonexistent') === null` (见 src/test/session-store.test.ts)。
|
|
250
|
+
// 用容错解析 (不存在 ⇒ 返回规范路径), 随后的 readFile 命中 ENOENT ⇒ return null。
|
|
251
|
+
// 老文件名(`real-123:default.json` 那批)由 resolveWritePath 兼容: 存在就返回老路径。
|
|
252
|
+
const filePath = await this.resolveWritePath(key);
|
|
196
253
|
let raw;
|
|
197
254
|
try {
|
|
198
255
|
raw = await fs.readFile(filePath, 'utf-8');
|
|
@@ -222,6 +279,90 @@ export class SessionStore {
|
|
|
222
279
|
return [];
|
|
223
280
|
}
|
|
224
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* 列最近改动的会话 (按 mtime 倒序), 带条数与首条用户消息预览。
|
|
284
|
+
*
|
|
285
|
+
* 2026-09-30 (leo: 「/session 没有反应, 没展示最新记录, 无法选择继续」): 只为"选一个继续"服务 ——
|
|
286
|
+
* **只解析最新的 limit 个文件**(缓存目录可能上千个, 全量解析会卡住输入), 解析失败的那条如实记 0。
|
|
287
|
+
*/
|
|
288
|
+
async listRecent(limit = 10) {
|
|
289
|
+
const out = [];
|
|
290
|
+
let files = [];
|
|
291
|
+
try {
|
|
292
|
+
files = await fs.readdir(this.cacheDir);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
const cands = [];
|
|
298
|
+
for (const f of files) {
|
|
299
|
+
if (!f.endsWith('.json') || f.endsWith('.tmp'))
|
|
300
|
+
continue;
|
|
301
|
+
try {
|
|
302
|
+
const st = await fs.stat(path.join(this.cacheDir, f));
|
|
303
|
+
cands.push({ key: SessionStore.filenameUnescape(f.slice(0, -'.json'.length)), mtimeMs: st.mtimeMs });
|
|
304
|
+
}
|
|
305
|
+
catch { /* 单条 stat 失败跳过 */ }
|
|
306
|
+
}
|
|
307
|
+
cands.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
308
|
+
for (const c of cands.slice(0, Math.max(1, limit))) {
|
|
309
|
+
let messages = 0;
|
|
310
|
+
let preview = '';
|
|
311
|
+
let title = c.key; // 会话文件里没有 title 字段 ⇒ 默认就是 key (web 侧才有会话名)
|
|
312
|
+
try {
|
|
313
|
+
// 兼容老文件名 (`real-123:default.json` 未转义那批) —— 读不到规范名就试原名
|
|
314
|
+
let text = '';
|
|
315
|
+
try {
|
|
316
|
+
text = await fs.readFile(this.pathFor(c.key), 'utf-8');
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
text = await fs.readFile(path.join(this.cacheDir, `${c.key}.json`), 'utf-8');
|
|
320
|
+
}
|
|
321
|
+
const raw = JSON.parse(text);
|
|
322
|
+
const arr = Array.isArray(raw) ? raw : (raw?.messages || []);
|
|
323
|
+
messages = Array.isArray(arr) ? arr.length : 0;
|
|
324
|
+
const md = (!Array.isArray(raw) && raw?.metadata) || {};
|
|
325
|
+
if (typeof md.title === 'string' && md.title.trim())
|
|
326
|
+
title = md.title.trim();
|
|
327
|
+
const mdPreview = typeof md.preview === 'string' ? md.preview.trim() : '';
|
|
328
|
+
// 预览要用**真实用户话**, 跳过自动注入的上下文块 (否则全是 <!-- current-turn … -->)
|
|
329
|
+
const isInjected = (x) => !!x && (/^<!--/.test(x.trim()) || /^#\s*你的项目上下文/.test(x.trim()) || /^\[cron\]/.test(x.trim()));
|
|
330
|
+
const first = (Array.isArray(arr) ? arr : []).find((m) => m && m.role === 'user' && m.content && !isInjected(String(m.content)));
|
|
331
|
+
preview = mdPreview || String(first?.content || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
332
|
+
}
|
|
333
|
+
catch { /* 损坏文件: 仍列出, 只是 0 条无预览 */ }
|
|
334
|
+
out.push({ key: c.key, title, mtimeMs: c.mtimeMs, messages, preview });
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* 合并写回会话文件的 metadata (原子: 先写 .tmp 再 rename)。
|
|
340
|
+
* 2026-09-30 (leo: 「Title/Preview 要有内容, 使用 AI 来总结进去」): AI 总结结果落在
|
|
341
|
+
* `metadata.{title,preview,summarizedAt}`, **只算一次**, 后续 /sessions 直接读。
|
|
342
|
+
* 原子写是硬要求 —— 会话文件是用户的会话历史, 半截文件比没有标题糟得多。
|
|
343
|
+
*/
|
|
344
|
+
async updateMetadata(key, patch) {
|
|
345
|
+
if (!key || key.includes('/') || key.includes('..'))
|
|
346
|
+
throw new Error(`SessionStore: invalid key ${JSON.stringify(key)}`);
|
|
347
|
+
const file = await this.resolveWritePath(key);
|
|
348
|
+
let raw = null;
|
|
349
|
+
try {
|
|
350
|
+
raw = JSON.parse(await fs.readFile(file, 'utf-8'));
|
|
351
|
+
}
|
|
352
|
+
catch (e) {
|
|
353
|
+
if (e?.code !== 'ENOENT')
|
|
354
|
+
throw new Error(`会话文件读不了 (${file}): ${String(e?.message || e).slice(0, 120)}`);
|
|
355
|
+
// 还没有文件 ⇒ 建成只有 metadata 的会话 (元数据更新对新会话应当有效, 不是错误)
|
|
356
|
+
}
|
|
357
|
+
const next = raw === null
|
|
358
|
+
? { key, messages: [], metadata: { ...patch } }
|
|
359
|
+
: Array.isArray(raw)
|
|
360
|
+
? { key, messages: raw, metadata: { ...patch } }
|
|
361
|
+
: { ...raw, metadata: { ...(raw?.metadata || {}), ...patch } };
|
|
362
|
+
const tmp = `${file}.tmp`;
|
|
363
|
+
await fs.writeFile(tmp, JSON.stringify(next, null, 2), 'utf-8');
|
|
364
|
+
await fs.rename(tmp, file);
|
|
365
|
+
}
|
|
225
366
|
/** 删一条 session — 失败抛错. */
|
|
226
367
|
async deleteKey(key) {
|
|
227
368
|
if (!key || key.includes('/') || key.includes('..')) {
|
|
@@ -83,7 +83,13 @@ const FALLBACK_PATH_DENYLIST = [
|
|
|
83
83
|
/(^|\/)tsconfig.*\.json$/,
|
|
84
84
|
/(^|\/)\.env(\.|$)/,
|
|
85
85
|
/(^|\/)\.git\//,
|
|
86
|
-
|
|
86
|
+
// 2026-10-01 (用户实测): 原先这行把**整个** ~/.bolloon/ 一刀封死 ⇒ patch 写自己的
|
|
87
|
+
// context-os/01-Me/… 被拒, 而同一目录 write_context_asset(Node fs) 却成功 ✗ —— 门在打自己的脚。
|
|
88
|
+
// 收窄口径: 只封**凭证 / 身份密钥 / 会话数据 / 钱包**; agent 自己的工作区
|
|
89
|
+
// (context-os · persona · memory · logs · goals · runs …) 一律放行。
|
|
90
|
+
/(^|\/)\.bolloon\/(identity\.json|keypair\.json|accounts\.json|llm-config\.json|bolloon-config\.json|agent-keys\/|iroh-secret[^/]*\.json|chain\.json)/,
|
|
91
|
+
/(^|\/)\.bolloon\/(sessions|wallets|bindings|eas-bind|_migrate_bak_[^/]*)\//,
|
|
92
|
+
/(^|\/)\.bolloon\/[^/]*\.token$/,
|
|
87
93
|
/(^|\/)dist\//,
|
|
88
94
|
// 2026-06-17: node_modules 不再 denylist, 因为 M3.4 自动 commit 阶段需要 npm install / npx vitest
|
|
89
95
|
// 通过 allowlist 限定 agent 只能 npm install, 不能 rm node_modules (shell arg denylist 仍禁 rm -rf)
|
|
@@ -116,7 +122,19 @@ function getDefaultPolicy() {
|
|
|
116
122
|
return {
|
|
117
123
|
version: 1,
|
|
118
124
|
commandAllowlist: Array.from(FALLBACK_COMMAND_ALLOWLIST),
|
|
119
|
-
|
|
125
|
+
// 2026-10-01: agent 自己的数据工作区必须显式允许 —— 这道护栏是 deny → allow → **默认拒**,
|
|
126
|
+
// 所以光把旧禁区收窄还不够: allowlist 里没有的路径照样被拒 ✗ (用户实测 context-os 被拒就是这个)。
|
|
127
|
+
pathAllowlist: [
|
|
128
|
+
...FALLBACK_PATH_ALLOWLIST,
|
|
129
|
+
...(process.env.HOME ? [
|
|
130
|
+
`${process.env.HOME}/.bolloon/context-os/**`,
|
|
131
|
+
`${process.env.HOME}/.bolloon/persona/**`,
|
|
132
|
+
`${process.env.HOME}/.bolloon/memory/**`,
|
|
133
|
+
`${process.env.HOME}/.bolloon/logs/**`,
|
|
134
|
+
`${process.env.HOME}/.bolloon/goals/**`,
|
|
135
|
+
`${process.env.HOME}/.bolloon/runs/**`,
|
|
136
|
+
] : []),
|
|
137
|
+
],
|
|
120
138
|
pathDenylist: FALLBACK_PATH_DENYLIST.map(r => r.source),
|
|
121
139
|
cooldownMs: 6 * 60 * 60 * 1000,
|
|
122
140
|
sandboxCwd: '.bolloon-shell-sandbox',
|
|
@@ -127,6 +145,44 @@ function getDefaultPolicy() {
|
|
|
127
145
|
* 加载策略 (有缓存)
|
|
128
146
|
* 加载失败返回 null, 调用方应回退到硬编码兜底
|
|
129
147
|
*/
|
|
148
|
+
/**
|
|
149
|
+
* 已知过时的策略条目 (2026-10-01)。
|
|
150
|
+
*
|
|
151
|
+
* 事故: 盘上 `~/.bolloon/self-improve-policy.json` 是 2026-06-14 写下的**快照**,
|
|
152
|
+
* 里面还留着旧版的宽正则 `(^|\/)\.bolloon\/` —— 它把整个数据目录一刀封死,
|
|
153
|
+
* 于是 `patch ~/.bolloon/context-os/...` 被拒, 而 `write_context_asset`(走 Node fs) 同一目录能写 ✗,
|
|
154
|
+
* 用户看到的就是"同一个目录, 一个工具能写一个不能"。
|
|
155
|
+
* 更糟: 连 2026-06-17 已经明确解禁的 `pi-sdk.ts` 也还封着 ✗ ⇒ **改代码也压不过这份旧策略**。
|
|
156
|
+
* 规矩: 代码里的 FALLBACK_PATH_DENYLIST 是唯一真源; 盘上策略里凡属"过时快照"的条目一律作废并回写。
|
|
157
|
+
*/
|
|
158
|
+
/** agent 自己的数据工作区 (相对 ~/.bolloon) —— 必须在 allowlist 里, 否则默认拒 */
|
|
159
|
+
export const DATA_WORKSPACE_SUBDIRS = ['context-os', 'persona', 'memory', 'logs', 'goals', 'runs'];
|
|
160
|
+
/** 盘上策略缺这些条目时补齐 (与"作废过时条目"对称: 旧策略既要减法也要加法) */
|
|
161
|
+
function ensureWorkspaceAllowed(policy, home) {
|
|
162
|
+
if (!Array.isArray(policy.pathAllowlist))
|
|
163
|
+
policy.pathAllowlist = [];
|
|
164
|
+
let changed = false;
|
|
165
|
+
for (const sub of DATA_WORKSPACE_SUBDIRS) {
|
|
166
|
+
const need = `${home}/.bolloon/${sub}/**`;
|
|
167
|
+
if (!policy.pathAllowlist.includes(need)) {
|
|
168
|
+
policy.pathAllowlist.push(need);
|
|
169
|
+
changed = true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return changed;
|
|
173
|
+
}
|
|
174
|
+
const STALE_POLICY_PATTERNS = [
|
|
175
|
+
'(^|\\/)\\.bolloon\\/', // 旧: 整个数据目录一刀封 (已收窄为凭证/身份/会话/钱包)
|
|
176
|
+
'(^|\\/)src\\/agents\\/pi-sdk\\.ts$', // 旧: 2026-06-17 已解禁 (自进化需要)
|
|
177
|
+
'(^|\\/)node_modules\\/', // 旧: 2026-06-17 已从硬编码清单移除
|
|
178
|
+
];
|
|
179
|
+
function dropStalePolicyPatterns(policy) {
|
|
180
|
+
if (!Array.isArray(policy.pathDenylist) || policy.pathDenylist.length === 0)
|
|
181
|
+
return false;
|
|
182
|
+
const before = policy.pathDenylist.length;
|
|
183
|
+
policy.pathDenylist = policy.pathDenylist.filter((src) => !STALE_POLICY_PATTERNS.includes(String(src)));
|
|
184
|
+
return policy.pathDenylist.length !== before;
|
|
185
|
+
}
|
|
130
186
|
export function loadPolicy(forceReload = false) {
|
|
131
187
|
const now = Date.now();
|
|
132
188
|
if (!forceReload && cachedPolicy && now - policyLoadedAt < POLICY_TTL_MS) {
|
|
@@ -142,6 +198,17 @@ export function loadPolicy(forceReload = false) {
|
|
|
142
198
|
}
|
|
143
199
|
const raw = fs.readFileSync(POLICY_PATH, 'utf-8');
|
|
144
200
|
const parsed = JSON.parse(raw);
|
|
201
|
+
// 2026-10-01: 盘上策略是**快照** —— 先作废已知过时条目 (否则改代码也压不过它), 再回写。
|
|
202
|
+
// 用户实测: `(^|\/)\.bolloon\/` 这条旧宽正则把整个数据目录封死, 而它只存在于盘上策略里。
|
|
203
|
+
try {
|
|
204
|
+
const dropped = dropStalePolicyPatterns(parsed);
|
|
205
|
+
const added = ensureWorkspaceAllowed(parsed, os.homedir());
|
|
206
|
+
if (dropped || added) {
|
|
207
|
+
fs.writeFileSync(POLICY_PATH, JSON.stringify(parsed, null, 2));
|
|
208
|
+
console.log(`[shell-guard] 已作废过时策略条目并回写: ${POLICY_PATH}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch { /* 回写失败不影响本次使用 */ }
|
|
145
212
|
// 极简 schema 校验
|
|
146
213
|
if (!parsed.version || !Array.isArray(parsed.commandAllowlist) || !Array.isArray(parsed.pathAllowlist) || !Array.isArray(parsed.pathDenylist)) {
|
|
147
214
|
console.warn('[shell-guard] 策略文件 schema 不对, 用硬编码兜底');
|
|
@@ -214,7 +281,7 @@ export function checkCommand(cmd, args) {
|
|
|
214
281
|
auditShellCall('denied', cmd, args, `命令 '${cmd}' 不在白名单`);
|
|
215
282
|
return {
|
|
216
283
|
allowed: false,
|
|
217
|
-
reason: `命令 '${cmd}' 不在白名单.
|
|
284
|
+
reason: `命令 '${cmd}' 不在白名单. 允许的命令: ${shortRoots(Array.from(allowSet))}`,
|
|
218
285
|
matchedBy: policy ? 'cmd-allowlist' : 'fallback-deny'
|
|
219
286
|
};
|
|
220
287
|
}
|
|
@@ -297,7 +364,7 @@ export function checkWritePath(targetPath) {
|
|
|
297
364
|
auditShellCall('denied', '', [], `路径 '${targetPath}' 不在任何 allowlist 中`, targetPath);
|
|
298
365
|
return {
|
|
299
366
|
allowed: false,
|
|
300
|
-
reason: `路径 '${targetPath}' 不在白名单.
|
|
367
|
+
reason: `路径 '${targetPath}' 不在白名单. 允许的根: ${shortRoots(allowlist)}`, // 2026-10-01: 不再把整张白名单(含临时目录)塞进错误 ✓
|
|
301
368
|
matchedBy: 'path-allowlist'
|
|
302
369
|
};
|
|
303
370
|
}
|
|
@@ -340,6 +407,19 @@ export const SHELL_SANDBOX_CWD = path.resolve(process.cwd(), '.bolloon-shell-san
|
|
|
340
407
|
// 与 checkCommand 的区别: 不查命令白名单/参数 allowlist — 完整 shell 命令字符串放行,
|
|
341
408
|
// 只挡"高危破坏/碰核心数据"模式. 用户明确: 灵活一点, 少围栏, 核心的东西不碰不搞乱.
|
|
342
409
|
// ============================================================================
|
|
410
|
+
/**
|
|
411
|
+
* 自生命周期模式 (唯一的归类依据, 2026-10-01)。
|
|
412
|
+
* 为什么单列: 报错文案要按"是不是自生命周期"分岔, 原先用"模式串里有没有 bolloon 等词"猜 ⇒
|
|
413
|
+
* 数据目录那条 (串里含 .bolloon) 被误判, 把只读命令报成"会杀死宿主服务" ✗。
|
|
414
|
+
*/
|
|
415
|
+
const SELF_LIFECYCLE_PATTERNS = [
|
|
416
|
+
/\b(bolloon|bolloon-agent|bolloon-agent-service)\s+(restart|stop|kill|quit|shutdown)\b/i,
|
|
417
|
+
/\bpm2\s+(restart|stop|delete|kill)\s+[\w./-]*bolloon\b/i,
|
|
418
|
+
/\b(systemctl|service)\s+(restart|stop|kill)\s+[\w.-]*bolloon\b/i,
|
|
419
|
+
/\bpkill\s+(-[a-z]+\s+)*(-f\s+)?[\w./-]*bolloon\b/i,
|
|
420
|
+
/\btaskkill\s+[^|]*\/IM\s+[\w-]*bolloon\b/i,
|
|
421
|
+
/\btaskkill\s+[^|]*\/FI\s+\"?[^\"]*bolloon/i,
|
|
422
|
+
];
|
|
343
423
|
const TERMINAL_DENY_PATTERNS = [
|
|
344
424
|
/\bsudo\b/, // 提权
|
|
345
425
|
/\bsu\b/,
|
|
@@ -352,8 +432,15 @@ const TERMINAL_DENY_PATTERNS = [
|
|
|
352
432
|
/\bwget\b[^|]*\|\s*(sh|bash)\b/,
|
|
353
433
|
/\b:\(\)\s*\{[^}]*\}\s*;\s*:/, // fork bomb
|
|
354
434
|
/\b(>|>>)\s*(\/etc\/|\/usr\/|\/System\/|\/bin\/|\/sbin\/)/, // 写系统目录
|
|
355
|
-
/[\/\s]\.bolloon\b
|
|
356
|
-
/
|
|
435
|
+
// 2026-10-01 (用户报「偶尔会出反水 bug」): 原先 /[\/\s]\.bolloon\b/ 会匹配**任何**含 .bolloon 的路径
|
|
436
|
+
// ⇒ 连 `ls ~/.bolloon/`、`cat .bolloon/x`、`grep -rn x .bolloon/` 这种**只读**命令都被拒;
|
|
437
|
+
// 而且因为模式串里带 "bolloon", 还会被下面的分类逻辑误判成"自生命周期命令", 打出
|
|
438
|
+
// "命令会重启或杀死 bolloon 宿主服务" —— 门在打自己的脚。
|
|
439
|
+
// 收窄口径: **只在删/移/截断/重定向写入**这些数据目录时拒; 读 (ls/cat/grep/find/du/stat/mkdir -p) 一律放行。
|
|
440
|
+
/\b(rm|rmdir|mv|truncate|shred)\b[^|;]*[\s/]\.bolloon\b/,
|
|
441
|
+
/(>|>>)\s*[^\s]*[\s/]?\.bolloon\b/,
|
|
442
|
+
/\b(rm|rmdir|mv|truncate|shred)\b[^|;]*[\s/]\.(diap|hermes|openclaw)\b/, // 其他 agent 数据 (同样只拦写/删)
|
|
443
|
+
/(>|>>)\s*[^\s]*[\s/]?\.(diap|hermes|openclaw)\b/,
|
|
357
444
|
/\brm\s+-rf\s+(\/|~|\*|\.|\$HOME)\b/, // 删根/家/通配
|
|
358
445
|
/\bgit\s+push\b[^|]*\s+(-f|--force)/, // 强推
|
|
359
446
|
/\bgit\s+reset\s+--hard\b/,
|
|
@@ -361,12 +448,7 @@ const TERMINAL_DENY_PATTERNS = [
|
|
|
361
448
|
// 2026-08-11 (Hermes cron/lifecycle_guard 模式): 自生命周期命令 — agent 通过 terminal
|
|
362
449
|
// 重启/杀掉自己宿主服务 (bolloon) 会形成复活循环: 服务死 → supervisor 复活 → 自动 resume
|
|
363
450
|
// → 同一命令再自杀. 策略: 命令形状锚定 (只匹配真实命令标识符, 不匹配散文, 误报率低).
|
|
364
|
-
|
|
365
|
-
/\bpm2\s+(restart|stop|delete|kill)\s+[\w./-]*bolloon\b/i,
|
|
366
|
-
/\b(systemctl|service)\s+(restart|stop|kill)\s+[\w.-]*bolloon\b/i,
|
|
367
|
-
/\bpkill\s+(-[a-z]+\s+)*(-f\s+)?[\w./-]*bolloon\b/i,
|
|
368
|
-
/\btaskkill\s+[^|]*\/IM\s+[\w-]*bolloon\b/i,
|
|
369
|
-
/\btaskkill\s+[^|]*\/FI\s+"?[^"]*bolloon/i,
|
|
451
|
+
...SELF_LIFECYCLE_PATTERNS,
|
|
370
452
|
];
|
|
371
453
|
/** 检查完整 shell 命令 (terminal 工具用). denylist-only: 命中高危模式即拒, 否则放行. */
|
|
372
454
|
export function checkTerminalCommand(rawCmd) {
|
|
@@ -379,7 +461,9 @@ export function checkTerminalCommand(rawCmd) {
|
|
|
379
461
|
// 2026-08-11: 用户可见静态文案走 i18n 目录 (Hermes locales 模式); 目录不可用时中文直出
|
|
380
462
|
let reason;
|
|
381
463
|
try {
|
|
382
|
-
|
|
464
|
+
// 2026-10-01: 归类改成**显式名单** —— 原先靠"模式串里有没有 bolloon 等词"猜, 于是
|
|
465
|
+
// 数据目录那条 (串里含 .bolloon) 被误判成自生命周期, 报错文案完全指错方向 ✗。
|
|
466
|
+
const isSelfLifecycle = SELF_LIFECYCLE_PATTERNS.includes(pattern);
|
|
383
467
|
reason = isSelfLifecycle
|
|
384
468
|
? i18nT('guard.self_lifecycle_reason', undefined, { pattern: String(pattern) })
|
|
385
469
|
: i18nT('guard.deny_reason', undefined, { pattern: String(pattern) });
|
|
@@ -417,3 +501,15 @@ export function writePolicy(newPolicy) {
|
|
|
417
501
|
return false;
|
|
418
502
|
}
|
|
419
503
|
}
|
|
504
|
+
/**
|
|
505
|
+
* 错误信息里的白名单**短形态** (2026-10-01)。
|
|
506
|
+
* 为什么: 原来把**整张白名单**拼进 `reason` ✗ ⇒ 这条报错随后被塞进
|
|
507
|
+
* ① 反思框(用户看到一屏白名单 ✗) ② 最终回复框(看着像"回复不完整" ✗ —— 其实是白名单切片 ✗)
|
|
508
|
+
* ③ 熔断判定(长文本撞上无关关键词 ⇒ 被误判成"鉴权类错误" ⇒ 直接熔断停在 needs_human ✗)。
|
|
509
|
+
* 做法: 滤掉临时目录噪音(不该出现在给人看的错误里 ✓) + 只列前若干条 + 给总数 ✓。
|
|
510
|
+
*/
|
|
511
|
+
function shortRoots(entries, max = 6) {
|
|
512
|
+
const clean = entries.filter((e) => !/bolloon-bootstrap|\/var\/folders\/|\/tmp\//.test(e));
|
|
513
|
+
const shown = clean.slice(0, max).join(', ');
|
|
514
|
+
return clean.length > max ? `${shown} …(共 ${clean.length} 条)` : (shown || '(空)');
|
|
515
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 技能用量遥测 + 生命周期 (2026-10-01)。
|
|
3
|
+
* 形态要点: 遥测放**旁挂文件** ✓ —— 绝不写进用户自己写的 SKILL.md ✗(那是用户的文件 ✓)。
|
|
4
|
+
* 生命周期: active → stale(久未用) → archived; `pinned` 可退出自动流转 ✓。
|
|
5
|
+
* 边界(如实): 本模块只**计算状态并给建议** ✓, **不自动移动/删除任何技能文件** ✗(那是破坏性动作, 交人决定 ✓)。
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
export function usagePath(home = os.homedir()) {
|
|
11
|
+
return path.join(home, '.bolloon', 'skills', '.usage.json');
|
|
12
|
+
}
|
|
13
|
+
function load(home) {
|
|
14
|
+
try {
|
|
15
|
+
const o = JSON.parse(fs.readFileSync(usagePath(home), 'utf-8'));
|
|
16
|
+
return o && typeof o === 'object' ? o : {};
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function save(m, home) {
|
|
23
|
+
try {
|
|
24
|
+
const p = usagePath(home);
|
|
25
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
26
|
+
fs.writeFileSync(p, JSON.stringify(m), { mode: 0o600 });
|
|
27
|
+
}
|
|
28
|
+
catch { /* 遥测失败不影响工具调用 */ }
|
|
29
|
+
}
|
|
30
|
+
/** 用了一次(读技能时调 ✓); 失败只吞 ✓ */
|
|
31
|
+
export function bumpUsage(skill, home, now = Date.now()) {
|
|
32
|
+
try {
|
|
33
|
+
const m = load(home);
|
|
34
|
+
const cur = m[skill] || { count: 0, lastUsedAt: 0 };
|
|
35
|
+
m[skill] = { ...cur, count: cur.count + 1, lastUsedAt: now };
|
|
36
|
+
save(m, home);
|
|
37
|
+
}
|
|
38
|
+
catch { /* best-effort */ }
|
|
39
|
+
}
|
|
40
|
+
/** 记下创建者(自审 / 用户点名) */
|
|
41
|
+
export function markCreatedBy(skill, by, home, now = Date.now()) {
|
|
42
|
+
try {
|
|
43
|
+
const m = load(home);
|
|
44
|
+
const cur = m[skill] || { count: 0, lastUsedAt: 0 };
|
|
45
|
+
m[skill] = { ...cur, createdBy: by, lastUsedAt: cur.lastUsedAt || now };
|
|
46
|
+
save(m, home);
|
|
47
|
+
}
|
|
48
|
+
catch { /* best-effort */ }
|
|
49
|
+
}
|
|
50
|
+
/** 钉住(退出自动流转) */
|
|
51
|
+
export function setPinned(skill, pinned, home) {
|
|
52
|
+
try {
|
|
53
|
+
const m = load(home);
|
|
54
|
+
const cur = m[skill] || { count: 0, lastUsedAt: 0 };
|
|
55
|
+
m[skill] = { ...cur, pinned };
|
|
56
|
+
save(m, home);
|
|
57
|
+
}
|
|
58
|
+
catch { /* best-effort */ }
|
|
59
|
+
}
|
|
60
|
+
export const STALE_AFTER_MS = 45 * 24 * 3600 * 1000; // 45 天没用 ⇒ stale
|
|
61
|
+
/** 生命周期(只算不给动作 ✓): active / stale / archived-eligible */
|
|
62
|
+
export function lifecycleOf(skill, home, now = Date.now()) {
|
|
63
|
+
const u = load(home)[skill];
|
|
64
|
+
if (!u)
|
|
65
|
+
return { state: 'unknown', reason: '旁挂遥测里没有它(可能是导入/内置技能)', count: 0, pinned: false };
|
|
66
|
+
if (u.pinned)
|
|
67
|
+
return { state: 'active', reason: '已 pinned(退出自动流转)', count: u.count, pinned: true };
|
|
68
|
+
const idle = now - (u.lastUsedAt || 0);
|
|
69
|
+
return {
|
|
70
|
+
state: idle > STALE_AFTER_MS ? 'stale' : 'active',
|
|
71
|
+
reason: idle > STALE_AFTER_MS ? `已 ${Math.round(idle / 86400000)} 天未用(仅状态, 不会自动删 ✓)` : `近用过(${u.count} 次)`,
|
|
72
|
+
count: u.count,
|
|
73
|
+
pinned: false,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** 汇总(给面板/工具用 ✓) */
|
|
77
|
+
export function usageSummary(home, now = Date.now()) {
|
|
78
|
+
const m = load(home);
|
|
79
|
+
let active = 0, stale = 0, pinned = 0;
|
|
80
|
+
for (const k of Object.keys(m)) {
|
|
81
|
+
const l = lifecycleOf(k, home, now);
|
|
82
|
+
if (l.pinned)
|
|
83
|
+
pinned++;
|
|
84
|
+
if (l.state === 'active')
|
|
85
|
+
active++;
|
|
86
|
+
else if (l.state === 'stale')
|
|
87
|
+
stale++;
|
|
88
|
+
}
|
|
89
|
+
return { total: Object.keys(m).length, active, stale, pinned };
|
|
90
|
+
}
|