Skip to content

Commit 7dfd605

Browse files
committed
refactor(openclaw): reuse shared context search contract
1 parent b34c22f commit 7dfd605

15 files changed

Lines changed: 999 additions & 37 deletions

examples/memory-plugin-shared/sync.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ const AGENT_PLUGINS_SHARED_FILES = [
3232
"mcp-proxy-config.mjs",
3333
"workspace-peer.mjs",
3434
];
35+
const OPENCLAW_SHARED_FILES = [
36+
"recall-compress-core.mjs",
37+
"recall-core.mjs",
38+
];
3539
const TARGETS = [
3640
{ dir: join(ROOT, "examples", "claude-code-memory-plugin", "scripts", "shared"), files: DOCTOR_SHARED_FILES },
3741
{ dir: join(ROOT, "examples", "codex-memory-plugin", "scripts", "shared"), files: DOCTOR_SHARED_FILES },
@@ -40,6 +44,7 @@ const TARGETS = [
4044
{ dir: join(ROOT, "examples", "pi-coding-agent-extension", "shared"), files: HARNESS_SHARED_FILES },
4145
{ dir: join(ROOT, "examples", "zcode-memory-plugin", "scripts", "shared") , files: ZCODE_SHARED_FILES },
4246
{ dir: join(ROOT, "agent-plugins", "servers", "shared"), files: AGENT_PLUGINS_SHARED_FILES },
47+
{ dir: join(ROOT, "examples", "openclaw-plugin", "shared"), files: OPENCLAW_SHARED_FILES },
4348
];
4449

4550
const GENERATED_HEADER = "// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.\n";

examples/memory-plugin-shared/sync.test.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ const AGENT_PLUGINS_SHARED_FILES = [
2727
"mcp-proxy-core.mjs",
2828
"workspace-peer.mjs",
2929
];
30+
const OPENCLAW_SHARED_FILES = [
31+
"recall-compress-core.mjs",
32+
"recall-core.mjs",
33+
];
3034
const TARGETS = [
3135
{ dir: join(ROOT, "examples", "claude-code-memory-plugin", "scripts", "shared"), files: DOCTOR_SHARED_FILES },
3236
{ dir: join(ROOT, "examples", "codex-memory-plugin", "scripts", "shared"), files: DOCTOR_SHARED_FILES },
@@ -35,6 +39,7 @@ const TARGETS = [
3539
{ dir: join(ROOT, "examples", "pi-coding-agent-extension", "shared"), files: HARNESS_SHARED_FILES },
3640
{ dir: join(ROOT, "examples", "zcode-memory-plugin", "scripts", "shared") , files: ZCODE_SHARED_FILES },
3741
{ dir: join(ROOT, "agent-plugins", "servers", "shared"), files: AGENT_PLUGINS_SHARED_FILES },
42+
{ dir: join(ROOT, "examples", "openclaw-plugin", "shared"), files: OPENCLAW_SHARED_FILES },
3843
];
3944
const GENERATED_HEADER = "// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.\n";
4045
const SKILLS_DIR = join(ROOT, "examples", "skills");

examples/openclaw-plugin/client.ts

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import {
1212
defaultResourcePackager,
1313
type ResourcePackager,
1414
} from "./adapters/resource-packager.js";
15+
import {
16+
buildContextSearchBody,
17+
contextRequestTimeoutMs,
18+
normalizeContextEntry,
19+
} from "./shared/recall-core.mjs";
1520

1621
export type FindResultItem = {
1722
uri: string;
@@ -522,19 +527,26 @@ export class OpenVikingClient {
522527
query: string,
523528
options: SearchContextOptions = {},
524529
): Promise<SearchContextResult> {
530+
const contractConfig = {
531+
recallLimit: options.limit,
532+
recallLimitConfigured: options.limit !== undefined,
533+
recallMaxTokens: options.maxTokens,
534+
recallMaxTokensConfigured: options.maxTokens !== undefined,
535+
scoreThreshold: options.scoreThreshold,
536+
recallQueryExpansion: options.queryExpansion,
537+
recallQueryExpansionConfigured: options.queryExpansion !== undefined,
538+
recallDedupTurns: options.dedupTurns,
539+
recallPeerScope: options.peerScope,
540+
recallContextTimeoutMs: options.requestTimeoutMs,
541+
timeoutMs: this.timeoutMs,
542+
};
525543
const body = {
544+
...buildContextSearchBody(contractConfig, { sessionId: options.sessionId }),
526545
query,
527-
mode: "context",
528-
session_id: options.sessionId,
529-
limit: options.limit,
530-
score_threshold: options.scoreThreshold,
531-
context_type: options.contextType,
532-
query_expansion: options.queryExpansion,
533-
max_tokens: options.maxTokens,
534-
detail: options.detail,
535-
dedup_turns: options.dedupTurns,
536-
peer_scope: options.peerScope,
546+
...(options.contextType !== undefined ? { context_type: options.contextType } : {}),
547+
...(options.detail !== undefined ? { detail: options.detail } : {}),
537548
};
549+
const requestTimeoutMs = contextRequestTimeoutMs(contractConfig, body);
538550
const actorPeerId = this.resolveActorPeerHeader(options.actorPeerId);
539551
const tenantHeaders = this.resolveTenantHeaders();
540552
this.routingDebugLog?.(
@@ -543,25 +555,32 @@ export class OpenVikingClient {
543555
X_OpenViking_Account: tenantHeaders.accountId ?? null,
544556
X_OpenViking_User: tenantHeaders.userId ?? null,
545557
X_OpenViking_Actor_Peer: actorPeerId ?? null,
546-
session_id: options.sessionId ?? null,
558+
session_id: body.session_id ?? null,
547559
query:
548560
query.length > 4000
549561
? `${query.slice(0, 4000)}…(+${query.length - 4000} more chars)`
550562
: query,
551-
limit: options.limit,
552-
score_threshold: options.scoreThreshold ?? null,
553-
context_type: options.contextType ?? null,
554-
query_expansion: options.queryExpansion ?? null,
555-
max_tokens: options.maxTokens,
556-
detail: options.detail ?? null,
557-
dedup_turns: options.dedupTurns ?? 0,
558-
peer_scope: options.peerScope ?? null,
563+
purpose: body.purpose,
564+
quotas: body.quotas ?? null,
565+
score_threshold: body.score_threshold,
566+
context_type: body.context_type ?? null,
567+
query_expansion: body.query_expansion ?? null,
568+
max_tokens: body.max_tokens ?? null,
569+
detail: body.detail ?? null,
570+
dedup_turns: body.dedup_turns ?? 0,
571+
peer_scope: body.peer_scope ?? null,
559572
}),
560573
);
561-
return this.request<SearchContextResult>("/api/v1/search/search", {
574+
const result = await this.request<SearchContextResult>("/api/v1/search/search", {
562575
method: "POST",
563576
body: JSON.stringify(body),
564-
}, options.requestTimeoutMs, actorPeerId);
577+
}, requestTimeoutMs, actorPeerId);
578+
return {
579+
...result,
580+
entries: Array.isArray(result.entries)
581+
? result.entries.map((entry) => ({ ...entry, ...normalizeContextEntry(entry) }))
582+
: result.entries,
583+
};
565584
}
566585

567586
async read(uri: string, actorPeerId?: string): Promise<string> {

examples/openclaw-plugin/docs/openviking-openclaw-plugin-guide.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -770,7 +770,7 @@ openclaw config get plugins.slots.contextEngine
770770
| `autoRecallTimeoutMs` | 自动召回总超时 | `15000` |||| 覆盖单次服务端 context search;默认值为最长 5 秒的 session query expansion 及后续检索保留余量。显式配置的值仍会按 `1000..300000` ms 限制。 |
771771
| `recallTargetTypes` | 自动召回 + 默认 `memory_recall` 资源类型集合 | `["user","agent"]` || 安装脚本/setup 参数支持 | `OPENVIKING_RECALL_TARGET_TYPES`(安装脚本写入 setup 参数) | 当前默认只查 `user` + `agent` 记忆。设置为 `["resource"]` 才会切成 resource-only;可组合 `resource,user,agent``config.ts:174``config.ts:360` |
772772
| `recallResources` | 自动召回 + 默认 `memory_recall` resources 兼容开关 | `false` ||| `OPENVIKING_RECALL_RESOURCES` | 旧兼容字段;只有未显式配置 `recallTargetTypes` 时才把 `resource` 追加到默认 `user` + `agent`,不会覆盖显式 resource-only:`config.ts:360` |
773-
| `recallLimit` | 自动召回 / `memory_recall` 返回条数 | `6` |||| 自动召回直接作为 context search `limit`;显式 `memory_recall` 仍按该值做最终选择。 |
773+
| `recallLimit` | 自动召回 / `memory_recall` 返回条数 | `6` |||| 自动召回按共享 context-search 契约映射为 coding quotas;显式 `memory_recall` 仍按该值做最终选择。 |
774774
| `recallScoreThreshold` | 自动召回 / `memory_recall` 过滤阈值 | `0.15` |||| 自动召回交给服务端过滤;显式 `memory_recall` 保留本地后处理。 |
775775
| `recallMaxInjectedChars` | 自动召回 / `memory_recall` 注入预算 | `4000` |||| 自动召回按 4 字符/token 换算为服务端 `max_tokens`;显式 `memory_recall` 仍使用字符预算。 |
776776
| `recallPreferAbstract` | 自动召回读取策略 | `false` ||||`true` 时把服务端 detail 固定为 `abstract`;否则由服务端按类别选择默认层级。 |
@@ -1095,7 +1095,7 @@ OPENVIKING_DEBUG=1 openclaw gateway restart
10951095

10961096
| 日志/字段 | 含义 | 关键路径 |
10971097
| --- | --- | --- |
1098-
| `openviking: context search POST .../api/v1/search/search {...}` | 自动召回向 OpenViking 发起服务端组装检索 | `session_id` / `context_type` / `query_expansion` / `peer_scope` / actor 与租户路由 |
1098+
| `openviking: context search POST .../api/v1/search/search {...}` | 自动召回向 OpenViking 发起服务端组装检索 | `purpose` / `quotas` / `session_id` / `context_type` / `query_expansion` / `max_tokens` / `peer_scope` / actor 与租户路由 |
10991099
| `openviking: find POST .../api/v1/search/find {...}` | 显式 recall/search 工具发起底层语义检索 | `target_uri` / `target_uri_input` / `query` / `X_OpenViking_Agent` |
11001100
| `openviking: injecting N memories ...` | 插件决定向本轮 prompt 注入 N 条召回内容 | `N`、注入字符数、估算 token |
11011101
| `openviking: inject-detail {...}` | 本轮实际注入模型的服务端组装条目摘要 | `entries[].uri``category``score``detail` |

examples/openclaw-plugin/docs/openviking-plugin-reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ $OPENCLAW_STATE_DIR/openclaw.json
9898
| `targetUri` | string | `viking://user/memories` || `memory_recall` / `memory_forget` 默认搜索范围。 |
9999
| `recallTargetTypes` | string[] | `["user", "agent"]` || 自动召回和默认 `memory_recall` 的搜索类型。允许 `resource``user``agent`|
100100
| `recallResources` | boolean | `false` | `OPENVIKING_RECALL_RESOURCES` | 旧兼容开关;仅在未显式配置 `recallTargetTypes` 时追加 `resource`|
101-
| `recallLimit` | number | `6` || 自动召回直接作为服务端 context search `limit`;显式 `memory_recall` 保留本地候选扩展。 |
101+
| `recallLimit` | number | `6` || 自动召回按共享 context-search 契约映射为 coding quotas;显式 `memory_recall` 保留本地候选扩展。 |
102102
| `recallScoreThreshold` | number | `0.15` || 自动召回交给服务端过滤;显式 `memory_recall` 保留本地后处理。范围 `0``1`|
103103
| `recallMaxInjectedChars` | number | `4000` || 自动召回按 4 字符/token 换算为服务端 `max_tokens`;显式召回仍使用字符预算。范围 `100``50000`|
104104
| `recallPreferAbstract` | boolean | `false` || 自动召回为 true 时请求服务端 abstract detail;否则由服务端按类别选择层级。 |

examples/openclaw-plugin/install-manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"adapters/",
2727
"registries/",
2828
"routing/",
29+
"shared/",
2930
"plugin/",
3031
"services/",
3132
"commands/setup.ts",

examples/openclaw-plugin/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"config/feature-gates.json",
2727
"registries/",
2828
"routing/",
29+
"shared/",
2930
"plugin/",
3031
"services/",
3132
"!vitest.config.ts",
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2+
import { createHash } from "node:crypto";
3+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
4+
import { dirname } from "node:path";
5+
6+
export const NO_RELEVANT_MEMORY = "NO_RELEVANT_MEMORY";
7+
export const DIGEST_HEADER = "OpenViking memory digest:";
8+
const COMPRESS_OK = "ok";
9+
const COMPRESS_EMPTY = "empty";
10+
const COMPRESS_FAILED = "failed";
11+
12+
// Medium-constraint prompt: state the goal and two structural floors, but no hard
13+
// bullet-length contract. Hard per-bullet limits pin the digest to headline
14+
// density; leaving it unconstrained lets small models rewrite long URIs into dead
15+
// links, which the URI repair below cleans up.
16+
export function buildRecallCompressionPrompt({ query, rendered, maxBullets = 6 }) {
17+
return `You are a memory relevance compressor utility.
18+
Do not use any tools. Do not investigate. Only transform the given text.
19+
20+
User query:
21+
${query}
22+
23+
Retrieved OpenViking context fragments:
24+
${rendered}
25+
26+
Write a memory digest for a coding agent about to answer that query. Keep the
27+
concrete facts (paths, identifiers, decisions, constraints); drop pleasantries
28+
and conversational filler.
29+
30+
Format rules:
31+
- Group related facts by topic, one bullet per topic, at most ${maxBullets} bullets.
32+
- Start every bullet with "- ".
33+
- End every bullet with its source, copied verbatim from the fragments above:
34+
"来源:viking://..." or "source: viking://...". Never edit, shorten, or invent a URI.
35+
- Output the digest body only. No preamble, no closing remark.
36+
37+
If nothing above is relevant to the query, output exactly: ${NO_RELEVANT_MEMORY}`;
38+
}
39+
40+
function editDistance(a, b) {
41+
if (a === b) return 0;
42+
const rows = a.length + 1;
43+
const cols = b.length + 1;
44+
let prev = Array.from({ length: cols }, (_, i) => i);
45+
for (let i = 1; i < rows; i += 1) {
46+
const cur = [i];
47+
for (let j = 1; j < cols; j += 1) {
48+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
49+
cur[j] = Math.min(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
50+
}
51+
prev = cur;
52+
}
53+
return prev[cols - 1];
54+
}
55+
56+
function nearestUri(candidate, validUris) {
57+
let best = "";
58+
let bestDistance = Infinity;
59+
for (const uri of validUris) {
60+
const distance = editDistance(candidate, uri);
61+
if (distance < bestDistance) {
62+
best = uri;
63+
bestDistance = distance;
64+
}
65+
}
66+
// Only repair near-misses; an unrelated hallucination is dropped instead.
67+
const tolerance = Math.max(4, Math.floor(candidate.length * 0.25));
68+
return bestDistance <= tolerance ? best : "";
69+
}
70+
71+
/**
72+
* Small models occasionally mangle long URIs. Snap every cited URI back onto the
73+
* set the server actually returned, and drop bullets whose citation cannot be
74+
* recovered so the digest never carries a dead link.
75+
*/
76+
export function repairDigestUris(digest, validUris = []) {
77+
const text = String(digest || "");
78+
if (!text) return "";
79+
const valid = validUris.map((uri) => String(uri || "").trim()).filter(Boolean);
80+
if (!valid.length) return text;
81+
const validSet = new Set(valid);
82+
83+
const lines = [];
84+
for (const line of text.split(/\r?\n/)) {
85+
if (!line.trim().startsWith("- ")) {
86+
lines.push(line);
87+
continue;
88+
}
89+
let dropped = false;
90+
const repaired = line.replace(/viking:\/\/[^\s<>"')\]]+/g, (uri) => {
91+
if (validSet.has(uri)) return uri;
92+
const nearest = nearestUri(uri, valid);
93+
if (nearest) return nearest;
94+
dropped = true;
95+
return uri;
96+
});
97+
if (!dropped) lines.push(repaired);
98+
}
99+
return lines.join("\n").trim();
100+
}
101+
102+
export function normalizeCompressedContext(raw, maxChars = 4000, maxBullets = 6) {
103+
const text = String(raw || "").trim();
104+
if (!text) return null;
105+
if (text.toUpperCase() === NO_RELEVANT_MEMORY) return "";
106+
const bullets = text.split(/\r?\n/)
107+
.map((line) => line.trim())
108+
.filter((line) => /^[-*]\s+/.test(line) && line.includes("viking://"))
109+
.slice(0, Math.max(1, maxBullets))
110+
.map((line) => `- ${line.replace(/^[-*]\s+/, "").slice(0, 500).trim()}`);
111+
if (!bullets.length) return null;
112+
return (`${DIGEST_HEADER}\n${bullets.join("\n")}`).slice(0, Math.max(100, maxChars));
113+
}
114+
115+
export function recallDigestCacheKey({
116+
query = "",
117+
rendered = "",
118+
entries = [],
119+
maxInputChars = 18000,
120+
maxBullets = 6,
121+
} = {}) {
122+
const uris = entries.map((entry) => String(entry?.uri || "").trim()).filter(Boolean).sort();
123+
const source = JSON.stringify({
124+
version: 2,
125+
query: String(query),
126+
rendered: String(rendered).slice(0, maxInputChars),
127+
uris,
128+
maxInputChars,
129+
maxBullets,
130+
});
131+
return createHash("sha256").update(source).digest("hex");
132+
}
133+
134+
async function readCache(path) {
135+
if (!path) return null;
136+
try { return JSON.parse(await readFile(path, "utf8")); } catch { return null; }
137+
}
138+
139+
async function writeCache(path, value) {
140+
if (!path) return;
141+
try {
142+
await mkdir(dirname(path), { recursive: true });
143+
const tmp = `${path}.tmp`;
144+
await writeFile(tmp, JSON.stringify(value));
145+
await rename(tmp, path);
146+
} catch { /* best effort */ }
147+
}
148+
149+
export async function compressRecallContext({
150+
query,
151+
rendered,
152+
entries = [],
153+
cfg = {},
154+
runCompressor,
155+
cachePath = "",
156+
now = 0,
157+
}) {
158+
const input = String(rendered || "").trim();
159+
if (!input) return { status: COMPRESS_EMPTY, context: "" };
160+
const minChars = Math.max(0, Number(cfg.recallCompressMinInputChars ?? 1500));
161+
if (input.length < minChars) return { status: COMPRESS_OK, context: input };
162+
163+
const maxInputChars = Math.max(1000, Number(cfg.recallCompressMaxInputChars || 18000));
164+
const maxBullets = Math.max(1, Number(cfg.recallCompressMaxBullets || 6));
165+
const key = recallDigestCacheKey({
166+
query,
167+
rendered: input,
168+
entries,
169+
maxInputChars,
170+
maxBullets,
171+
});
172+
const cached = await readCache(cachePath);
173+
if (cached?.key === key && typeof cached.digest === "string") {
174+
return { status: COMPRESS_OK, context: cached.digest };
175+
}
176+
177+
const prompt = buildRecallCompressionPrompt({
178+
query,
179+
rendered: input.slice(0, maxInputChars),
180+
maxBullets,
181+
});
182+
const raw = await runCompressor(prompt);
183+
const normalized = normalizeCompressedContext(raw, 4000, maxBullets);
184+
if (normalized === null) return { status: COMPRESS_FAILED, context: "" };
185+
if (!normalized) return { status: COMPRESS_EMPTY, context: "" };
186+
187+
const validUris = entries.map((entry) => entry?.uri).filter(Boolean);
188+
const digest = repairDigestUris(normalized, validUris.length
189+
? validUris
190+
: (input.match(/viking:\/\/[^\s<>"']+/g) || []));
191+
if (!digest) return { status: COMPRESS_FAILED, context: "" };
192+
193+
await writeCache(cachePath, { key, digest, updatedAt: now || 0 });
194+
return { status: COMPRESS_OK, context: digest };
195+
}

0 commit comments

Comments
 (0)