@bolloon/bolloon-agent 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -91,6 +91,32 @@ npm start
91
91
 
92
92
  ---
93
93
 
94
+ ## Docker
95
+
96
+ 不想在本机装 Node 与依赖, 可以直接跑容器 (多阶段镜像 · 非 root 用户 uid 1001 · 状态全部落在卷里):
97
+
98
+ ```bash
99
+ docker build -t bolloon-agent:0.5.1 .
100
+ docker run -d --name bolloon-agent \
101
+ -p 127.0.0.1:54188:54188 \
102
+ -v bolloon-data:/home/bolloon/.bolloon \
103
+ bolloon-agent:0.5.1 --web
104
+ # → http://127.0.0.1:54188
105
+
106
+ docker compose up -d # 同上的 compose 写法
107
+ docker compose run --rm bolloon-cli --version # 一次性 CLI (不用起服务)
108
+ ```
109
+
110
+ - **密钥别写进 compose**: 复制 `.env.example` → `.env.docker` (已在 `.gitignore`), 或者把本机
111
+ 0600 的 `llm-config.json` **只读挂载**进容器 (更安全: 不进环境变量、不进 `docker inspect`)。
112
+ - **数据卷里有身份私钥与 API key 配置** (`/home/bolloon/.bolloon`): 别导出、别提交、别 `docker commit`。
113
+ - 端口只认环境变量 `PORT` (默认 `54188`); `--port` 参数无效。
114
+
115
+ 完整说明 (镜像怎么搭的 · 卷与 uid 权限 · HEALTHCHECK 探什么/不探什么 · 常见问题) 见
116
+ [docs/wiki/docker-deployment.md](./docs/wiki/docker-deployment.md)。
117
+
118
+ ---
119
+
94
120
  ## 环境变量
95
121
 
96
122
  | 变量 | 说明 |
@@ -136,6 +162,19 @@ bolloon --help # All commands
136
162
 
137
163
  Requires Node.js ≥ 18 and an LLM API key (`OPENAI_API_KEY` or `DEEPSEEK_API_KEY`).
138
164
 
165
+ ### Docker
166
+
167
+ Run it in a container instead (multi-stage image, non-root user, state in a volume):
168
+
169
+ ```bash
170
+ docker build -t bolloon-agent:0.5.1 .
171
+ docker run -d --name bolloon-agent -p 127.0.0.1:54188:54188 \
172
+ -v bolloon-data:/home/bolloon/.bolloon bolloon-agent:0.5.1 --web
173
+ ```
174
+
175
+ Copy `.env.example` to `.env.docker` (gitignored) for keys, or mount your `llm-config.json` read-only
176
+ (safer). Full guide: [docs/wiki/docker-deployment.md](./docs/wiki/docker-deployment.md).
177
+
139
178
  ### License
140
179
 
141
180
  This project is released under the [MIT License](./LICENSE). You are free to use, modify, and distribute this project, provided that you retain the copyright and permission notices. See the [LICENSE](./LICENSE) file for details.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * agents/agent-lang - 智能体交流语言的**声明层**与消息封装层接线
3
+ *
4
+ * 这一层只做三件事:
5
+ * ① 声明: manifest 里带上 `supportedLangs` (本机支持哪些语言);
6
+ * ② 出站: 拿"本机声明 + 对端声明"裁决 → 要么真编 (双方 efficode), 要么原文直发;
7
+ * ③ 入站: 只按对端**声明**的语言分派 —— 声明不是 efficode 的载荷一律不进解码器。
8
+ *
9
+ * 不支持的场景怎么处理 (照 leo 的要求, 不许静默):
10
+ * - 对端没声明 / 只声明 natural → 自然语言, 记一笔 `fallback=false`
11
+ * - 本机想要 efficode 但对端没声明 → 回落, 记一笔 `fallback=true` + 原因
12
+ * - 对端声明了本机不认识的语言字符串 → 回落, 原样呈现, 记 `unknownSide`
13
+ * - 对端声明 efficode 但本机没声明支持 → 拒绝解码并抛 (绝不硬解)
14
+ */
15
+ import { decodeFromPeer, encodeForPeer, defaultDeclaredLang, negotiateLang, recordLangDecision, unknownLangNames, } from '../efficode/negotiate.js';
16
+ import { normalizeLang } from '../efficode/index.js';
17
+ /** 本机支持的语言声明 (进程内, 由 manifest 装配处设置) */
18
+ let localLangs = [];
19
+ /** 本机默认声明 (env 可覆盖; 默认 natural) */
20
+ let defaultLang = null;
21
+ export function setLocalSupportedLangs(langs) {
22
+ const arr = Array.isArray(langs) ? langs : langs == null ? [] : [langs];
23
+ localLangs = arr.map((x) => String(x).trim()).filter(Boolean);
24
+ return localLangs;
25
+ }
26
+ export function getLocalSupportedLangs() {
27
+ return [...localLangs];
28
+ }
29
+ /** 本机声明 (没显式设置就回落到 env/默认; 声明不变量绝不"猜"成 efficode) */
30
+ export function localDeclaredLang(env = process.env) {
31
+ if (defaultLang)
32
+ return defaultLang;
33
+ return defaultDeclaredLang(env);
34
+ }
35
+ export function setLocalDeclaredLang(lang) {
36
+ defaultLang = normalizeLang(lang);
37
+ return defaultLang ?? 'natural';
38
+ }
39
+ /** 本机要写进 manifest 的声明: 显式语言列表优先, 否则 [默认声明] */
40
+ export function manifestLangDeclaration(env = process.env) {
41
+ if (localLangs.length)
42
+ return [...localLangs];
43
+ return [localDeclaredLang(env)];
44
+ }
45
+ /** 从 manifest 里读对端声明 (老 manifest 没这个字段 → 空数组 = 没声明) */
46
+ export function peerLangsOf(manifest) {
47
+ const raw = manifest?.supportedLangs;
48
+ const arr = Array.isArray(raw) ? raw : raw == null ? [] : [raw];
49
+ return arr.map((x) => String(x).trim()).filter(Boolean);
50
+ }
51
+ /**
52
+ * 出站消息: 双方声明都是 efficode 才编码, 否则原文直发并在帧里如实声明 `lang: 'natural'`。
53
+ */
54
+ export function buildAgentMessage(opts) {
55
+ const mine = opts.mine ?? manifestLangDeclaration(opts.env);
56
+ const enc = encodeForPeer({ text: opts.text, mine, theirs: opts.theirs, from: opts.from, op: opts.op });
57
+ const frame = {
58
+ type: 'agent_message',
59
+ payload: { text: enc.text, lang: enc.lang },
60
+ ts: Date.now(),
61
+ fromDid: opts.from ?? '',
62
+ ...(opts.to ? { toDid: opts.to } : {}),
63
+ };
64
+ recordLangDecision(enc.decision, { channel: 'agent_message', peer: opts.to });
65
+ return { frame: JSON.stringify(frame), lang: enc.lang, decision: enc.decision, bytes: enc.bytes, encoded: enc.lang === 'efficode' };
66
+ }
67
+ /** 解析线上帧 (非 agent_message / 坏 JSON → null; 不抛, 调用方按"不是我的帧"处理) */
68
+ export function parseAgentMessageFrame(text) {
69
+ try {
70
+ const v = JSON.parse(text);
71
+ if (!v || v.type !== 'agent_message' || !v.payload || typeof v.payload.text !== 'string')
72
+ return null;
73
+ return v;
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ /**
80
+ * 入站消息: 严格按帧里 `lang` 分派。
81
+ * - `lang: 'efficode'` 且本机也声明 efficode → 真解包 (畸形包抛 EfficodeError)
82
+ * - 其它一切 (含缺字段 / 未知字符串) → 原样文本 + 明确 note, **不进解码器**
83
+ */
84
+ export function readAgentMessage(frameText, opts = {}) {
85
+ const frame = parseAgentMessageFrame(frameText);
86
+ if (!frame)
87
+ return null;
88
+ const mine = opts.mine ?? manifestLangDeclaration(opts.env);
89
+ const declared = frame.payload.lang;
90
+ const inc = decodeFromPeer({ payload: frame.payload.text, declaredLang: declared, mine });
91
+ const decision = negotiateLang(mine, declared);
92
+ recordLangDecision(decision, { channel: 'agent_message_recv' });
93
+ return {
94
+ ...inc,
95
+ declared: inc.declaredRaw,
96
+ unknownLangs: unknownLangNames(declared),
97
+ fromDid: frame.fromDid ?? '',
98
+ toDid: frame.toDid,
99
+ decision,
100
+ };
101
+ }
@@ -54,6 +54,10 @@ export function setLocalManifest(m) {
54
54
  localManifest.exportments = [];
55
55
  if (!('sciences' in m))
56
56
  localManifest.sciences = [];
57
+ // 2026-09-28: 交流语言声明同规矩 —— 不给就显式清空, 免得旧的 efficode 声明在对端已经不支持时还留着.
58
+ // (老 manifest 语义不变: 没有 supportedLangs = 没声明 = 自然语言)
59
+ if (!('supportedLangs' in m))
60
+ localManifest.supportedLangs = [];
57
61
  // 2026-09-18: 网络脉冲 (只记公开统计; 不落 DID/正文; 统计失败绝不影响主路径)
58
62
  try {
59
63
  const did = String(localManifest.ownerPublicKey || '');
@@ -18,6 +18,8 @@
18
18
  import * as os from 'os';
19
19
  import * as path from 'path';
20
20
  import { getCIDDatabase } from '../orbitdb/cid-database.js';
21
+ // 2026-09-28: 交流语言 (Efficode 是可选项, 不是默认项) —— 只有双方都声明才用
22
+ import { decodeFromPeer, encodeForPeer, recordLangDecision } from '../efficode/negotiate.js';
21
23
  // ============ 依赖注入 (测试用, 避免单测起真实 OrbitDB 节点) ============
22
24
  let _dbOverride = null;
23
25
  /** 测试用: 注入 fake CIDDatabase */
@@ -281,7 +283,11 @@ export async function groupMessages(groupId, limit = 50) {
281
283
  for (const entry of all) {
282
284
  const v = entry.value;
283
285
  if (v && typeof v.text === 'string' && typeof v.from === 'string') {
284
- msgs.push({ from: v.from, text: v.text, ts: typeof v.ts === 'number' ? v.ts : 0 });
286
+ const m = { from: v.from, text: v.text, ts: typeof v.ts === 'number' ? v.ts : 0 };
287
+ // 只透传字符串形态的语言声明; 别的形状一律当"没声明"(不让脏值进到解码分派)
288
+ if (typeof v.lang === 'string' && v.lang.trim())
289
+ m.lang = v.lang.trim();
290
+ msgs.push(m);
285
291
  }
286
292
  }
287
293
  msgs.sort((a, b) => a.ts - b.ts);
@@ -293,7 +299,7 @@ export async function groupMembers(groupId) {
293
299
  return Array.from(new Set(msgs.map((m) => m.from)));
294
300
  }
295
301
  /** 发送群消息 (广播给所有成员) */
296
- export async function groupSend(groupId, text, from) {
302
+ export async function groupSend(groupId, text, from, opts) {
297
303
  const msg = String(text || '').trim();
298
304
  if (!msg)
299
305
  return { ok: false, error: '消息不能为空' };
@@ -309,13 +315,48 @@ export async function groupSend(groupId, text, from) {
309
315
  }
310
316
  }
311
317
  try {
312
- await store.add({ from: String(from || 'anonymous'), text: msg, ts: Date.now() });
318
+ const record = { from: String(from || 'anonymous'), text: msg, ts: Date.now() };
319
+ // 只有显式给了合法声明才写字段 —— 不给就不写 (老读者读到的东西一字不变)
320
+ const lang = typeof opts?.lang === 'string' ? opts.lang.trim() : '';
321
+ if (lang)
322
+ record.lang = lang;
323
+ await store.add(record);
313
324
  return { ok: true };
314
325
  }
315
326
  catch (e) {
316
327
  return { ok: false, error: `发送失败: ${String(e?.message || e).slice(0, 160)}` };
317
328
  }
318
329
  }
330
+ /**
331
+ * 2026-09-28: 按语言裁决发群消息 —— 真可走的那条路。
332
+ *
333
+ * 双方都声明 efficode ⇒ 正文真编成 Efficode 包 (文本模式 Base64), `lang: 'efficode'` 一起落库;
334
+ * 否则**原文直发** + `lang: 'natural'` (或干脆不写字段, 由调用方定), 并记一笔回落原因。
335
+ * 裁决/编码/落库各自失败都有结构化结果, 不静默。
336
+ */
337
+ export async function groupSendWithLang(groupId, text, from, opts = { mine: undefined, theirs: undefined }) {
338
+ const enc = encodeForPeer({ text, mine: opts.mine, theirs: opts.theirs, from, op: opts.op });
339
+ recordLangDecision(enc.decision, { channel: `group:${groupId}`, peer: from });
340
+ const wantLang = enc.lang === 'efficode' || opts.record === 'always' ? enc.lang : '';
341
+ const r = await groupSend(groupId, enc.text, from, wantLang ? { lang: wantLang } : undefined);
342
+ return {
343
+ ok: r.ok,
344
+ error: r.error,
345
+ lang: enc.lang,
346
+ bytes: enc.bytes,
347
+ fallback: enc.decision.fallback,
348
+ reason: enc.decision.reason,
349
+ symbolic: enc.symbolic,
350
+ };
351
+ }
352
+ /**
353
+ * 2026-09-28: 读一条群消息 —— 严格按它自己声明的 `lang` 分派。
354
+ * 声明不是 efficode (含未知字符串 / 没声明) ⇒ 原样返回, **不进解码器**。
355
+ * 声明 efficode 且本机也声明支持 ⇒ 真解包 (畸形包抛, 由调用方如实报失败)。
356
+ */
357
+ export function readGroupMessage(msg, mine) {
358
+ return decodeFromPeer({ payload: msg.text, declaredLang: msg.lang, mine });
359
+ }
319
360
  /** 群组邀请链接 */
320
361
  export async function groupLink(groupId) {
321
362
  const groups = await loadGroups();
@@ -33,6 +33,8 @@ import { createGoal, attachRun, findActiveGoal } from './goal-store.js';
33
33
  import { captureRunModelConfig } from '../llm/model-selection.js';
34
34
  // 2026-09-26: 工具名出网净化 (pi-ai.ts 唯一边界) + 回程派发还原 (原名 ↔ API 名)
35
35
  import { resolveApiToolName, expandKnownToolNames } from '../llm/tool-name.js';
36
+ // 2026-09-28 (前缀 KV 可命中): CURRENT TURN 注入标记 —— "当前轮是否已注入"的唯一判据
37
+ import { CURRENT_TURN_MARKER } from '../llm/pi-ai.js';
36
38
  import { PiAgentHarness } from './pi-harness.js';
37
39
  import { DiscoveredAgentsManager, createSocialHeartbeat } from '../social/heartbeat.js';
38
40
  import { SkillRegistry } from '@bolloon/constraint-runtime';
@@ -79,6 +81,55 @@ import { DEFAULT_MAX_REVIEWS } from './loop-review.js';
79
81
  * 循环内: 注入 system 提示强制 LLM 收尾; 运行层 (M3): 熔断 → needs_human (交人)。
80
82
  */
81
83
  const MAX_SAME_TOOL_FAILURES = 3;
84
+ /**
85
+ * 2026-09-28 (前缀 KV 可命中): 把 chat() 回带的「当前轮已注入」内容写回调用方自己的 history.
86
+ *
87
+ * 谁是调用方: ReAct 循环每轮用 `buildMessages()` **重建全新对象** —— 注入只活在那一份里.
88
+ * 不写回的话下一轮那条 user 就退回注入前, 前缀从它起分叉:
89
+ * 第 1 次 `S H [D1 U1]` 第 2 次 `S H U1 U2` ← U1 丢了 D1
90
+ * 正确形态: `S H [D1 U1] U2` (D1 成了 U1 的一部分, 于是成为后续轮的稳定前缀).
91
+ *
92
+ * 为什么是纯函数 (不塞进类里): 门 `scripts/verify-kv-prefix.ts` 要能**直接驱它**断言,
93
+ * 而不是绕一整条 ReAct 循环才验到"写回"这一下.
94
+ *
95
+ * 匹配判据 (只做加法, 绝不乱改历史):
96
+ * 1. wire 最后一条 content 必须以 CURRENT TURN 标记开头 (没注入过 → 0, 不动);
97
+ * 2. 从 history **末尾往前**找第一条 `content` 是它后缀的条目 ——
98
+ * 去掉后缀剩下的那截必须仍以标记开头 (证明"注入是前部加了一段");
99
+ * 3. 命中就整段写回 (tool 条目拿到的是含 `[工具结果]\n` 前缀 + 注入的整段),
100
+ * 写回后该条 `content` 已等于 wire → 第二次调用前缀为空, 自然返回 0 (幂等).
101
+ *
102
+ * @returns 真写回的条数 (0 = 没注入过 / 匹配不上 / 已经写回过)
103
+ */
104
+ export function writeBackCurrentTurnInto(history, wire) {
105
+ try {
106
+ if (!Array.isArray(history) || history.length === 0)
107
+ return 0;
108
+ if (!Array.isArray(wire) || wire.length === 0)
109
+ return 0;
110
+ const last = wire[wire.length - 1];
111
+ const wireContent = typeof last?.content === 'string' ? last.content : '';
112
+ if (!wireContent.startsWith(CURRENT_TURN_MARKER))
113
+ return 0; // 没注入过 → 不动
114
+ for (let i = history.length - 1; i >= 0; i--) {
115
+ const entry = history[i];
116
+ const inner = typeof entry?.content === 'string' ? entry.content : '';
117
+ if (!inner)
118
+ continue;
119
+ if (!wireContent.endsWith(inner))
120
+ continue;
121
+ const prefix = wireContent.slice(0, wireContent.length - inner.length);
122
+ if (!prefix || !prefix.startsWith(CURRENT_TURN_MARKER))
123
+ continue;
124
+ entry.content = wireContent;
125
+ return 1;
126
+ }
127
+ return 0;
128
+ }
129
+ catch {
130
+ return 0; // 写回失败绝不影响对话本身
131
+ }
132
+ }
82
133
  export class PiAgentSession {
83
134
  cwd;
84
135
  peerId;
@@ -1775,12 +1826,14 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1775
1826
  .join('\n');
1776
1827
  loopProgressSection = `\n【本轮循环进度】你已完成以下 ${loopActionLog.length} 个动作, 这是连续执行的同一轮任务:\n${actionLines}\n请基于已有结果继续推进, 不要重复执行上面已成功的动作. 全部完成后用 <final gen> 结束.\n`;
1777
1828
  }
1829
+ // 2026-09-28 (前缀 KV 可命中): `refineContext` / `loopProgressSection` 是**每轮都在变**的段
1830
+ // (质量分 / 连续错误数 / 本轮已完成动作). 留在 system 里 = 每轮把 IMMUTABLE PREFIX 打碎 =
1831
+ // 服务端前缀 KV 永远 miss. 移到 CURRENT TURN 区 (注入到最后一条 user 前部) —— 文本一字未改, 只换位置.
1832
+ const currentTurnContext = `${refineContext}${loopProgressSection}`;
1778
1833
  const systemPrompt = `${this.bootstrapAddition}你是 ${this.identity.name},基于ReAct (Reasoning + Acting)模式工作。${personaSection}
1779
1834
  当前工作目录: ${this.cwd}
1780
1835
  当前身份: ${this.identity.name} (${this.identity.did})
1781
- ${refineContext}
1782
1836
  ${this.currentIntentHint}
1783
- ${loopProgressSection}
1784
1837
 
1785
1838
  ${toolDefs}
1786
1839
 
@@ -1830,7 +1883,11 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1830
1883
  // 2026-09-16 (Milestone 1-B): 模型调用前后走唯一门面 (扩展点 + 计数留痕; 默认不加新 hook 事件, 避免改变现有触发次数)
1831
1884
  this.piHarness().beforeModelCall(this.harnessCtx());
1832
1885
  const _modelCallT0 = Date.now();
1833
- const response = await this.callLlmWithRecovery(llm, messages, systemPrompt, signal, onStream, openaiFormattedTools);
1886
+ const response = await this.callLlmWithRecovery(llm, messages, systemPrompt, signal, onStream, openaiFormattedTools, currentTurnContext);
1887
+ // 2026-09-28 (前缀 KV 可命中): 把注入了 CURRENT TURN 的当前轮**原样写回自己的 messageHistory** ——
1888
+ // buildMessages() 每轮重建全新对象, 不写回的话下一轮那条 user 就退回注入前, 前缀从那里分叉.
1889
+ // 用 chat() 回带的 wire (真正发出去的那一份), 而不是把 messages 再拼一遍.
1890
+ this.writeBackCurrentTurn(response.messages);
1834
1891
  this.piHarness().afterModelCall(this.harnessCtx(), { ms: Date.now() - _modelCallT0 });
1835
1892
  const reply = (response.reply || '').trim();
1836
1893
  // 2026-06-30: OpenAI 协议 native tool_calls (LLM 真产了 tool_call 时, minimax/M3 会返回 id)
@@ -2571,6 +2628,11 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
2571
2628
  const source = this.projectedHistory ?? this.messageHistory;
2572
2629
  const WINDOW = 15;
2573
2630
  const out = [];
2631
+ // 2026-09-28 (前缀 KV 可命中): 带 CURRENT TURN 注入的条目要**原样回带**.
2632
+ // 两个理由: (1) 注入块是"当前轮的前缀", 重渲染 (重加 `[工具结果]` 前缀 / 再 slice 截断)
2633
+ // 会改字节 → 下一轮前缀从这条起分叉; (2) 已注入的 tool 条目在 wire 上已经是 user 形状,
2634
+ // 再套一次前缀就是双前缀, 连内容都变了.
2635
+ const isInjectedEntry = (m) => typeof m.content === 'string' && m.content.includes(CURRENT_TURN_MARKER);
2574
2636
  // 早期历史压缩: 超过窗口时, 不直接丢弃 — 提取前段用户意图摘要注入 (同步, 无 LLM).
2575
2637
  // 结构对齐 Context OS: System Prompt(persona) + 压缩摘要 + 最近消息.
2576
2638
  if (source.length > WINDOW) {
@@ -2586,6 +2648,10 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
2586
2648
  });
2587
2649
  for (const m of slice) {
2588
2650
  const r = m.role;
2651
+ if (isInjectedEntry(m)) {
2652
+ out.push({ role: 'user', content: String(m.content) });
2653
+ continue;
2654
+ }
2589
2655
  if (r === 'tool') {
2590
2656
  out.push({ role: 'user', content: `[工具结果]\n${(m.content || '').slice(0, 2000)}` });
2591
2657
  continue;
@@ -2608,6 +2674,10 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
2608
2674
  const slice = source.slice(-WINDOW);
2609
2675
  for (const m of slice) {
2610
2676
  const r = m.role;
2677
+ if (isInjectedEntry(m)) {
2678
+ out.push({ role: 'user', content: String(m.content) });
2679
+ continue;
2680
+ }
2611
2681
  if (r === 'tool') {
2612
2682
  out.push({ role: 'user', content: `[工具结果]\n${m.content || ''}` });
2613
2683
  continue;
@@ -2631,6 +2701,20 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
2631
2701
  return [{ role: 'user', content: this.buildContext() }];
2632
2702
  }
2633
2703
  }
2704
+ /**
2705
+ * 2026-09-28 (前缀 KV 可命中): 把 chat() 回带的当前轮写回自己的 messageHistory.
2706
+ * 逻辑在模块级 `writeBackCurrentTurnInto` (纯函数, 门可以直接驱它断言);
2707
+ * 这里只负责接到 `this.messageHistory` 上, 并保证任何异常都静默 (写回失败不影响对话).
2708
+ */
2709
+ writeBackCurrentTurn(wire) {
2710
+ try {
2711
+ return writeBackCurrentTurnInto(this.messageHistory, wire);
2712
+ }
2713
+ catch (err) {
2714
+ console.warn('[PiAgent] writeBackCurrentTurn failed (silent):', err);
2715
+ return 0;
2716
+ }
2717
+ }
2634
2718
  /**
2635
2719
  * 估算 messageHistory 的 token 数 (4 字符 ≈ 1 token, 与 context-compaction 同步).
2636
2720
  * 失败静默: 任何异常 → 0 (不阻塞)
@@ -2652,7 +2736,9 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
2652
2736
  *
2653
2737
  * 失败静默: 全部失败 → 返回空 reply, 让上层 no-tool_use 终止
2654
2738
  */
2655
- async callLlmWithRecovery(llm, contextOrMessages, systemPrompt, signal, onStream, tools) {
2739
+ async callLlmWithRecovery(llm, contextOrMessages, systemPrompt, signal, onStream, tools,
2740
+ /** 2026-09-28: 当前轮易变段 (循环进度/改进提示) —— 进 CURRENT TURN 区, 不进 system */
2741
+ currentTurnContext) {
2656
2742
  // Reactive compaction 预检: 估算 token 超 80% 阈值, 跑一次
2657
2743
  const estimated = this.estimateHistoryTokens();
2658
2744
  if (estimated > this.maxContextTokens() * 0.8) {
@@ -2706,9 +2792,13 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
2706
2792
  // M3.5 (2026-06-17): 传 messages 数组 (如果 contextOrMessages 是数组) 或字符串
2707
2793
  // 数组版让 LLM 看到结构化的 user/assistant/tool role, 而不是把 history 拼成单字符串
2708
2794
  // Bug 5: pass tool IDs for native OpenAI tool calling
2709
- const response = await llm.chat(contextOrMessages, systemPrompt, signal, tools);
2795
+ // 2026-09-28 (前缀 KV 可命中): 显式 purpose/source —— pi-sdk 的 ReAct 循环**就是主对话**
2796
+ // (完整 IMMUTABLE PREFIX + 工具全集 + CURRENT TURN, 也只有它带 cache_prompt);
2797
+ // currentTurnContext 是调用方自己的当前轮易变段 (循环进度/改进提示), 走 CURRENT TURN 区.
2798
+ // 2026-09-28: 回带最终 wire messages (含注入后的当前轮) → 上层据此写回 messageHistory.
2799
+ const response = await llm.chat(contextOrMessages, systemPrompt, signal, tools, 'main-agent', 'react-loop', undefined, currentTurnContext);
2710
2800
  // 2026-06-30: 透传 toolCalls (OpenAI 协议 native) 给上层, 让 assistant message 能 emit 真 id
2711
- return { reply: response.reply || '', toolCalls: response.toolCalls };
2801
+ return { reply: response.reply || '', toolCalls: response.toolCalls, messages: response.messages };
2712
2802
  }
2713
2803
  catch (err) {
2714
2804
  // 用户主动 abort: 不重试, 立即抛
@@ -2816,7 +2906,9 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
2816
2906
  // 给 Context Collapse (虚拟投影) 和 Auto-Compact (摘要) 共用
2817
2907
  const llm = getMinimax();
2818
2908
  const llmChat = async (systemPrompt, userPrompt) => {
2819
- const r = await llm.chat(userPrompt, systemPrompt, signal);
2909
+ // 2026-09-28 (前缀 KV 可命中): 压缩摘要是**非主对话的一次性调用** → 轻量前缀
2910
+ // (它不该拖满 IMMUTABLE PREFIX, 更不该把主对话的 KV slot 顶掉).
2911
+ const r = await llm.chat(userPrompt, systemPrompt, signal, undefined, 'auto-compact', 'pi-sdk-compact');
2820
2912
  return r.reply;
2821
2913
  };
2822
2914
  // 2026-08-06: 预算 = ContextManager 配置 (1M * 55% ≈ 550K), 不再写死 8000.
@@ -3082,7 +3174,8 @@ ${this.extractOperationsFromRef(operationsRef)}
3082
3174
  const conversation = messages.map(m => `${m.type === 'user' ? '用户' : '助手'}: ${m.content}`).join('\n');
3083
3175
  const llm = getMinimax();
3084
3176
  try {
3085
- const response = await llm.chat(`根据以下对话内容,为这个对话生成一个简短的名称(不超过20个字):\n\n${conversation}\n\n直接输出名称,不要其他解释。`, '命名建议');
3177
+ const response = await llm.chat(`根据以下对话内容,为这个对话生成一个简短的名称(不超过20个字):\n\n${conversation}\n\n直接输出名称,不要其他解释。`, '命名建议', undefined, undefined, 'chat', 'session-namer' // 非主对话: 轻量前缀, 不抢主对话的 KV slot
3178
+ );
3086
3179
  const name = response.reply.trim();
3087
3180
  // 拒绝错误回退串 (LLM 不可用时返回的占位文本)
3088
3181
  if (!name)
@@ -329,7 +329,9 @@ export class WorkflowPivotLoop {
329
329
  // 2026-08-02: 传原生 OpenAI tools — deepseek 返回结构化 tool_calls,
330
330
  // UI 才能显示真实的工具执行 step (之前靠文本 JSON 猜格式, LLM 编造 result)
331
331
  const openAITools = this.buildOpenAITools();
332
- const llmResponse = await llm.chat(context, headerForThisIter, signal, openAITools);
332
+ // 2026-09-28: pivot loop 就是主对话循环 → purpose='main-agent'
333
+ // (完整 IMMUTABLE PREFIX + 工具全集 + CURRENT TURN; 也只有它带 cache_prompt)
334
+ const llmResponse = await llm.chat(context, headerForThisIter, signal, openAITools, 'main-agent', 'pivot-loop');
333
335
  const reply = (llmResponse.reply || '').trim();
334
336
  this.vlog(`[pivot] iter=${this.state.iteration} LLM took=${Date.now() - t0}ms reply=${reply.length} nativeToolCalls=${llmResponse.toolCalls?.length ?? 0} head=${reply.substring(0, 80).replace(/\n/g, ' ')}`);
335
337
  this.emit({ type: 'token', content: reply });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * efficode/compress - 可插拔压缩层
3
+ *
4
+ * 三个算法 (数据块第一字节 = 算法 id):
5
+ * 0 none 原样 (小消息用 —— 头上开箱代价为 0)
6
+ * 1 lz77 本仓实现, 纯 JS 可跑任何运行面 (含手机 WebView)
7
+ * 2 deflate node:zlib deflateRaw (真压缩, 真数字)
8
+ *
9
+ * 默认策略 `pickAlgo`: 先按内容选, 选完**真压一遍**, 压不小就退回 `none`
10
+ * (小消息硬压一定更贵 —— 这是实测结论, 见 wiki §"更高效吗")。
11
+ */
12
+ import { deflateRawSync, inflateRawSync } from 'node:zlib';
13
+ import { lz77Compress, lz77Decompress } from './lz77.js';
14
+ import { COMPRESSION_IDS, COMPRESSION_BY_ID, EfficodeError } from './types.js';
15
+ export const COMPRESSION_ALGOS = ['none', 'lz77', 'deflate'];
16
+ /** 压缩。返回 [1B 算法 id][载荷] 的完整数据块体 */
17
+ export function compressBlock(raw, algo) {
18
+ const body = compressPayload(raw, algo);
19
+ const out = new Uint8Array(body.length + 1);
20
+ out[0] = COMPRESSION_IDS[algo];
21
+ out.set(body, 1);
22
+ return out;
23
+ }
24
+ function compressPayload(raw, algo) {
25
+ switch (algo) {
26
+ case 'none':
27
+ return raw;
28
+ case 'lz77':
29
+ return lz77Compress(raw);
30
+ case 'deflate':
31
+ return new Uint8Array(deflateRawSync(raw));
32
+ default:
33
+ throw new EfficodeError('EFFICODE_UNKNOWN_COMPRESSION', `未知压缩算法 ${String(algo)}`);
34
+ }
35
+ }
36
+ /** 解压 [1B 算法 id][载荷]。未知算法 id → 报错, 绝不"跳过解压直接给原文" */
37
+ export function decompressBlock(block) {
38
+ if (block.length < 1)
39
+ throw new EfficodeError('EFFICODE_TRUNCATED', '数据块空 (缺算法 id)');
40
+ const algo = COMPRESSION_BY_ID[block[0]];
41
+ if (!algo) {
42
+ throw new EfficodeError('EFFICODE_UNKNOWN_COMPRESSION', `数据块声明未知压缩算法 id=${block[0]}`, 0);
43
+ }
44
+ const payload = block.subarray(1);
45
+ switch (algo) {
46
+ case 'none':
47
+ return { algo, data: payload.slice() };
48
+ case 'lz77':
49
+ return { algo, data: lz77Decompress(payload) };
50
+ case 'deflate': {
51
+ try {
52
+ return { algo, data: new Uint8Array(inflateRawSync(payload)) };
53
+ }
54
+ catch (e) {
55
+ throw new EfficodeError('EFFICODE_TRUNCATED', `deflate 解压失败: ${String(e?.message ?? e).slice(0, 120)}`);
56
+ }
57
+ }
58
+ default:
59
+ throw new EfficodeError('EFFICODE_UNKNOWN_COMPRESSION', `未知压缩算法 ${String(algo)}`);
60
+ }
61
+ }
62
+ /**
63
+ * 默认算法选择: 结构化文本 (重复多) 用 deflate; 其余先用 lz77 试;
64
+ * 压缩后**不小于**原长就退回 none (省下的不该是负数)。
65
+ */
66
+ export function pickAlgo(raw, preferred = 'deflate') {
67
+ if (raw.length < 32)
68
+ return 'none'; // 小消息: 头上开箱代价 > 收益
69
+ const got = compressPayload(raw, preferred);
70
+ return got.length < raw.length ? preferred : 'none';
71
+ }
72
+ /** 真测: 三个算法分别压出来的字节数 (给门和 wiki 用真实数字, 不许估) */
73
+ export function measureAll(raw) {
74
+ const out = {};
75
+ for (const a of COMPRESSION_ALGOS)
76
+ out[a] = compressPayload(raw, a).length + 1; // +1 = 算法 id 字节
77
+ return out;
78
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * efficode/crc32 - 包尾 CRC32 (IEEE 802.3, 多项式 0xEDB88320)
3
+ *
4
+ * 为什么自己写而不是用 zlib.crc32: 包层要能在**任何**运行面 (含手机 WebView 的
5
+ * 纯 JS 上下文) 真算同一个校验值, 所以这里是不依赖 node:zlib 的纯实现。
6
+ * 自证向量写在 src/test/efficode.test.ts: crc32("123456789") === 0xCBF43926 (规范值)。
7
+ */
8
+ const TABLE = (() => {
9
+ const t = new Uint32Array(256);
10
+ for (let n = 0; n < 256; n++) {
11
+ let c = n;
12
+ for (let k = 0; k < 8; k++)
13
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
14
+ t[n] = c >>> 0;
15
+ }
16
+ return t;
17
+ })();
18
+ /** 算 CRC32, 返回无符号 32 位整数 */
19
+ export function crc32(buf) {
20
+ let c = 0xffffffff;
21
+ for (let i = 0; i < buf.length; i++)
22
+ c = TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
23
+ return (c ^ 0xffffffff) >>> 0;
24
+ }
25
+ /** 打包成 4 字节大端 */
26
+ export function crc32Bytes(buf) {
27
+ const v = crc32(buf);
28
+ const out = new Uint8Array(4);
29
+ out[0] = (v >>> 24) & 0xff;
30
+ out[1] = (v >>> 16) & 0xff;
31
+ out[2] = (v >>> 8) & 0xff;
32
+ out[3] = v & 0xff;
33
+ return out;
34
+ }
35
+ /** 从 4 字节大端读回 */
36
+ export function readCrc32Bytes(b, offset = 0) {
37
+ return (((b[offset] << 24) | (b[offset + 1] << 16) | (b[offset + 2] << 8) | b[offset + 3]) >>> 0);
38
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * efficode/did - DID 验证段 (固定 32B)
3
+ *
4
+ * 布局 (32 字节):
5
+ * [0] algo id (= 1, sha256-截断)
6
+ * [1..3] 原始身份串字节数 (24 位大端, 上限 16MB)
7
+ * [4..31] sha256(身份串) 的**前 28 字节**
8
+ *
9
+ * 诚实边界 (必须写进 wiki, 不许含糊):
10
+ * - 32B **装不下**一个 DID / 公钥, 所以本段是**摘要**, 只能做"是不是同一个身份"的比对,
11
+ * **不能**从段里还原身份, 也**不能**独立证明持有私钥;
12
+ * - 真正的"链上签名验证 + ECC 临时会话密钥"属**规范中/未实现** —— 本模块没有签名函数,
13
+ * 也没有密钥交换函数 (不写假实现)。签名校验的接线点在 packet 层的 `verifyDid` 回调,
14
+ * 现成可用的只有摘要比对这一条。
15
+ */
16
+ import { createHash } from 'node:crypto';
17
+ import { DID_SEGMENT_BYTES, EfficodeError } from './types.js';
18
+ export const DID_ALGO_SHA256_TRUNC28 = 1;
19
+ /** 编: 身份串 → 32B 段 */
20
+ export function encodeDidSegment(did) {
21
+ const raw = Buffer.from(String(did), 'utf-8');
22
+ if (raw.length === 0)
23
+ throw new EfficodeError('EFFICODE_DID_SEGMENT_BAD', '身份串为空');
24
+ if (raw.length > 0xffffff)
25
+ throw new EfficodeError('EFFICODE_DID_SEGMENT_BAD', `身份串过长 (${raw.length}B > 16MB)`);
26
+ const full = createHash('sha256').update(raw).digest();
27
+ const seg = new Uint8Array(DID_SEGMENT_BYTES);
28
+ seg[0] = DID_ALGO_SHA256_TRUNC28;
29
+ seg[1] = (raw.length >>> 16) & 0xff;
30
+ seg[2] = (raw.length >>> 8) & 0xff;
31
+ seg[3] = raw.length & 0xff;
32
+ seg.set(full.subarray(0, DID_SEGMENT_BYTES - 4), 4);
33
+ return seg;
34
+ }
35
+ /** 解: 32B 段 → 结构。长度不足 / algo 未知 → 抛 */
36
+ export function decodeDidSegment(seg) {
37
+ if (seg.length !== DID_SEGMENT_BYTES) {
38
+ throw new EfficodeError('EFFICODE_DID_SEGMENT_BAD', `DID 段应为 ${DID_SEGMENT_BYTES}B, 实得 ${seg.length}B`);
39
+ }
40
+ const algo = seg[0];
41
+ if (algo !== DID_ALGO_SHA256_TRUNC28) {
42
+ throw new EfficodeError('EFFICODE_DID_SEGMENT_BAD', `未知 DID 摘要算法 id=${algo}`, 0);
43
+ }
44
+ const origLen = (seg[1] << 16) | (seg[2] << 8) | seg[3];
45
+ return { algo, origLen, digest: seg.slice(4) };
46
+ }
47
+ /** 摘要段的稳定文本形式: `algo1:<56 hex>` —— 可安全落日志 (单向, 不可还原身份) */
48
+ export function didDigestString(seg) {
49
+ return `algo${seg.algo}:${Buffer.from(seg.digest).toString('hex')}`;
50
+ }
51
+ /**
52
+ * 校验一条身份声明是否与段匹配。
53
+ * 这是**明文比对** (拿原身份串再算一遍摘要), 不是签名验证 —— 返回值里如实标出方式。
54
+ */
55
+ export function matchesDidSegment(did, seg) {
56
+ const expected = encodeDidSegment(did);
57
+ let match = expected.length === seg.length;
58
+ if (match) {
59
+ for (let i = 0; i < seg.length; i++)
60
+ if (expected[i] !== seg[i]) {
61
+ match = false;
62
+ break;
63
+ }
64
+ }
65
+ return { match, method: 'digest', signed: false };
66
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * efficode - AI 专用交流语言的**参考实现** (v0.1.0)
3
+ *
4
+ * 模块划分:
5
+ * types.ts 公共类型 / 失败码
6
+ * ops.ts 指令集与词典 (符号 ↔ 二进制操作码, 前缀表达式)
7
+ * crc32.ts 包尾 CRC32 (纯 JS, 任何运行面可算)
8
+ * lz77.ts 自解压数据块用的轻量 LZ77 (真实实现)
9
+ * compress.ts 可插拔压缩层 (none / lz77 / deflate)
10
+ * did.ts DID 验证段 (32B 摘要段)
11
+ * packet.ts 包封装: [Header 2B]|[DID 32B]|[指令段]|[数据块]|[CRC32 4B]
12
+ * negotiate.ts 智能体"用哪种语言"的声明/协商/回落与记录
13
+ *
14
+ * 状态的**唯一事实源**在 docs/wiki/efficode.md 的逐节标注 (已实现 / 规范中 / 未实现);
15
+ * 本目录只提供代码, 代码里的注释只写"这段代码真做了什么"。
16
+ */
17
+ export * from './types.js';
18
+ export * from './ops.js';
19
+ export * from './crc32.js';
20
+ export * from './lz77.js';
21
+ export * from './compress.js';
22
+ export * from './did.js';
23
+ export * from './packet.js';
24
+ export * from './negotiate.js';