Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/fovea/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

本文件记录 fovea 的对外变化。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本遵循语义化版本。

## [0.2.5] — 2026-11-05

### Added

- 支持 Gemini 请求(`contents` 字段):thought 与 thoughtSignature、functionCall 不动;functionResponse 保留 id / name / 键,其中的长字符串被裁;图片换成注记;文档与视频载荷不动。此前 Gemini 请求完全原样放行,装了也不省。

### Fixed

- `saccade: "auto"` 不再把 Claude Code 的 `<system-reminder>`(token 预算、工具指引)当成用户的新问题。它与任务没有共同词,旧规则判成换话题、整段历史折叠。
- Responses 请求的首个答案是 `custom_tool_call` / `computer_call` / `local_shell_call` 时,任务豁免范围算错,整条历史一条都不裁。
- Chat 旧版 functions API 的 `role: "function"` 消息此前完全没被处理,那正是最占钱的工具输出。

### Changed

- 新增 `fovea.protocols.test.mjs`:四种协议各自的形状与边界(Anthropic Messages、OpenAI Responses、Chat Completions、Gemini),12 条用例。

## [0.2.4] — 2026-11-05

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions packages/fovea/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
**magpie 网关中间件:最近窗口看得清,之前的旧历史只带轮廓。**

[![License: MIT](https://img.shields.io/badge/License-MIT-2f63a2.svg?style=for-the-badge)](LICENSE)
[![Tests](https://img.shields.io/badge/tests-45%2F45-brightgreen?style=for-the-badge)](#证据三层各管什么)
[![Tests](https://img.shields.io/badge/tests-57%2F57-brightgreen?style=for-the-badge)](#证据三层各管什么)
[![Platform](https://img.shields.io/badge/magpie-middleware-8a6bbf?style=for-the-badge)](https://usemagpie.ai)
[![Runtime](https://img.shields.io/badge/runtime-moejs%20%C2%B7%20zero%20deps-4a5568?style=for-the-badge)](fovea.middleware.js)

Expand Down Expand Up @@ -78,7 +78,7 @@ git clone https://github.com/magpie-community/plugins && bun test packages/fovea

## 证据三层,各管什么

1. **规则正确性** —— `bun test`:45 个确定性用例(离线、零依赖)。覆盖三种形状的工具配对、protect 在两种模式下生效、代理对不劈开、document 块不动、标记幂等、连续两轮已裁前缀逐字节相同。
1. **规则正确性** —— `bun test`:57 个确定性用例(离线、零依赖)。覆盖三种形状的工具配对、protect 在两种模式下生效、代理对不劈开、document 块不动、标记幂等、连续两轮已裁前缀逐字节相同。
2. **同一请求确实变小** —— `bun benchmark.mjs [request.json] [options]`,或直接在证据页粘贴你的请求。合成样例:47,777 → 14,148 字符,已裁的 27 条消息逐字稳定。
3. **真实 token / 缓存 / 费用** —— `FOVEA_GATEWAY_KEY=<临时key> bun live-benchmark.mjs`:同模型、同请求、ABBA 四连发,记录供应商返回的 input / cache_read / output token 与耗时,只看暖缓存的第二次。0.1.0 时代的一次实测:163,143 → 42,358 prompt tokens,中位延迟 7.1s → 2.5s(因历史形状与供应商缓存而异)。**账单以 `magpie usage --csv` 的 `cost_usd` 为准;字符百分比不是美元。**

Expand Down
160 changes: 152 additions & 8 deletions packages/fovea/fovea.middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export function onRequest(body, ctx) {
// carries body.input. Size is measured by walking, not by
// stringifying: a 5MB body would otherwise allocate 5MB of string on
// every request, on the request path.
const list = Array.isArray(body.messages) ? body.messages : Array.isArray(body.input) ? body.input : null
const list = Array.isArray(body.messages) ? body.messages : Array.isArray(body.input) ? body.input : Array.isArray(body.contents) ? body.contents : null
if (!list) return
if (cfg.threshold > 0 && sizeOf(body) < cfg.threshold) return

Expand Down Expand Up @@ -115,6 +115,7 @@ export function onRequest(body, ctx) {
const item = list[i]
if (cfg.stateMarker && carriesState(item, cfg.stateMarker)) continue
if (list === body.input) changed = (saccade ? collapseInput : slimInput)(item, cfg) || changed
else if (list === body.contents) changed = (saccade ? collapseGemini : slimGemini)(item, cfg) || changed
else changed = (saccade ? collapseMessage : slimMessage)(item, cfg) || changed
}
return changed ? body : undefined
Expand Down Expand Up @@ -147,12 +148,28 @@ function windowStart(list, keepCount, keepTokens) {
// assistant reply, a function_call or a reasoning item, whichever comes
// first — is that preamble and stays whole; the history proper begins at
// the answer.
// The Responses API marks an answer with the item's type, not the role:
// a function_call, a custom_tool_call, a computer_call, a local_shell_call
// and their outputs are all "the model spoke". Leaving them out made the
// preamble exemption swallow the whole history on a body whose first
// answer was a custom_tool_call (2026-11-05).
const ANSWER_ITEMS = new Set([
"function_call", "function_call_output",
"custom_tool_call", "custom_tool_call_output",
"computer_call", "computer_call_output",
"local_shell_call", "local_shell_call_output",
"web_search_call", "reasoning",
])

function leadingUserEnd(list, start) {
for (let i = 0; i < start; i++) {
const it = list[i]
if (!it || typeof it !== "object") continue
if (it.role === "assistant" || it.role === "tool" || it.type === "function_call" ||
it.type === "reasoning" || it.type === "function_call_output") return i
// Gemini's model turns carry role "model"; its function responses ride
// in a user turn's parts, so the role alone cannot mark the answer.
if (it.role === "assistant" || it.role === "model" || it.role === "tool") return i
if (typeof it.type === "string" && ANSWER_ITEMS.has(it.type)) return i
if (Array.isArray(it.parts) && it.parts.some((p) => p && p.functionCall)) return i
}
return start
}
Expand All @@ -175,6 +192,13 @@ function hasStateBlock(list, marker) {

function carriesState(item, marker) {
if (!item || typeof item !== "object" || !marker) return false
// Gemini's model turn is the agent's own message, like an assistant one.
if (item.role === "model" && Array.isArray(item.parts)) {
for (const p of item.parts) {
if (p && typeof p === "object" && typeof p.text === "string" && startsWithMarker(p.text, marker)) return true
}
return false
}
if (item.role !== "assistant") return false
if (item.type !== undefined && item.type !== "message") return false
if (typeof item.content === "string") return startsWithMarker(item.content, marker)
Expand All @@ -195,7 +219,11 @@ function startsWithMarker(text, marker) {
function slimMessage(m, cfg) {
if (!m || typeof m !== "object") return false
if (m.role === "system" || m.role === "developer") return false
if (m.role === "tool") return slimContent(m, "content", cfg.toolHead, cfg.toolTail, cfg)
// role "function" is the legacy functions API's answer half (older
// clients). Its content is the tool output and just as big as a
// role "tool" message — leaving it whole meant the oldest clients got
// none of the saving.
if (m.role === "tool" || m.role === "function") return slimContent(m, "content", cfg.toolHead, cfg.toolTail, cfg)
if (m.role === "assistant") {
// tool_calls are the pairing half of the tool results below; thinking
// blocks carry signatures their next turn checks. Neither is ours.
Expand Down Expand Up @@ -303,7 +331,8 @@ function slimContent(holder, key, head, tail, cfg) {
function collapseMessage(m, cfg) {
if (!m || typeof m !== "object") return false
if (m.role === "system" || m.role === "developer") return false
if (m.role === "tool") return collapseContent(m, "content", cfg)
// role "function": the legacy functions API's answer half — see slimMessage.
if (m.role === "tool" || m.role === "function") return collapseContent(m, "content", cfg)
if (m.role === "assistant") {
let changed = false
if (typeof m.content === "string") changed = collapseText(m, "content", cfg)
Expand Down Expand Up @@ -400,24 +429,39 @@ function firstTask(list) {
const it = list[i]
if (it && it.role === "user") {
const t = textOf(it)
if (t) return t
if (t) return withoutHarness(t)
}
}
for (const it of list) {
if (it && it.role === "user") {
const t = textOf(it)
if (t) return t
if (t) return withoutHarness(t)
}
}
return null
}

// Harness-injected blocks, not the user's words. Claude Code appends
// <system-reminder> as its own user message — token budgets, tool guidance,
// file-change notices. Reading one as "the newest question" made every
// follow-up look like a new topic: the reminder shares no words with the
// task, so the overlap test said "shifted" and the history collapsed.
const HARNESS_BLOCKS = /<(system-reminder|system-reminder-sudo)>[\s\S]*?<\/\1>/gi

function withoutHarness(text) {
return String(text).replace(HARNESS_BLOCKS, " ").replace(/\s+/g, " ").trim()
}

function lastQuestion(list) {
for (let i = list.length - 1; i >= 0; i--) {
const it = list[i]
if (!it || it.role !== "user") continue
const t = textOf(it)
if (t) return t
if (!t) continue
// A message that is nothing but a harness block is not a question;
// keep looking for the user's actual words.
const spoken = withoutHarness(t)
if (spoken) return spoken
}
return null
}
Expand All @@ -426,6 +470,17 @@ function lastQuestion(list) {
// tool_result content and image payloads are the machinery, not the
// question.
function textOf(it) {
// Gemini: the text rides in parts[], and a part may be a thought
// (thought: true) — the model's own reasoning, not the user's words.
if (Array.isArray(it.parts)) {
let out = ""
for (const p of it.parts) {
if (!p || typeof p !== "object" || typeof p.text !== "string") continue
if (p.thought) continue
out += " " + p.text
}
return out.trim() || null
}
if (typeof it.content === "string") return it.content
if (!Array.isArray(it.content)) return null
let out = ""
Expand All @@ -441,6 +496,95 @@ function words(text) {
return out
}

// ── gemini ─────────────────────────────────────────────────────────────
// Gemini's history is contents[]: each turn has role user|model and parts[].
// A part may be plain text, a thought (thought: true), a functionCall, a
// functionResponse (the tool output — the big one), inlineData (an image
// or a file) or fileData.
//
// What must not be touched: thoughts and anything carrying a
// thoughtSignature (the provider checks the signature over the payload),
// functionCall (the call half of a functionResponse), and the sources —
// inlineData of a document or a video is a payload, not history prose.
//
// A functionResponse keeps its id, its name and its keys; only the strings
// inside the response object are thinned.

function slimGemini(c, cfg) {
if (!c || typeof c !== "object" || !Array.isArray(c.parts)) return false
let changed = false
for (const p of c.parts) {
if (!p || typeof p !== "object") continue
if (p.thought || p.thoughtSignature) continue
if (p.functionCall) continue
if (p.functionResponse) {
changed = slimFunctionResponse(p, cfg) || changed
} else if (typeof p.text === "string") {
const u = cut(p.text, cfg.textHead, cfg.textTail, "text", cfg)
if (u !== p.text) { p.text = u; changed = true }
} else if (cfg.dropImages && p.inlineData && typeof p.inlineData.mimeType === "string" && p.inlineData.mimeType.startsWith("image/")) {
p.text = "[" + MARK + ": old image omitted]"
delete p.inlineData
changed = true
}
}
return changed
}

function collapseGemini(c, cfg) {
if (!c || typeof c !== "object" || !Array.isArray(c.parts)) return false
let changed = false
for (const p of c.parts) {
if (!p || typeof p !== "object") continue
if (p.thought || p.thoughtSignature) continue
if (p.functionCall) continue
if (p.functionResponse) {
changed = collapseFunctionResponse(p, cfg) || changed
} else if (typeof p.text === "string") {
changed = collapseText(p, "text", cfg) || changed
} else if (p.inlineData && typeof p.inlineData.mimeType === "string" && p.inlineData.mimeType.startsWith("image/")) {
p.text = NOTE
delete p.inlineData
changed = true
}
}
return changed
}

// The response object is free-form: walk it and cut its long strings,
// leaving the keys and the shape alone.
function slimFunctionResponse(part, cfg) {
const r = part.functionResponse && part.functionResponse.response
if (!r || typeof r !== "object") return false
let changed = false
for (const key of Object.keys(r)) {
const v = r[key]
if (typeof v === "string") {
const u = cut(v, cfg.toolHead, cfg.toolTail, "tool output", cfg)
if (u !== v) { r[key] = u; changed = true }
} else if (v && typeof v === "object") {
changed = slimFunctionResponse({ functionResponse: { response: v } }, cfg) || changed
}
}
return changed
}

function collapseFunctionResponse(part, cfg) {
const r = part.functionResponse && part.functionResponse.response
if (!r || typeof r !== "object") return false
let changed = false
for (const key of Object.keys(r)) {
const v = r[key]
if (typeof v === "string") {
const holder = { value: v }
if (collapseText(holder, "value", cfg)) { r[key] = holder.value; changed = true }
} else if (v && typeof v === "object") {
changed = collapseFunctionResponse({ functionResponse: { response: v } }, cfg) || changed
}
}
return changed
}

// ── shared ─────────────────────────────────────────────────────────────

function isImage(type) {
Expand Down
Loading
Loading