@bolloon/bolloon-agent 0.4.0 → 0.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/dist/cron/cron-parser.js +142 -0
- package/dist/cron/jobs-store.js +96 -0
- package/dist/cron/scheduler.js +72 -0
- package/dist/cron/suggestion-catalog.js +41 -0
- package/dist/cron/suggestions-command.js +96 -0
- package/dist/cron/suggestions.js +110 -0
- package/dist/documents/reader.js +9 -4
- package/dist/index.js +107 -0
- package/dist/llm/error-lessons.js +16 -0
- package/dist/llm/pi-ai.js +32 -7
- package/dist/web/client.js +22 -16
- package/dist/web/loop-noise.js +86 -0
- package/dist/web/server-sse.js +10 -5
- package/package.json +37 -38
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cron-parser.ts — cron 表达式 / 间隔解析 + 下次运行时间计算
|
|
3
|
+
*
|
|
4
|
+
* 借鉴 Hermes agent cron/scheduler.py 的调度心智:
|
|
5
|
+
* - 支持标准 5 段 cron ("0 8 * * *") 与人类可读间隔 ("every 30m", "1h", "30s")
|
|
6
|
+
* - 纯函数, 无 IO, 可单测: parseSchedule(schedule, from) → 下次触发 Date
|
|
7
|
+
* - schedule 为空 / 解析失败 → 返回 null (调用方决定不调度或报错)
|
|
8
|
+
*/
|
|
9
|
+
/** "every 30m" / "every 1h" / "30s" / "1h" / "90s" → 毫秒; 其它返回 null */
|
|
10
|
+
function _parseIntervalMs(raw) {
|
|
11
|
+
const m = raw.toLowerCase().trim().match(/^every\s+(\d+)\s*(s|m|h|d)?$|^(\d+)\s*(s|m|h|d)$/);
|
|
12
|
+
if (!m)
|
|
13
|
+
return null;
|
|
14
|
+
const num = Number(m[1] ?? m[3]);
|
|
15
|
+
const unit = (m[2] ?? m[4]) || 'm';
|
|
16
|
+
const mult = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
17
|
+
return num * mult[unit];
|
|
18
|
+
}
|
|
19
|
+
/** 把 5 段 cron 拆成字段集合. 支持 *, 数字, 逗号, 范围, 步进 (仅分钟/小时/月/周日). */
|
|
20
|
+
function _parseCronFields(expr) {
|
|
21
|
+
const parts = expr.trim().split(/\s+/);
|
|
22
|
+
if (parts.length !== 5)
|
|
23
|
+
return null;
|
|
24
|
+
const parse = (field, lo, hi) => {
|
|
25
|
+
const set = new Set();
|
|
26
|
+
for (const seg of field.split(',')) {
|
|
27
|
+
if (seg === '*') {
|
|
28
|
+
for (let v = lo; v <= hi; v++)
|
|
29
|
+
set.add(v);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
let step = 1;
|
|
33
|
+
let range = seg;
|
|
34
|
+
if (seg.includes('/')) {
|
|
35
|
+
const [r, s] = seg.split('/');
|
|
36
|
+
range = r;
|
|
37
|
+
step = Number(s) || 1;
|
|
38
|
+
}
|
|
39
|
+
let start = lo, end = hi;
|
|
40
|
+
if (range === '*') {
|
|
41
|
+
start = lo;
|
|
42
|
+
end = hi;
|
|
43
|
+
}
|
|
44
|
+
else if (range.includes('-')) {
|
|
45
|
+
const [a, b] = range.split('-');
|
|
46
|
+
start = Number(a);
|
|
47
|
+
end = Number(b);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
start = end = Number(range);
|
|
51
|
+
}
|
|
52
|
+
if (Number.isNaN(start) || Number.isNaN(end) || Number.isNaN(step))
|
|
53
|
+
return null;
|
|
54
|
+
for (let v = start; v <= end; v += step)
|
|
55
|
+
set.add(v);
|
|
56
|
+
}
|
|
57
|
+
return set;
|
|
58
|
+
};
|
|
59
|
+
const min = parse(parts[0], 0, 59);
|
|
60
|
+
const hour = parse(parts[1], 0, 23);
|
|
61
|
+
const dom = parse(parts[2], 1, 31);
|
|
62
|
+
const mon = parse(parts[3], 1, 12);
|
|
63
|
+
const dow = parse(parts[4], 0, 6);
|
|
64
|
+
if (!min || !hour || !dom || !mon || !dow)
|
|
65
|
+
return null;
|
|
66
|
+
return { min, hour, dom, mon, dow };
|
|
67
|
+
}
|
|
68
|
+
function _matchesCron(d, f) {
|
|
69
|
+
const domMatch = f.dom.has(d.getDate());
|
|
70
|
+
const dowMatch = f.dow.has(d.getDay());
|
|
71
|
+
// 经典 cron 语义: dom 与 dow 都受限时用 OR, 否则各自 AND
|
|
72
|
+
const domRestricted = !f.dom.has(31) && f.dom.size < 31;
|
|
73
|
+
const dowRestricted = f.dow.size < 7;
|
|
74
|
+
const dayOk = domRestricted && dowRestricted ? domMatch || dowMatch : domMatch && dowMatch;
|
|
75
|
+
return (dayOk &&
|
|
76
|
+
f.mon.has(d.getMonth() + 1) &&
|
|
77
|
+
f.hour.has(d.getHours()) &&
|
|
78
|
+
f.min.has(d.getMinutes()));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 解析 schedule 并计算下次触发时间.
|
|
82
|
+
* @param schedule "0 8 * * *" / "every 30m" / "1h" / "30s"
|
|
83
|
+
* @param from 基准时间 (默认 now)
|
|
84
|
+
* @returns ParsedSchedule 或 null (无法解析 / 未来 1 年内无匹配)
|
|
85
|
+
*/
|
|
86
|
+
export function parseSchedule(schedule, from = new Date()) {
|
|
87
|
+
const raw = (schedule || '').trim();
|
|
88
|
+
if (!raw)
|
|
89
|
+
return null;
|
|
90
|
+
// 间隔形式 (最快路径)
|
|
91
|
+
const intervalMs = _parseIntervalMs(raw);
|
|
92
|
+
if (intervalMs != null && intervalMs > 0) {
|
|
93
|
+
return { kind: 'interval', intervalMs, next: new Date(from.getTime() + intervalMs) };
|
|
94
|
+
}
|
|
95
|
+
// 5 段 cron: 从 from 起逐分钟扫描, 找下一个匹配 (上限 1 年避免死循环)
|
|
96
|
+
const fields = _parseCronFields(raw);
|
|
97
|
+
if (!fields)
|
|
98
|
+
return null;
|
|
99
|
+
const cursor = new Date(from);
|
|
100
|
+
cursor.setSeconds(0, 0);
|
|
101
|
+
cursor.setMinutes(cursor.getMinutes() + 1); // 严格 "下一次", 不含当前分
|
|
102
|
+
const capMs = from.getTime() + 366 * 86_400_000;
|
|
103
|
+
while (cursor.getTime() < capMs) {
|
|
104
|
+
if (_matchesCron(cursor, fields)) {
|
|
105
|
+
return { kind: 'cron', expr: raw, next: cursor, fields };
|
|
106
|
+
}
|
|
107
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 刷新一次调度: 给定 schedule 与上次触发, 返回本次应跑的触发点.
|
|
113
|
+
* 间隔型 → 以 lastRunAt 为基准叠加; 无 lastRunAt (首次) → 立即 due (返回 now).
|
|
114
|
+
* cron 型 → 从 max(lastRunAt, now) 起找匹配; 上次触发已过期 → 立即 due.
|
|
115
|
+
*/
|
|
116
|
+
export function nextAfter(schedule, lastRunAt, now = new Date()) {
|
|
117
|
+
const raw = (schedule || '').trim();
|
|
118
|
+
if (!raw)
|
|
119
|
+
return null;
|
|
120
|
+
const intervalMs = _parseIntervalMs(raw);
|
|
121
|
+
if (intervalMs != null && intervalMs > 0) {
|
|
122
|
+
if (!lastRunAt)
|
|
123
|
+
return now; // 首次: 立即触发
|
|
124
|
+
return new Date(lastRunAt.getTime() + intervalMs);
|
|
125
|
+
}
|
|
126
|
+
// cron 型: 依托上次触发后的下一次, 与"当前墙钟"比较
|
|
127
|
+
const fields = _parseCronFields(raw);
|
|
128
|
+
if (!fields)
|
|
129
|
+
return null;
|
|
130
|
+
const base = lastRunAt && lastRunAt > now ? new Date(lastRunAt) : new Date(now);
|
|
131
|
+
base.setSeconds(0, 0);
|
|
132
|
+
let cursor = new Date(base);
|
|
133
|
+
if (lastRunAt)
|
|
134
|
+
cursor.setMinutes(cursor.getMinutes() + 1); // 严格晚于上次触发
|
|
135
|
+
const capMs = base.getTime() + 366 * 86_400_000;
|
|
136
|
+
while (cursor.getTime() < capMs) {
|
|
137
|
+
if (_matchesCron(cursor, fields))
|
|
138
|
+
return cursor;
|
|
139
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jobs-store.ts — 定时任务 (schedule jobs) 持久化
|
|
3
|
+
*
|
|
4
|
+
* 借鉴 Hermes agent cron/scheduler.py 心智:
|
|
5
|
+
* - 每条 job 有: id / name / schedule (cron 或 every N) / prompt (执行体) / enabled
|
|
6
|
+
* - 记录 lastRunAt / runCount, 调度器 tick 时找出 due 的 job 执行
|
|
7
|
+
* - 落盘 ~/.bolloon/cron-jobs.json, 进程内互斥链序列化 + 临时文件原子写
|
|
8
|
+
* - 不建数据库, 不引入外部调度系统 —— 单文件 + 内存互斥
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from 'fs/promises';
|
|
11
|
+
import * as os from 'os';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import * as crypto from 'crypto';
|
|
14
|
+
function getJobsPath(home = os.homedir()) {
|
|
15
|
+
return path.join(home, '.bolloon', 'cron-jobs.json');
|
|
16
|
+
}
|
|
17
|
+
let jobsLock = Promise.resolve();
|
|
18
|
+
function withLock(fn) {
|
|
19
|
+
const run = jobsLock.then(fn);
|
|
20
|
+
jobsLock = run.then(() => undefined, () => undefined);
|
|
21
|
+
return run;
|
|
22
|
+
}
|
|
23
|
+
async function readAll(home) {
|
|
24
|
+
try {
|
|
25
|
+
const raw = await fs.readFile(getJobsPath(home), 'utf-8');
|
|
26
|
+
const p = JSON.parse(raw);
|
|
27
|
+
if (p?.version === 1 && Array.isArray(p.jobs))
|
|
28
|
+
return p;
|
|
29
|
+
return { version: 1, jobs: [] };
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return { version: 1, jobs: [] };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function writeAll(file, home) {
|
|
36
|
+
const p = getJobsPath(home);
|
|
37
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
38
|
+
const tmp = p + '.tmp';
|
|
39
|
+
await fs.writeFile(tmp, JSON.stringify(file, null, 2), 'utf-8');
|
|
40
|
+
await fs.rename(tmp, p);
|
|
41
|
+
}
|
|
42
|
+
export async function addJob(input, home = os.homedir()) {
|
|
43
|
+
return withLock(async () => {
|
|
44
|
+
const file = await readAll(home);
|
|
45
|
+
const job = {
|
|
46
|
+
id: crypto.randomUUID(),
|
|
47
|
+
name: input.name,
|
|
48
|
+
schedule: input.schedule,
|
|
49
|
+
prompt: input.prompt,
|
|
50
|
+
enabled: true,
|
|
51
|
+
createdAt: new Date().toISOString(),
|
|
52
|
+
runCount: 0,
|
|
53
|
+
};
|
|
54
|
+
file.jobs.push(job);
|
|
55
|
+
await writeAll(file, home);
|
|
56
|
+
return job;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
export async function listJobs(home = os.homedir()) {
|
|
60
|
+
return withLock(async () => (await readAll(home)).jobs);
|
|
61
|
+
}
|
|
62
|
+
export async function removeJob(id, home = os.homedir()) {
|
|
63
|
+
return withLock(async () => {
|
|
64
|
+
const file = await readAll(home);
|
|
65
|
+
const before = file.jobs.length;
|
|
66
|
+
file.jobs = file.jobs.filter((j) => j.id !== id);
|
|
67
|
+
if (file.jobs.length === before)
|
|
68
|
+
return false;
|
|
69
|
+
await writeAll(file, home);
|
|
70
|
+
return true;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export async function setEnabled(id, enabled, home = os.homedir()) {
|
|
74
|
+
return withLock(async () => {
|
|
75
|
+
const file = await readAll(home);
|
|
76
|
+
const job = file.jobs.find((j) => j.id === id);
|
|
77
|
+
if (!job)
|
|
78
|
+
return null;
|
|
79
|
+
job.enabled = enabled;
|
|
80
|
+
await writeAll(file, home);
|
|
81
|
+
return job;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/** 标记已运行: 更新 lastRunAt + runCount (runId 用于并行保护, 由 scheduler 提供) */
|
|
85
|
+
export async function markRun(id, at, home = os.homedir()) {
|
|
86
|
+
return withLock(async () => {
|
|
87
|
+
const file = await readAll(home);
|
|
88
|
+
const job = file.jobs.find((j) => j.id === id);
|
|
89
|
+
if (!job)
|
|
90
|
+
return null;
|
|
91
|
+
job.lastRunAt = at;
|
|
92
|
+
job.runCount += 1;
|
|
93
|
+
await writeAll(file, home);
|
|
94
|
+
return job;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler.ts — 定时任务调度器
|
|
3
|
+
*
|
|
4
|
+
* 借鉴 Hermes agent cron/scheduler.py 心智:
|
|
5
|
+
* - 一个 tick() 扫描全部 enabled jobs, 用 parseSchedule 判断是否 due (下次时间 <= now)
|
|
6
|
+
* - due → 执行 exec(job), 完成后 markRun 更新 lastRunAt
|
|
7
|
+
* - 并发保护: 正在运行的 jobId 集合, 避免同一 job 并行重复触发
|
|
8
|
+
* - exec 可注入: bolloon CLI 里把 job.prompt 投给 agent 调度闭环, 测试里可注入 fake
|
|
9
|
+
* - 不建数据库, 不引入外部调度 —— 由调用方 (CLI 主循环) 定期调 tick()
|
|
10
|
+
*/
|
|
11
|
+
import * as os from 'os';
|
|
12
|
+
import { listJobs, markRun } from './jobs-store.js';
|
|
13
|
+
import { parseSchedule, nextAfter } from './cron-parser.js';
|
|
14
|
+
export class Scheduler {
|
|
15
|
+
exec;
|
|
16
|
+
now;
|
|
17
|
+
home;
|
|
18
|
+
/** 正在运行的 job id, 防止并行重复触发 */
|
|
19
|
+
running = new Set();
|
|
20
|
+
/** 已触发但执行失败, 待下次重试 (避免死循环 + 可观测) */
|
|
21
|
+
failed = new Map();
|
|
22
|
+
constructor(opts) {
|
|
23
|
+
this.exec = opts.exec;
|
|
24
|
+
this.now = opts.now ?? (() => new Date());
|
|
25
|
+
this.home = opts.home ?? os.homedir();
|
|
26
|
+
}
|
|
27
|
+
/** 是否正在运行某 job (供外部查询并发状态) */
|
|
28
|
+
isRunning(jobId) {
|
|
29
|
+
return this.running.has(jobId);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 一次调度心跳. 找出所有 due 的 enabled job 并执行 (串行), 成功者 markRun.
|
|
33
|
+
* @returns 本次执行数量
|
|
34
|
+
*/
|
|
35
|
+
async tick() {
|
|
36
|
+
const now = this.now();
|
|
37
|
+
const jobs = await listJobs(this.home);
|
|
38
|
+
let ran = 0;
|
|
39
|
+
for (const job of jobs) {
|
|
40
|
+
if (!job.enabled)
|
|
41
|
+
continue;
|
|
42
|
+
if (this.running.has(job.id))
|
|
43
|
+
continue; // 正在跑, 跳过防重入
|
|
44
|
+
const parsed = parseSchedule(job.schedule, now);
|
|
45
|
+
if (!parsed)
|
|
46
|
+
continue; // 解析不了的调度, 跳过 (可另做告警)
|
|
47
|
+
const lastRunAt = job.lastRunAt ? new Date(job.lastRunAt) : undefined;
|
|
48
|
+
const due = nextAfter(job.schedule, lastRunAt, now);
|
|
49
|
+
if (due && due.getTime() > now.getTime())
|
|
50
|
+
continue; // 还不到时间 (含首次: 立即触发)
|
|
51
|
+
this.running.add(job.id);
|
|
52
|
+
try {
|
|
53
|
+
await this.exec(job);
|
|
54
|
+
await markRun(job.id, now.toISOString(), this.home);
|
|
55
|
+
this.failed.delete(job.id);
|
|
56
|
+
ran += 1;
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
this.failed.set(job.id, (this.failed.get(job.id) ?? 0) + 1);
|
|
60
|
+
console.error(`[scheduler] job 执行失败 (${job.name}):`, e?.message ?? e);
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
this.running.delete(job.id);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return ran;
|
|
67
|
+
}
|
|
68
|
+
/** 最近失败统计 (供诊断) */
|
|
69
|
+
failureCounts() {
|
|
70
|
+
return this.failed;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* suggestion-catalog.ts — 内置"下一动作"建议目录
|
|
3
|
+
*
|
|
4
|
+
* 借鉴 Hermes agent suggestion_catalog.py 心智:
|
|
5
|
+
* - 不是让用户每次从零想"该做什么", 而是提供一组成熟、可一配置就用的自动化/策略建议
|
|
6
|
+
* - 每个条目是 { dedupKey, title, summary, schedule?, prompt? }
|
|
7
|
+
* - CLI `/suggestions catalog` 可列出, 用户任选自动装机 (装成 suggestion 或 cron job)
|
|
8
|
+
*/
|
|
9
|
+
export const SUGGESTION_CATALOG = [
|
|
10
|
+
{
|
|
11
|
+
dedupKey: 'daily-review',
|
|
12
|
+
title: '每日工作复盘',
|
|
13
|
+
summary: '每天工作结束后 5 分钟, 让 agent 总结当天做的事、遗留事项、并写进 wiki 日志。',
|
|
14
|
+
schedule: '0 18 * * *',
|
|
15
|
+
prompt: '对今天的会话做一次工作复盘: 列出完成的任务、遗留的阻塞项、并追加一行到 wiki 日志。',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
dedupKey: 'weekly-planner',
|
|
19
|
+
title: '每周计划',
|
|
20
|
+
summary: '每周一早上生成本周重点任务清单, 纳入当前计划。',
|
|
21
|
+
schedule: '0 9 * * 1',
|
|
22
|
+
prompt: '生成本周计划: 结合最近 wiki 状态与未完成任务, 列出 3-5 条本周重点。',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
dedupKey: 'wiki-sync',
|
|
26
|
+
title: 'Wiki 一致性检查',
|
|
27
|
+
summary: '定期跑 wiki lint / stale 检查, 发现过期页面就建议修正。',
|
|
28
|
+
schedule: '0 12 * * 5',
|
|
29
|
+
prompt: '运行一次 wiki 一致性检查 (stale_report/wiki_lint), 把发现的过期页面整理成待办建议。',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
dedupKey: 'version-check',
|
|
33
|
+
title: '版本更新检查',
|
|
34
|
+
summary: '定期检查 bolloon 是否有新版本并提示升级。',
|
|
35
|
+
schedule: 'every 1d',
|
|
36
|
+
prompt: '检查 bolloon 是否有新版本, 若有则提示用户升级。',
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
export function findCatalogEntry(dedupKey) {
|
|
40
|
+
return SUGGESTION_CATALOG.find((c) => c.dedupKey === dedupKey);
|
|
41
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* suggestions-command.ts — `/suggestions` CLI 子命令处理 (纯函数返回文本)
|
|
3
|
+
*
|
|
4
|
+
* 借鉴 Hermes agent suggestions_cmd.py 心智: 一个入口, 多个子动作, 全部返回可回显的文本.
|
|
5
|
+
* 用法:
|
|
6
|
+
* /suggestions → 列出当前待处理建议
|
|
7
|
+
* /suggestions accept <n> → 接受第 n 条 (转成任务 / 消费)
|
|
8
|
+
* /suggestions dismiss <n>
|
|
9
|
+
* /suggestions clear → 全部清空
|
|
10
|
+
* /suggestions catalog → 列出内置建议目录
|
|
11
|
+
* /suggestions install <dedupKey> → 从目录装机 (生成 suggestion 或 cron job)
|
|
12
|
+
*
|
|
13
|
+
* 返回 [text, consumedSuggestionId?]; 无副作用逻辑放在 store, 本模块只做编排与文本渲染.
|
|
14
|
+
*/
|
|
15
|
+
import { listSuggestions, acceptSuggestion, dismissSuggestion, clearSuggestions, addSuggestion, } from './suggestions.js';
|
|
16
|
+
import { addJob } from './jobs-store.js';
|
|
17
|
+
import { findCatalogEntry, SUGGESTION_CATALOG } from './suggestion-catalog.js';
|
|
18
|
+
function noArgs() {
|
|
19
|
+
return '用法: /suggestions \u3000| accept <n> \u3000| dismiss <n> \u3000| clear \u3000| catalog \u3000| install <dedupKey>';
|
|
20
|
+
}
|
|
21
|
+
function renderList(items) {
|
|
22
|
+
if (items.length === 0)
|
|
23
|
+
return '当前没有待处理建议。\n\n可用: /suggestions catalog 查看内置建议目录。';
|
|
24
|
+
const lines = items.map((s, i) => `${i + 1}. [${s.source}] ${s.summary}${s.dueAt ? ` (到期 ${s.dueAt})` : ''}`);
|
|
25
|
+
return lines.join('\n') + `\n\n用 /suggestions accept <n> 接受, dismiss <n> 忽略, clear 全清。`;
|
|
26
|
+
}
|
|
27
|
+
export function renderCatalog() {
|
|
28
|
+
return '内置建议目录:\n' +
|
|
29
|
+
SUGGESTION_CATALOG.map((c) => `- ${c.dedupKey}: ${c.title} — ${c.summary}`).join('\n') +
|
|
30
|
+
'\n\n用 /suggestions install <dedupKey> 装机。';
|
|
31
|
+
}
|
|
32
|
+
export async function handleSuggestionsCommand(args, home) {
|
|
33
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
34
|
+
const action = parts[0]?.toLowerCase() ?? '';
|
|
35
|
+
switch (action) {
|
|
36
|
+
case '':
|
|
37
|
+
case 'list': {
|
|
38
|
+
const items = await listSuggestions(home);
|
|
39
|
+
return { text: renderList(items) };
|
|
40
|
+
}
|
|
41
|
+
case 'accept': {
|
|
42
|
+
const n = Number(parts[1]);
|
|
43
|
+
if (!Number.isInteger(n) || n < 1)
|
|
44
|
+
return { text: '用法: /suggestions accept <n>' };
|
|
45
|
+
const items = await listSuggestions(home);
|
|
46
|
+
const target = items[n - 1];
|
|
47
|
+
if (!target)
|
|
48
|
+
return { text: `没有第 ${n} 条建议。` };
|
|
49
|
+
const accepted = await acceptSuggestion(target.id, home);
|
|
50
|
+
if (!accepted)
|
|
51
|
+
return { text: '该建议已被移除, 请重试。' };
|
|
52
|
+
return {
|
|
53
|
+
text: `已接受: ${accepted.summary}`,
|
|
54
|
+
accepted,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
case 'dismiss': {
|
|
58
|
+
const n = Number(parts[1]);
|
|
59
|
+
if (!Number.isInteger(n) || n < 1)
|
|
60
|
+
return { text: '用法: /suggestions dismiss <n>' };
|
|
61
|
+
const items = await listSuggestions(home);
|
|
62
|
+
const target = items[n - 1];
|
|
63
|
+
if (!target)
|
|
64
|
+
return { text: `没有第 ${n} 条建议。` };
|
|
65
|
+
const ok = await dismissSuggestion(target.id, home);
|
|
66
|
+
return { text: ok ? `已忽略: ${target.summary}` : '忽略失败。' };
|
|
67
|
+
}
|
|
68
|
+
case 'clear': {
|
|
69
|
+
const n = await clearSuggestions(home);
|
|
70
|
+
return { text: `已清空 ${n} 条建议。` };
|
|
71
|
+
}
|
|
72
|
+
case 'catalog': {
|
|
73
|
+
return {
|
|
74
|
+
text: renderCatalog(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
case 'install': {
|
|
78
|
+
const key = parts[1];
|
|
79
|
+
if (!key)
|
|
80
|
+
return { text: '用法: /suggestions install <dedupKey>' };
|
|
81
|
+
const entry = findCatalogEntry(key);
|
|
82
|
+
if (!entry)
|
|
83
|
+
return { text: `目录里没有 ${key}。用 /suggestions catalog 看。` };
|
|
84
|
+
// 有 schedule + prompt → 直接生成 cron job; 否则先塞一条建议
|
|
85
|
+
const s = await addSuggestion({ dedupKey: entry.dedupKey, summary: entry.summary, source: 'system' }, home);
|
|
86
|
+
let extra = '';
|
|
87
|
+
if (entry.schedule && entry.prompt) {
|
|
88
|
+
await addJob({ name: entry.title, schedule: entry.schedule, prompt: entry.prompt }, home);
|
|
89
|
+
extra = ` 并生成了定时任务 (${entry.schedule})。`;
|
|
90
|
+
}
|
|
91
|
+
return { text: `已装机: ${entry.title}${extra}\n(可用 /suggestions dismiss 1 撤销这条建议)` };
|
|
92
|
+
}
|
|
93
|
+
default:
|
|
94
|
+
return { text: noArgs() };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* suggestions.ts — 轻量建议队列 (sticky-note suggestions)
|
|
3
|
+
*
|
|
4
|
+
* 借鉴 Hermes agent cron/suggestions.py + suggestion_catalog.py 心智:
|
|
5
|
+
* - 建议是一个个"待办/提醒/下一动作", 由调度器/策略/系统在合适的时机塞入
|
|
6
|
+
* - 同一 dedup_key 只保留最新的一个 (避免同一提醒重复刷屏)
|
|
7
|
+
* - MAX_PENDING 上限, 超出自动丢最旧的 (队列有界, 不无限膨胀)
|
|
8
|
+
* - 用户可接受 (accept) / 忽略 (dismiss) / 全部清空 (clear)
|
|
9
|
+
* - 每个 suggestion 记住来源 (source) / 摘要 (summary) / 到期 (dueAt?, 可选)
|
|
10
|
+
* - 落盘 ~/.bolloon/suggestions.json, 进程内互斥链序列化 (对齐 server-storage)
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from 'fs/promises';
|
|
13
|
+
import * as os from 'os';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
import * as crypto from 'crypto';
|
|
16
|
+
export const MAX_PENDING = 5; // 借鉴 hermes: 建议队列有界, 最长该数
|
|
17
|
+
function getSuggestionsPath(home = os.homedir()) {
|
|
18
|
+
return path.join(home, '.bolloon', 'suggestions.json');
|
|
19
|
+
}
|
|
20
|
+
// 进程内互斥链: 所有写操作串行, 失败不毒化链 (对齐 server-storage 的 updateChannels 设计)
|
|
21
|
+
let suggestionsLock = Promise.resolve();
|
|
22
|
+
function withLock(fn) {
|
|
23
|
+
const run = suggestionsLock.then(fn);
|
|
24
|
+
suggestionsLock = run.then(() => undefined, () => undefined);
|
|
25
|
+
return run;
|
|
26
|
+
}
|
|
27
|
+
async function readAll(home) {
|
|
28
|
+
try {
|
|
29
|
+
const raw = await fs.readFile(getSuggestionsPath(home), 'utf-8');
|
|
30
|
+
const parsed = JSON.parse(raw);
|
|
31
|
+
if (parsed?.version === 1 && Array.isArray(parsed.items))
|
|
32
|
+
return parsed;
|
|
33
|
+
return { version: 1, items: [] };
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return { version: 1, items: [] };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function writeAll(file, home) {
|
|
40
|
+
const p = getSuggestionsPath(home);
|
|
41
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
42
|
+
// 原子写: 先写临时文件再 rename, 避免半截 JSON (对齐 plan-store 直接重写该校验线)
|
|
43
|
+
const tmp = p + '.tmp';
|
|
44
|
+
await fs.writeFile(tmp, JSON.stringify(file, null, 2), 'utf-8');
|
|
45
|
+
await fs.rename(tmp, p);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* 添加一条建议. 同一 dedupKey 已存在 → 更新其 summary/来源, 并重置插入到队尾; 不超过 MAX_PENDING.
|
|
49
|
+
* @returns 新构建 (或更新后) 的 suggestion
|
|
50
|
+
*/
|
|
51
|
+
export async function addSuggestion(input, home = os.homedir()) {
|
|
52
|
+
return withLock(async () => {
|
|
53
|
+
const file = await readAll(home);
|
|
54
|
+
const existing = file.items.find((s) => s.dedupKey === input.dedupKey);
|
|
55
|
+
const now = new Date().toISOString();
|
|
56
|
+
const entry = {
|
|
57
|
+
id: existing?.id ?? crypto.randomUUID(),
|
|
58
|
+
dedupKey: input.dedupKey,
|
|
59
|
+
summary: input.summary,
|
|
60
|
+
source: input.source,
|
|
61
|
+
relatedTaskId: input.relatedTaskId,
|
|
62
|
+
createdAt: existing?.createdAt ?? now,
|
|
63
|
+
...(input.dueAt ? { dueAt: input.dueAt } : {}),
|
|
64
|
+
};
|
|
65
|
+
file.items = file.items.filter((s) => s.dedupKey !== input.dedupKey);
|
|
66
|
+
file.items.push(entry);
|
|
67
|
+
while (file.items.length > MAX_PENDING)
|
|
68
|
+
file.items.shift(); // 丢最旧的
|
|
69
|
+
await writeAll(file, home);
|
|
70
|
+
return entry;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/** 全部未完成建议 */
|
|
74
|
+
export async function listSuggestions(home = os.homedir()) {
|
|
75
|
+
return withLock(async () => (await readAll(home)).items);
|
|
76
|
+
}
|
|
77
|
+
/** 接受: 从队列移除并返回它 (调用方可据此做后续动作, 如转成 task) */
|
|
78
|
+
export async function acceptSuggestion(id, home = os.homedir()) {
|
|
79
|
+
return withLock(async () => {
|
|
80
|
+
const file = await readAll(home);
|
|
81
|
+
const idx = file.items.findIndex((s) => s.id === id);
|
|
82
|
+
if (idx < 0)
|
|
83
|
+
return null;
|
|
84
|
+
const [removed] = file.items.splice(idx, 1);
|
|
85
|
+
await writeAll(file, home);
|
|
86
|
+
return removed;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/** 忽略: 从队列移除 */
|
|
90
|
+
export async function dismissSuggestion(id, home = os.homedir()) {
|
|
91
|
+
return withLock(async () => {
|
|
92
|
+
const file = await readAll(home);
|
|
93
|
+
const before = file.items.length;
|
|
94
|
+
file.items = file.items.filter((s) => s.id !== id);
|
|
95
|
+
if (file.items.length === before)
|
|
96
|
+
return false;
|
|
97
|
+
await writeAll(file, home);
|
|
98
|
+
return true;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/** 全部清空 */
|
|
102
|
+
export async function clearSuggestions(home = os.homedir()) {
|
|
103
|
+
return withLock(async () => {
|
|
104
|
+
const file = await readAll(home);
|
|
105
|
+
const n = file.items.length;
|
|
106
|
+
file.items = [];
|
|
107
|
+
await writeAll(file, home);
|
|
108
|
+
return n;
|
|
109
|
+
});
|
|
110
|
+
}
|
package/dist/documents/reader.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from 'fs/promises';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
-
import
|
|
3
|
+
import { PDFParse } from 'pdf-parse';
|
|
4
4
|
import mammoth from 'mammoth';
|
|
5
5
|
export class DocumentReader {
|
|
6
6
|
async read(filePath) {
|
|
@@ -55,9 +55,14 @@ export class DocumentReader {
|
|
|
55
55
|
}
|
|
56
56
|
async readPdf(filePath) {
|
|
57
57
|
const dataBuffer = await fs.readFile(filePath);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
const parser = new PDFParse({ data: dataBuffer });
|
|
59
|
+
try {
|
|
60
|
+
const result = await parser.getText();
|
|
61
|
+
return result.text;
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
await parser.destroy();
|
|
65
|
+
}
|
|
61
66
|
}
|
|
62
67
|
async readDocx(filePath) {
|
|
63
68
|
const result = await mammoth.extractRawText({ path: filePath });
|
package/dist/index.js
CHANGED
|
@@ -635,6 +635,36 @@ async function startCLI(comm) {
|
|
|
635
635
|
setTimeout(() => { cliOrganizeHeartbeat?.runOnce().catch(() => { }); }, 3000);
|
|
636
636
|
}
|
|
637
637
|
catch { /* 自动整理启动失败不阻塞 CLI */ }
|
|
638
|
+
// ==================== 2026-08-11: cron 调度心跳 (借鉴 Hermes cron/scheduler.py) ====================
|
|
639
|
+
// 轻量定时任务: 每隔一段时间扫一次 due 的 job, 把 job.prompt 投给 agent 执行.
|
|
640
|
+
// 不阻塞启动, 失败不崩溃 (各自 try/catch), 继承 organize heartbeat 的"静默降级"心智.
|
|
641
|
+
let cliCronTimer = null;
|
|
642
|
+
try {
|
|
643
|
+
const { Scheduler } = await import('./cron/scheduler.js');
|
|
644
|
+
const cronScheduler = new Scheduler({
|
|
645
|
+
now: () => new Date(),
|
|
646
|
+
exec: async (job) => {
|
|
647
|
+
// 借 agent 执行任务 prompt (脱壳为 text 提示). 无 agent / 超时 / 失败 → 抛出交给 scheduler 记录失败
|
|
648
|
+
const a = await Promise.race([
|
|
649
|
+
await getAgent().catch(() => null),
|
|
650
|
+
new Promise((res) => setTimeout(() => res(null), 8000)),
|
|
651
|
+
]).catch(() => null);
|
|
652
|
+
if (!a || typeof a.promptStream !== 'function') {
|
|
653
|
+
throw new Error('无可用 agent, 跳过调度任务');
|
|
654
|
+
}
|
|
655
|
+
await a.promptStream?.(`[cron] ${job.name}: ${job.prompt}`, () => { }, undefined, cliActiveChannelId || undefined);
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
const cronIntervalMs = Number(process.env.BOLLOON_CRON_HEARTBEAT_MS) || 60_000;
|
|
659
|
+
cliCronTimer = setInterval(() => {
|
|
660
|
+
cronScheduler.tick().catch((e) => {
|
|
661
|
+
console.error('[cron] 调度心跳失败 (不影响主流程):', e?.message ?? e);
|
|
662
|
+
});
|
|
663
|
+
}, cronIntervalMs);
|
|
664
|
+
// 启动延迟一轮, 避开启动峰值
|
|
665
|
+
setTimeout(() => { cronScheduler.tick().catch(() => { }); }, 15_000);
|
|
666
|
+
}
|
|
667
|
+
catch { /* cron 调度启动失败不阻塞 CLI */ }
|
|
638
668
|
// Wait on a promise that resolves on Ctrl+C / 双击 Esc
|
|
639
669
|
// (ink-app 的 requestExit 调 __inkRequestExit → resolve, 清理后 process.exit)
|
|
640
670
|
let cliExitResolve = () => { };
|
|
@@ -648,6 +678,8 @@ async function startCLI(comm) {
|
|
|
648
678
|
cliOrganizeHeartbeat?.stop();
|
|
649
679
|
}
|
|
650
680
|
catch { /* 非致命 */ }
|
|
681
|
+
if (cliCronTimer)
|
|
682
|
+
clearInterval(cliCronTimer);
|
|
651
683
|
comm.stop();
|
|
652
684
|
process.exit(0);
|
|
653
685
|
}
|
|
@@ -983,6 +1015,79 @@ async function processInput(input, comm) {
|
|
|
983
1015
|
catch { /* 静默 */ }
|
|
984
1016
|
return;
|
|
985
1017
|
}
|
|
1018
|
+
// /suggestions — 建议队列 (/suggestions [list|accept <n>|dismiss <n>|clear|catalog|install <key>])
|
|
1019
|
+
// 借鉴 Hermes cron/suggestions.py: 有界待办建议, dedup + 用户消费
|
|
1020
|
+
if (cmd === '/suggestions' || cmd.startsWith('/suggestions ')) {
|
|
1021
|
+
try {
|
|
1022
|
+
const { handleSuggestionsCommand } = await import('./cron/suggestions-command.js');
|
|
1023
|
+
const res = await handleSuggestionsCommand(trimmed.slice('/suggestions'.length));
|
|
1024
|
+
appendLine(`${C_ACCENT}◎ 建议${RESET}`);
|
|
1025
|
+
appendLine(res.text);
|
|
1026
|
+
}
|
|
1027
|
+
catch (e) {
|
|
1028
|
+
appendLine(`${C_ERROR}✗ /suggestions 失败: ${String(e.message || e).slice(0, 200)}${RESET}`);
|
|
1029
|
+
}
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
// /cron — 定时任务 (/cron list | add <name> <schedule> <prompt> | rm <id> | on/off <id>)
|
|
1033
|
+
// 借鉴 Hermes cron/scheduler.py: 轻量单文件定时任务 + 调度 tick
|
|
1034
|
+
if (cmd === '/cron' || cmd.startsWith('/cron ')) {
|
|
1035
|
+
const { listJobs, addJob, removeJob, setEnabled } = await import('./cron/jobs-store.js');
|
|
1036
|
+
const { parseSchedule } = await import('./cron/cron-parser.js');
|
|
1037
|
+
const parts = trimmed.slice('/cron'.length).trim().split(/\s+/).filter(Boolean);
|
|
1038
|
+
const action = parts[0]?.toLowerCase() ?? 'list';
|
|
1039
|
+
try {
|
|
1040
|
+
if (action === 'list') {
|
|
1041
|
+
const jobs = await listJobs();
|
|
1042
|
+
if (jobs.length === 0) {
|
|
1043
|
+
appendLine(`${C_ACCENT}⏱ 定时任务${RESET}${C_DIM} 空 — 用 /cron add <名称> '<schedule>' <prompt> 添加${RESET}`);
|
|
1044
|
+
appendLine(`${C_DIM} 示例: /cron add 每日复盘 '0 18 * * *' 总结今天工作并写进 wiki${RESET}`);
|
|
1045
|
+
}
|
|
1046
|
+
else {
|
|
1047
|
+
appendLine(`${C_ACCENT}⏱ 定时任务 (${jobs.length}):${RESET}`);
|
|
1048
|
+
for (const j of jobs) {
|
|
1049
|
+
const next = (() => { try {
|
|
1050
|
+
return parseSchedule(j.schedule)?.next.toISOString();
|
|
1051
|
+
}
|
|
1052
|
+
catch {
|
|
1053
|
+
return null;
|
|
1054
|
+
} })();
|
|
1055
|
+
appendLine(` ${j.enabled ? C_ACCENT + '●' + RESET : C_DIM + '○' + RESET} [${C_DIM}${j.id.slice(0, 8)}${RESET}] ${j.name} ${C_DIM}· ${j.schedule}${RESET}`);
|
|
1056
|
+
appendLine(` ${C_DIM}prompt: ${j.prompt.slice(0, 60)}${RESET}`);
|
|
1057
|
+
if (next)
|
|
1058
|
+
appendLine(` ${C_DIM}下次: ${next} · 已跑 ${j.runCount} 次${RESET}`);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
else if (action === 'add') {
|
|
1063
|
+
// 参数: <name> <schedule> <prompt...> — name 可能带空格, 用引号/斜杠分隔
|
|
1064
|
+
const m = trimmed.slice('/cron'.length).trim().match(/^add\s+(.*?)\s+'([^']+)'?\s+(.*)$/);
|
|
1065
|
+
if (!m || !parseSchedule(m[2])) {
|
|
1066
|
+
appendLine(`${C_WARN}用法: /cron add <名称> '<schedule>' <prompt>${RESET}`);
|
|
1067
|
+
}
|
|
1068
|
+
else {
|
|
1069
|
+
const job = await addJob({ name: m[1], schedule: m[2], prompt: m[3] }, os.homedir());
|
|
1070
|
+
appendLine(`${C_OK}✓ 已创建任务 ${job.name} (${job.schedule})${RESET}`);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
else if (action === 'rm') {
|
|
1074
|
+
const ok = await removeJob(parts[1], os.homedir());
|
|
1075
|
+
appendLine(`${ok ? C_OK + '✓' + RESET + ' 已删除' : C_ERROR + '✗ 未找到' + RESET} 任务 ${parts[1] ?? ''}`);
|
|
1076
|
+
}
|
|
1077
|
+
else if (action === 'on' || action === 'off') {
|
|
1078
|
+
const en = action === 'on';
|
|
1079
|
+
const j = await setEnabled(parts[1], en, os.homedir());
|
|
1080
|
+
appendLine(j ? `${C_OK}✓ ${en ? '启用' : '停用'} ${j.name}${RESET}` : `${C_ERROR}✗ 未找到 ${parts[1]}${RESET}`);
|
|
1081
|
+
}
|
|
1082
|
+
else {
|
|
1083
|
+
appendLine(`${C_DIM}用法: /cron list | add | rm <id> | on/off <id>${RESET}`);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
catch (e) {
|
|
1087
|
+
appendLine(`${C_ERROR}✗ /cron 失败: ${String(e.message || e).slice(0, 200)}${RESET}`);
|
|
1088
|
+
}
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
986
1091
|
// /session — 当前会话信息
|
|
987
1092
|
if (cmd === '/session') {
|
|
988
1093
|
try {
|
|
@@ -1554,6 +1659,8 @@ async function processInput(input, comm) {
|
|
|
1554
1659
|
appendLine(` ${C_ACCENT}/judgement${RESET} 判断力列表`);
|
|
1555
1660
|
appendLine(` ${C_ACCENT}/insight${RESET} Context OS 洞察 (08-Insights)`);
|
|
1556
1661
|
appendLine(` ${C_ACCENT}/wiki${RESET} wiki 状态`);
|
|
1662
|
+
appendLine(` ${C_ACCENT}/suggestions${RESET} 建议队列 ${C_DIM}list · accept <n> · dismiss <n> · clear · catalog · install${RESET}`);
|
|
1663
|
+
appendLine(` ${C_ACCENT}/cron${RESET} 定时任务 ${C_DIM}list · add <名> '<schedule>' <prompt> · rm/on/off <id>${RESET}`);
|
|
1557
1664
|
appendLine(` ${C_ACCENT}/dream${RESET} 随机灵感 ${C_DIM}/dream <主题> 写入梦想文档并触发循环${RESET}`);
|
|
1558
1665
|
appendLine(` ${C_ACCENT}@名字${RESET} @ 命中智能体 ${C_DIM}弹出窗选择后发送给智能体${RESET}`);
|
|
1559
1666
|
appendLine(` ${C_ACCENT}/名字${RESET} / 命中命令/技能/插件 ${C_DIM}输入 / 自动弹出${RESET}`);
|
|
@@ -51,3 +51,19 @@ export class ErrorLessonStore {
|
|
|
51
51
|
this.lessons.clear();
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
+
/** 分类结果 → 可执行重试计划. attempt 从 0 起, 越往后退避越大 (指数). */
|
|
55
|
+
export function planRecovery(classified, attempt = 0) {
|
|
56
|
+
switch (classified.recovery) {
|
|
57
|
+
case 'backoff-retry':
|
|
58
|
+
return { shouldRetry: true, backoffMs: 1000 * (2 ** attempt), recovery: 'backoff-retry' };
|
|
59
|
+
case 'retry-once':
|
|
60
|
+
return { shouldRetry: attempt < 1, backoffMs: attempt < 1 ? 1500 : 0, recovery: 'retry-once' };
|
|
61
|
+
case 'compact-retry':
|
|
62
|
+
// 需先压缩上下文 — 重试动作交给上层 (调用方负责 autoCompact), 这里只标注
|
|
63
|
+
return { shouldRetry: false, backoffMs: 0, recovery: 'compact-retry' };
|
|
64
|
+
default:
|
|
65
|
+
return { shouldRetry: false, backoffMs: 0, recovery: classified.recovery };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** 最大自动恢复重试次数 (Hermes 心智: 退避重试有上限, 不无限循环浪费) */
|
|
69
|
+
export const MAX_RECOVERY_ATTEMPTS = 3;
|
package/dist/llm/pi-ai.js
CHANGED
|
@@ -103,13 +103,38 @@ export class PiAIModel {
|
|
|
103
103
|
if (signal?.aborted || error?.name === 'AbortError') {
|
|
104
104
|
throw error; // 上层 try/catch 处理
|
|
105
105
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
106
|
+
const { ErrorLessonStore, planRecovery, MAX_RECOVERY_ATTEMPTS, classifyApiError } = await import('./error-lessons.js');
|
|
107
|
+
const store = (chatErrorLessons ??= new ErrorLessonStore());
|
|
108
|
+
const baseLesson = store.learn(error);
|
|
109
|
+
if (baseLesson.isNewLesson) {
|
|
110
|
+
console.warn(`[llm-lesson] 新错误教训: ${baseLesson.classified.category} (${baseLesson.classified.pattern}) → ${baseLesson.classified.recovery}`);
|
|
111
|
+
}
|
|
112
|
+
// 2026-08-11 (Hermes error_classifier + recovery hints): 分类后真正执行恢复动作 —
|
|
113
|
+
// 429/5xx → 退避重试; network → 重试 1 次; context overflow → 上层 compact (标注);
|
|
114
|
+
// auth → 不重试 (报错给用户). 之前只学习教训不重试, HTTP 429/5xx 直接失败返回.
|
|
115
|
+
for (let attempt = 0; attempt < MAX_RECOVERY_ATTEMPTS; attempt++) {
|
|
116
|
+
const classified = classifyApiError(error);
|
|
117
|
+
const plan = planRecovery(classified, attempt);
|
|
118
|
+
if (!plan.shouldRetry)
|
|
119
|
+
break; // 不可恢复 / 已到 retry-once 上限
|
|
120
|
+
const backoffMs = plan.backoffMs;
|
|
121
|
+
console.warn(`[llm-recovery] ${classified.category} attempt ${attempt + 1}/${MAX_RECOVERY_ATTEMPTS}, 退避 ${backoffMs}ms 重试`);
|
|
122
|
+
await new Promise((r) => setTimeout(r, backoffMs));
|
|
123
|
+
try {
|
|
124
|
+
const response = await this.generateText({
|
|
125
|
+
messages,
|
|
126
|
+
temperature: 0.8,
|
|
127
|
+
maxTokens: 16384,
|
|
128
|
+
signal,
|
|
129
|
+
tools,
|
|
130
|
+
});
|
|
131
|
+
return { reply: response.reply, toolCalls: response.toolCalls };
|
|
132
|
+
}
|
|
133
|
+
catch (retryErr) {
|
|
134
|
+
if (signal?.aborted || retryErr?.name === 'AbortError')
|
|
135
|
+
throw retryErr;
|
|
136
|
+
error = retryErr; // 记录最后一次错误, 循环继续重试 (backoff 递增)
|
|
137
|
+
}
|
|
113
138
|
}
|
|
114
139
|
console.error('PiAI chat error:', error);
|
|
115
140
|
// 2026-06-15: 真实 error 信息 + 明确告诉 LLM "这是 API 错, 不要 retry"
|
package/dist/web/client.js
CHANGED
|
@@ -10,8 +10,13 @@
|
|
|
10
10
|
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
11
11
|
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
12
12
|
});
|
|
13
|
-
var __esm = (fn, res) => function __init() {
|
|
14
|
-
|
|
13
|
+
var __esm = (fn, res, err) => function __init() {
|
|
14
|
+
if (err) throw err[0];
|
|
15
|
+
try {
|
|
16
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
17
|
+
} catch (e) {
|
|
18
|
+
throw err = [e], e;
|
|
19
|
+
}
|
|
15
20
|
};
|
|
16
21
|
var __export = (target, all) => {
|
|
17
22
|
for (var name in all)
|
|
@@ -379,10 +384,11 @@
|
|
|
379
384
|
if (!content) return null;
|
|
380
385
|
const strippedContent = content.replace(/<think[\s\S]*?<\/think/g, "");
|
|
381
386
|
try {
|
|
382
|
-
const result = /* @__PURE__ */ function _diagProbe() {
|
|
387
|
+
const result = /* @__PURE__ */ (function _diagProbe() {
|
|
383
388
|
return null;
|
|
384
|
-
}();
|
|
385
|
-
|
|
389
|
+
})();
|
|
390
|
+
void result;
|
|
391
|
+
const probe = (function _doParse() {
|
|
386
392
|
const m1 = strippedContent.match(/<invoke\s+name=["']([\w]+)["']/);
|
|
387
393
|
if (m1) {
|
|
388
394
|
return { name: m1[1], args: { __probe_invoketag: "1" } };
|
|
@@ -396,7 +402,7 @@
|
|
|
396
402
|
return { name: m3[1], args: { __probe_json_name: "1" } };
|
|
397
403
|
}
|
|
398
404
|
return null;
|
|
399
|
-
}();
|
|
405
|
+
})();
|
|
400
406
|
console.warn(
|
|
401
407
|
"[parseToolCall diag] rawLen=" + content.length + " strippedLen=" + strippedContent.length + " probeName=" + (probe?.name ?? "null") + " rawHead=" + JSON.stringify(content.slice(0, 500))
|
|
402
408
|
);
|
|
@@ -1609,22 +1615,22 @@
|
|
|
1609
1615
|
var lastSeenMsgIds = /* @__PURE__ */ new Map();
|
|
1610
1616
|
var COLLAPSED_PEERS_KEY = "bolloon.p2p.collapsedPeers";
|
|
1611
1617
|
var SEEN_PEERS_KEY = "bolloon.p2p.seenPeers";
|
|
1612
|
-
var collapsedPeers = function loadCollapsed() {
|
|
1618
|
+
var collapsedPeers = (function loadCollapsed() {
|
|
1613
1619
|
try {
|
|
1614
1620
|
const raw = localStorage.getItem(COLLAPSED_PEERS_KEY);
|
|
1615
1621
|
return new Set(raw ? JSON.parse(raw) : []);
|
|
1616
1622
|
} catch {
|
|
1617
1623
|
return /* @__PURE__ */ new Set();
|
|
1618
1624
|
}
|
|
1619
|
-
}();
|
|
1620
|
-
var seenPeers = function loadSeen() {
|
|
1625
|
+
})();
|
|
1626
|
+
var seenPeers = (function loadSeen() {
|
|
1621
1627
|
try {
|
|
1622
1628
|
const raw = localStorage.getItem(SEEN_PEERS_KEY);
|
|
1623
1629
|
return new Set(raw ? JSON.parse(raw) : []);
|
|
1624
1630
|
} catch {
|
|
1625
1631
|
return /* @__PURE__ */ new Set();
|
|
1626
1632
|
}
|
|
1627
|
-
}();
|
|
1633
|
+
})();
|
|
1628
1634
|
function saveCollapsedPeers() {
|
|
1629
1635
|
try {
|
|
1630
1636
|
localStorage.setItem(COLLAPSED_PEERS_KEY, JSON.stringify([...collapsedPeers]));
|
|
@@ -5845,7 +5851,7 @@ ${m.content}`, "user", false, log);
|
|
|
5845
5851
|
}
|
|
5846
5852
|
var p2pToggleAllBtn = document.getElementById("p2p-toggle-all-btn");
|
|
5847
5853
|
if (p2pToggleAllBtn) {
|
|
5848
|
-
let
|
|
5854
|
+
let syncToggleAllBtn2 = function() {
|
|
5849
5855
|
const allPks = /* @__PURE__ */ new Set([
|
|
5850
5856
|
...knownPeers.map((p) => p.publicKey),
|
|
5851
5857
|
...remoteChannels.map((g) => g.peerId)
|
|
@@ -5866,7 +5872,7 @@ ${m.content}`, "user", false, log);
|
|
|
5866
5872
|
p2pToggleAllBtn.title = "\u70B9\u51FB\u6298\u53E0\u6240\u6709 P2P \u597D\u53CB";
|
|
5867
5873
|
}
|
|
5868
5874
|
};
|
|
5869
|
-
|
|
5875
|
+
syncToggleAllBtn = syncToggleAllBtn2;
|
|
5870
5876
|
p2pToggleAllBtn.addEventListener("click", (e) => {
|
|
5871
5877
|
e.stopPropagation();
|
|
5872
5878
|
const allPks = /* @__PURE__ */ new Set([
|
|
@@ -5882,12 +5888,12 @@ ${m.content}`, "user", false, log);
|
|
|
5882
5888
|
} else {
|
|
5883
5889
|
collapseAllPeers();
|
|
5884
5890
|
}
|
|
5885
|
-
|
|
5891
|
+
syncToggleAllBtn2();
|
|
5886
5892
|
});
|
|
5887
|
-
window.__syncP2PToggleAllBtn =
|
|
5888
|
-
|
|
5893
|
+
window.__syncP2PToggleAllBtn = syncToggleAllBtn2;
|
|
5894
|
+
syncToggleAllBtn2();
|
|
5889
5895
|
}
|
|
5890
|
-
var
|
|
5896
|
+
var syncToggleAllBtn;
|
|
5891
5897
|
var refreshSharedBtn = document.getElementById("refresh-shared-btn");
|
|
5892
5898
|
if (refreshSharedBtn) {
|
|
5893
5899
|
refreshSharedBtn.addEventListener("click", async (e) => {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loop-noise.ts — 良性连接拆除噪音抑制 (借鉴 Hermes tui_gateway/loop_noise.py)
|
|
3
|
+
*
|
|
4
|
+
* Hermes 问题: Desktop 客户端强关 WebSocket 时, gateway 还有 pending socket 操作,
|
|
5
|
+
* asyncio transport teardown 对每个 _call_connection_lost 回调打一条完整 traceback,
|
|
6
|
+
* 单次断开可刷 50+ 条相同 ConnectionResetError/WinError 10054 进 errors.log (#50005).
|
|
7
|
+
*
|
|
8
|
+
* Bolloon 等价: SSE/HTTP 客户端断开后, 服务端向已关闭的 res 写消息 → 每次广播抛
|
|
9
|
+
* "write EPIPE / write after end / 连接被重置"。这不是可行动的错误 — 是对端在
|
|
10
|
+
* 写 drain 前挂线的预期副作用。这里对"拆除期写失败"这类良性错误做一次过滤,
|
|
11
|
+
* 把同类错误折叠成一行 debug, 其余错误原样交给默认逻辑, 真实的循环 bug 仍会暴露。
|
|
12
|
+
*
|
|
13
|
+
* 设计要点 (对齐 hermes):
|
|
14
|
+
* - 同时看错误类型 AND 写路径标记 (broadcast 的 res.write) 才吞 — 其它地方抛同样错误
|
|
15
|
+
* 不被误吞 (对应 hermes 的 _call_connection_lost gating)。
|
|
16
|
+
* - 同类错误窗口节流 (channel_directory 模式): 同一 (类别, 摘要) 窗口内只 warn 一次,
|
|
17
|
+
* 复用 delivery-ledger 已有的 shouldWarn 心智。
|
|
18
|
+
*/
|
|
19
|
+
/** 良性拆除期写失败: 对端挂线 / 写已结束的流。等价 hermes 的 ConnectionReset/Aborted/BrokenPipe。 */
|
|
20
|
+
const BENIGN_WRITE_PATTERNS = [
|
|
21
|
+
/write EPIPE/i,
|
|
22
|
+
/write after end/i,
|
|
23
|
+
/ECONNRESET|connection (reset|aborted)|10054|10053/i,
|
|
24
|
+
/Cannot (write|set headers) after (the client|they) (are |is |has )?(closed|ended)/i,
|
|
25
|
+
/socket hang up/i,
|
|
26
|
+
/broken pipe/i,
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* 一条错误是否属于"写给已断开客户端"的良性噪音。
|
|
30
|
+
* 双重判定等价 hermes 的 (错误类型 + _call_connection_lost 回调 gating):
|
|
31
|
+
* - 类型/消息命中拆除类写失败 (ConnectionReset/BrokenPipe/写已结束)
|
|
32
|
+
* - 且调用点本来就是"写路径" — 由 guardClientWriteNoise 只在 res.write 处调用保证
|
|
33
|
+
*/
|
|
34
|
+
export function isBenignClientWriteNoise(err) {
|
|
35
|
+
const msg = String(err?.message || err || '');
|
|
36
|
+
return BENIGN_WRITE_PATTERNS.some((re) => re.test(msg));
|
|
37
|
+
}
|
|
38
|
+
/** 同类错误节流器: 同 (类别, 摘要) 窗口内返回 false (不重复 warn), 窗口外返回 true 并刷新。 */
|
|
39
|
+
export class NoiseThrottle {
|
|
40
|
+
windowMs;
|
|
41
|
+
last = new Map();
|
|
42
|
+
constructor(windowMs = 5 * 60 * 1000) {
|
|
43
|
+
this.windowMs = windowMs;
|
|
44
|
+
}
|
|
45
|
+
shouldWarn(category, detail) {
|
|
46
|
+
const key = `${category}|${detail.slice(0, 80)}`;
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
const last = this.last.get(key) ?? 0;
|
|
49
|
+
if (now - last < this.windowMs)
|
|
50
|
+
return false;
|
|
51
|
+
this.last.set(key, now);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
reset() {
|
|
55
|
+
this.last.clear();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 包装一个"可能抛拆除期写失败"的写操作:
|
|
60
|
+
* - 良性噪音 → 静默吞一次 (不抛出, 不打印), 仅按节流器偶尔 debug 一行
|
|
61
|
+
* - 其它错误 → 原样抛出 / 转交 onError, 不掩盖真实问题
|
|
62
|
+
*
|
|
63
|
+
* @param fn 实际写操作 (抛异常或 reject)
|
|
64
|
+
* @param opts 诊断标签 + 可选节流器 (默认全局单例) + 可选 onError 回调 (捕获真实错误)
|
|
65
|
+
*/
|
|
66
|
+
export async function guardClientWriteNoise(fn, opts = { label: 'write' }) {
|
|
67
|
+
const throttle = opts.throttle ?? globalNoiseThrottle;
|
|
68
|
+
try {
|
|
69
|
+
await fn();
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
if (isBenignClientWriteNoise(e)) {
|
|
73
|
+
// 良性: 折叠成一行 (节流), 不抛出
|
|
74
|
+
if (throttle.shouldWarn(opts.label, String(e?.message || e))) {
|
|
75
|
+
console.debug(`[loop-noise] 已抑制良性客户端断开写失败 (${opts.label}): ${String(e?.message || '').slice(0, 120)}`);
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (opts.onError)
|
|
80
|
+
opts.onError(e);
|
|
81
|
+
else
|
|
82
|
+
throw e;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** 全局默认节流器 (server 生命期复用, 跨连接共享告警窗口)。 */
|
|
86
|
+
export const globalNoiseThrottle = new NoiseThrottle();
|
package/dist/web/server-sse.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* SSEClient type 来自 ./server-types.ts.
|
|
12
12
|
*/
|
|
13
13
|
import * as crypto from 'crypto';
|
|
14
|
+
import { guardClientWriteNoise } from './loop-noise.js';
|
|
14
15
|
const sseClients = new Set();
|
|
15
16
|
export function getSseClients() {
|
|
16
17
|
return sseClients;
|
|
@@ -57,12 +58,16 @@ export function broadcast(data, channelId) {
|
|
|
57
58
|
console.log(`[broadcast] type=${data.type}, channelId=${channelId}, seq=${seq}, msgId=${msgId}, clients=${sseClients.size}`);
|
|
58
59
|
for (const client of sseClients) {
|
|
59
60
|
if (!channelId || client.channelId === channelId) {
|
|
60
|
-
|
|
61
|
+
// 2026-08-11 (Hermes loop_noise): 客户端在写 drain 前断开 → 每次广播对已关闭 res
|
|
62
|
+
// write 都会抛 "write EPIPE / write after end / 连接已关闭"。这是预期副作用, 不是
|
|
63
|
+
// bug — guardClientWriteNoise 把同类错误折叠成一行 (节流), 不打印每条刷屏。
|
|
64
|
+
void guardClientWriteNoise(() => {
|
|
61
65
|
client.res.write(message);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
return Promise.resolve();
|
|
67
|
+
}, {
|
|
68
|
+
label: `sse-broadcast:${data.type}`,
|
|
69
|
+
onError: (e) => console.error(`[broadcast] 写入失败 (非良性): ${e.message}`),
|
|
70
|
+
});
|
|
66
71
|
}
|
|
67
72
|
}
|
|
68
73
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
2
|
"name": "@bolloon/bolloon-agent",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
|
|
6
6
|
"main": "dist/cli-entry.js",
|
|
@@ -55,11 +55,10 @@
|
|
|
55
55
|
"src/constraint-runtime"
|
|
56
56
|
],
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@bolloon/bolloon-agent": "^0.3.22",
|
|
59
58
|
"@bolloon/constraint-runtime": "0.1.0",
|
|
60
|
-
"@capacitor/android": "^8.
|
|
61
|
-
"@capacitor/core": "^8.
|
|
62
|
-
"@capacitor/ios": "^8.
|
|
59
|
+
"@capacitor/android": "^8.5.0",
|
|
60
|
+
"@capacitor/core": "^8.5.0",
|
|
61
|
+
"@capacitor/ios": "^8.5.0",
|
|
63
62
|
"@chainsafe/libp2p-noise": "^17.0.0",
|
|
64
63
|
"@chainsafe/libp2p-yamux": "^8.0.1",
|
|
65
64
|
"@diap/sdk": "^0.2.4",
|
|
@@ -72,57 +71,57 @@
|
|
|
72
71
|
"@libp2p/tcp": "^11.0.20",
|
|
73
72
|
"@libp2p/upnp-nat": "^4.0.20",
|
|
74
73
|
"@multiformats/multiaddr": "^13.0.3",
|
|
75
|
-
"@noble/hashes": "^
|
|
74
|
+
"@noble/hashes": "^2.3.0",
|
|
76
75
|
"@orbitdb/core": "^4.0.0",
|
|
77
|
-
"@polymarket/client": "^0.
|
|
76
|
+
"@polymarket/client": "^0.5.0",
|
|
78
77
|
"@rayhanadev/iroh": "^0.1.1",
|
|
79
|
-
"@x402/aptos": "^2.
|
|
80
|
-
"@x402/avm": "^2.
|
|
81
|
-
"@x402/axios": "^2.
|
|
82
|
-
"@x402/core": "^2.
|
|
83
|
-
"@x402/evm": "^2.
|
|
84
|
-
"@x402/express": "^2.
|
|
85
|
-
"@x402/extensions": "^2.
|
|
86
|
-
"@x402/fastify": "^2.
|
|
87
|
-
"@x402/fetch": "^2.
|
|
88
|
-
"@x402/hedera": "^2.
|
|
89
|
-
"@x402/hono": "^2.
|
|
90
|
-
"@x402/keeta": "^2.
|
|
91
|
-
"@x402/mcp": "^2.
|
|
92
|
-
"@x402/next": "^2.
|
|
93
|
-
"@x402/paywall": "^2.
|
|
94
|
-
"@x402/stellar": "^2.
|
|
95
|
-
"@x402/svm": "^2.
|
|
96
|
-
"@x402/tvm": "^2.
|
|
78
|
+
"@x402/aptos": "^2.21.0",
|
|
79
|
+
"@x402/avm": "^2.21.0",
|
|
80
|
+
"@x402/axios": "^2.21.0",
|
|
81
|
+
"@x402/core": "^2.21.0",
|
|
82
|
+
"@x402/evm": "^2.21.0",
|
|
83
|
+
"@x402/express": "^2.21.0",
|
|
84
|
+
"@x402/extensions": "^2.21.0",
|
|
85
|
+
"@x402/fastify": "^2.21.0",
|
|
86
|
+
"@x402/fetch": "^2.21.0",
|
|
87
|
+
"@x402/hedera": "^2.21.0",
|
|
88
|
+
"@x402/hono": "^2.21.0",
|
|
89
|
+
"@x402/keeta": "^2.21.0",
|
|
90
|
+
"@x402/mcp": "^2.21.0",
|
|
91
|
+
"@x402/next": "^2.21.0",
|
|
92
|
+
"@x402/paywall": "^2.21.0",
|
|
93
|
+
"@x402/stellar": "^2.21.0",
|
|
94
|
+
"@x402/svm": "^2.21.0",
|
|
95
|
+
"@x402/tvm": "^2.21.0",
|
|
97
96
|
"b4a": "^1.8.1",
|
|
98
97
|
"dotenv": "^17.4.2",
|
|
99
|
-
"esbuild": "^0.24.0",
|
|
100
98
|
"express": "^5.2.1",
|
|
101
99
|
"helia": "^7.1.3",
|
|
102
100
|
"ink": "^7.1.1",
|
|
103
101
|
"ink-text-input": "^6.0.0",
|
|
104
|
-
"libp2p": "^3.3.
|
|
105
|
-
"mammoth": "^1.
|
|
106
|
-
"pdf-parse": "^
|
|
102
|
+
"libp2p": "^3.3.8",
|
|
103
|
+
"mammoth": "^1.12.1",
|
|
104
|
+
"pdf-parse": "^2.4.5",
|
|
107
105
|
"platform": "^1.3.6",
|
|
108
106
|
"react": "^19.2.8",
|
|
109
107
|
"react-dom": "^19.2.8",
|
|
110
|
-
"viem": "^2.
|
|
108
|
+
"viem": "^2.55.13"
|
|
111
109
|
},
|
|
112
110
|
"devDependencies": {
|
|
113
|
-
"@capacitor/cli": "^8.
|
|
114
|
-
"@playwright/test": "^1.
|
|
111
|
+
"@capacitor/cli": "^8.5.0",
|
|
112
|
+
"@playwright/test": "^1.62.1",
|
|
115
113
|
"@types/express": "^5.0.6",
|
|
116
114
|
"@types/node": "^25.9.1",
|
|
117
115
|
"@types/pdf-parse": "^1.1.5",
|
|
118
116
|
"@types/react": "^19.2.18",
|
|
119
117
|
"@types/react-dom": "^19.2.4",
|
|
120
|
-
"concurrently": "^
|
|
121
|
-
"electron": "^
|
|
122
|
-
"electron-builder": "^26.15.
|
|
118
|
+
"concurrently": "^10.0.4",
|
|
119
|
+
"electron": "^43.3.0",
|
|
120
|
+
"electron-builder": "^26.15.7",
|
|
121
|
+
"esbuild": "^0.28.2",
|
|
123
122
|
"lefthook": "^2.1.9",
|
|
124
|
-
"playwright": "^1.
|
|
125
|
-
"tsx": "^4.
|
|
123
|
+
"playwright": "^1.62.1",
|
|
124
|
+
"tsx": "^4.23.12",
|
|
126
125
|
"typescript": "^5.0.0",
|
|
127
126
|
"vitest": "^4.1.6"
|
|
128
127
|
},
|