opencode-clm 0.1.0
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/CHANGELOG.md +26 -0
- package/LICENSE +35 -0
- package/NOTICE +41 -0
- package/README.md +294 -0
- package/index.ts +332 -0
- package/package.json +28 -0
- package/skills/clm-context/SKILL.md +166 -0
- package/src/budget.ts +304 -0
- package/src/clm.ts +725 -0
- package/src/compact.ts +73 -0
- package/src/context-document.ts +659 -0
- package/src/continuity.ts +722 -0
- package/src/mirror-guard.ts +95 -0
- package/src/mirror-store.ts +154 -0
- package/src/observation.ts +155 -0
- package/src/opencode.ts +339 -0
- package/src/overflow.ts +212 -0
- package/src/policy.ts +60 -0
- package/src/presentation.ts +127 -0
- package/src/projection.ts +146 -0
- package/src/settings.ts +189 -0
- package/src/state.ts +241 -0
- package/src/steering.ts +54 -0
- package/src/types.ts +102 -0
- package/steering/house-brief.md +43 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Adapted from pi-clm src/mirror-guard.ts (MIT, Copyright 2026 Emanuel Casco).
|
|
2
|
+
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a tool call does to the mirror file.
|
|
7
|
+
*
|
|
8
|
+
* - "write": the call mutates the mirror (edit/write/apply_patch tools, or a bash command
|
|
9
|
+
* with an explicit write indicator).
|
|
10
|
+
* - "read": the read tool targets the mirror, or a bash command references it without a
|
|
11
|
+
* recognized write indicator (header discovery, grep, head). Allowed and never counted,
|
|
12
|
+
* so inspection cannot consume the write allowance.
|
|
13
|
+
* - "none": the call does not reference the mirror.
|
|
14
|
+
*
|
|
15
|
+
* Bash classification is a heuristic on command text; validation before the next request
|
|
16
|
+
* remains the correctness boundary regardless of how a mutation was performed.
|
|
17
|
+
*/
|
|
18
|
+
export type MirrorToolIntent = "write" | "read" | "none";
|
|
19
|
+
|
|
20
|
+
const WRITE_INDICATOR = new RegExp(
|
|
21
|
+
[
|
|
22
|
+
String.raw`>>?`,
|
|
23
|
+
String.raw`\btee\b`,
|
|
24
|
+
String.raw`\bsed\s+(?:-\S+\s+)*-i\b`,
|
|
25
|
+
String.raw`\bperl\s+(?:-\S+\s+)*-i\b`,
|
|
26
|
+
String.raw`\btruncate\b`,
|
|
27
|
+
String.raw`\brm\b`,
|
|
28
|
+
String.raw`\bmv\b`,
|
|
29
|
+
String.raw`\bcp\b`,
|
|
30
|
+
String.raw`\bdd\b`,
|
|
31
|
+
String.raw`\binstall\b`,
|
|
32
|
+
String.raw`\bln\b`,
|
|
33
|
+
String.raw`\brsync\b`,
|
|
34
|
+
String.raw`\bex\b`,
|
|
35
|
+
String.raw`write_text\s*\(`,
|
|
36
|
+
String.raw`\.write\s*\(`,
|
|
37
|
+
String.raw`\bwritelines\s*\(`,
|
|
38
|
+
String.raw`writeFile(?:Sync)?\s*\(`,
|
|
39
|
+
String.raw`\bopen\s*\([^()]*["'][wax+]`,
|
|
40
|
+
String.raw`\.open\(\s*["'][wax]`,
|
|
41
|
+
String.raw`\bmode\s*=\s*["'][wax]`,
|
|
42
|
+
].join("|"),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/** Redirections that never write the mirror: fd duplication (`2>&1`, `>&2`) and `/dev/null`. */
|
|
46
|
+
const HARMLESS_REDIRECT = /\d*>&\d+|[12&]?>>?\s*\/dev\/null/g;
|
|
47
|
+
|
|
48
|
+
/** File headers of OpenCode's `apply_patch` format (`*** Update File: <path>` and kin). */
|
|
49
|
+
const PATCH_PATH = /^\s*\*\*\* (?:Add File|Update File|Delete File|Move to):\s*(.+?)\s*$/gm;
|
|
50
|
+
|
|
51
|
+
function escapeRegExp(text: string): string {
|
|
52
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Does the command name the mirror file, its directory, or the mirror's basename? */
|
|
56
|
+
function referencesMirror(command: string, mirrorPath: string): boolean {
|
|
57
|
+
if (command.includes(mirrorPath) || command.includes("LIVE_CONTEXT.md")) return true;
|
|
58
|
+
const directory = new RegExp(`${escapeRegExp(dirname(mirrorPath))}(?=$|[/\\s"'\`;|&)])`);
|
|
59
|
+
return directory.test(command);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function pathArg(input: Record<string, unknown>): string {
|
|
63
|
+
const given = typeof input.filePath === "string" ? input.filePath : typeof input.path === "string" ? input.path : "";
|
|
64
|
+
return given.replace(/^@/, "");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function patchPaths(patchText: string): string[] {
|
|
68
|
+
return [...patchText.matchAll(PATCH_PATH)].map((match) => match[1]!);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function classifyMirrorToolCall(
|
|
72
|
+
toolName: string,
|
|
73
|
+
input: Record<string, unknown>,
|
|
74
|
+
cwd: string,
|
|
75
|
+
mirrorPath: string,
|
|
76
|
+
): MirrorToolIntent {
|
|
77
|
+
const isMirror = (path: string) => path !== "" && resolve(cwd, path) === mirrorPath;
|
|
78
|
+
switch (toolName) {
|
|
79
|
+
case "edit":
|
|
80
|
+
case "write":
|
|
81
|
+
return isMirror(pathArg(input)) ? "write" : "none";
|
|
82
|
+
case "apply_patch":
|
|
83
|
+
return typeof input.patchText === "string" && patchPaths(input.patchText).some(isMirror) ? "write" : "none";
|
|
84
|
+
case "read":
|
|
85
|
+
return isMirror(pathArg(input)) ? "read" : "none";
|
|
86
|
+
case "bash": {
|
|
87
|
+
if (typeof input.command !== "string") return "none";
|
|
88
|
+
const command = input.command;
|
|
89
|
+
if (!referencesMirror(command, mirrorPath)) return "none";
|
|
90
|
+
return WRITE_INDICATOR.test(command.replace(HARMLESS_REDIRECT, " ")) ? "write" : "read";
|
|
91
|
+
}
|
|
92
|
+
default:
|
|
93
|
+
return "none";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Adapted from pi-clm src/mirror-store.ts (MIT, Copyright 2026 Emanuel Casco).
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { chmod, lstat, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
|
|
9
|
+
const STALE_MIRROR_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
/** `${XDG_STATE_HOME || ~/.local/state}/opencode-clm/mirrors`, resolved at call time. */
|
|
12
|
+
export function defaultMirrorParent(): string {
|
|
13
|
+
const stateHome = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
|
|
14
|
+
return join(stateHome, "opencode-clm", "mirrors");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function safeSessionId(sessionId: string): string {
|
|
18
|
+
const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 64);
|
|
19
|
+
return safe || "session";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function exists(path: string): Promise<boolean> {
|
|
23
|
+
return lstat(path).then(
|
|
24
|
+
() => true,
|
|
25
|
+
() => false,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Newest mtime among a directory and the entries directly inside it. */
|
|
30
|
+
async function newestMtime(directory: string): Promise<number> {
|
|
31
|
+
let newest = (await stat(directory)).mtimeMs;
|
|
32
|
+
for (const name of await readdir(directory)) {
|
|
33
|
+
const info = await lstat(join(directory, name)).catch(() => undefined);
|
|
34
|
+
if (info && info.mtimeMs > newest) newest = info.mtimeMs;
|
|
35
|
+
}
|
|
36
|
+
return newest;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Best-effort removal of mirror directories left behind by sessions that never reached
|
|
41
|
+
* shutdown. Live sessions rewrite their mirror every turn, so a directory whose newest
|
|
42
|
+
* mtime (the directory or any file directly inside it) is older than the threshold has
|
|
43
|
+
* no owner. Failures are ignored: another process may remove or own an entry concurrently.
|
|
44
|
+
*/
|
|
45
|
+
async function sweepStaleMirrors(parentDirectory: string, keepDirectory: string, maxAgeMs: number): Promise<void> {
|
|
46
|
+
let names: string[];
|
|
47
|
+
try {
|
|
48
|
+
names = await readdir(parentDirectory);
|
|
49
|
+
} catch {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
53
|
+
for (const name of names) {
|
|
54
|
+
if (!name.startsWith("clm-")) continue;
|
|
55
|
+
const directory = join(parentDirectory, name);
|
|
56
|
+
if (directory === keepDirectory) continue;
|
|
57
|
+
try {
|
|
58
|
+
const info = await lstat(directory);
|
|
59
|
+
if (!info.isDirectory() || (await newestMtime(directory)) >= cutoff) continue;
|
|
60
|
+
await rm(directory, { recursive: true, force: true });
|
|
61
|
+
} catch {
|
|
62
|
+
// Ignored: sweeping is opportunistic hygiene only.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Refuse a session directory that is a symlink, not a directory, or owned by another user. */
|
|
68
|
+
async function assertPrivateDirectory(directory: string): Promise<void> {
|
|
69
|
+
const info = await lstat(directory);
|
|
70
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
71
|
+
throw new Error(`Refusing mirror directory ${directory}: not a real directory.`);
|
|
72
|
+
}
|
|
73
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
74
|
+
if (uid !== undefined && info.uid !== uid) {
|
|
75
|
+
throw new Error(`Refusing mirror directory ${directory}: owned by uid ${info.uid}, not ${uid}.`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class MirrorStore {
|
|
80
|
+
readonly directory: string;
|
|
81
|
+
readonly filePath: string;
|
|
82
|
+
private disposed = false;
|
|
83
|
+
|
|
84
|
+
private constructor(directory: string) {
|
|
85
|
+
this.directory = directory;
|
|
86
|
+
this.filePath = join(directory, "LIVE_CONTEXT.md");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* One directory per session: `<parent>/clm-<session>/`. The name is deterministic so a
|
|
91
|
+
* resumed session finds its saved state; the parent gets a `.gitignore` of `*` if absent.
|
|
92
|
+
* The parent is chmod 0700 when the store creates it or when it is the default parent.
|
|
93
|
+
*/
|
|
94
|
+
static async create(
|
|
95
|
+
sessionId: string,
|
|
96
|
+
parentDirectory?: string,
|
|
97
|
+
staleAgeMs = STALE_MIRROR_AGE_MS,
|
|
98
|
+
): Promise<MirrorStore> {
|
|
99
|
+
const parent = parentDirectory ?? defaultMirrorParent();
|
|
100
|
+
const ownsParent = parentDirectory === undefined || !(await exists(parent));
|
|
101
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
102
|
+
if (ownsParent) await chmod(parent, 0o700);
|
|
103
|
+
await writeFile(join(parent, ".gitignore"), "*\n", { flag: "wx" }).catch(() => undefined);
|
|
104
|
+
const directory = join(parent, `clm-${safeSessionId(sessionId)}`);
|
|
105
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
106
|
+
await assertPrivateDirectory(directory);
|
|
107
|
+
await chmod(directory, 0o700);
|
|
108
|
+
await sweepStaleMirrors(parent, directory, staleAgeMs).catch(() => undefined);
|
|
109
|
+
return new MirrorStore(directory);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Synchronous read for hooks that inspect the file right after a tool ran. */
|
|
113
|
+
readSync(): string | undefined {
|
|
114
|
+
if (this.disposed) return undefined;
|
|
115
|
+
try {
|
|
116
|
+
return readFileSync(this.filePath, "utf8");
|
|
117
|
+
} catch {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async write(content: string): Promise<void> {
|
|
123
|
+
if (this.disposed) throw new Error("Mirror store has been disposed.");
|
|
124
|
+
const temporaryPath = join(this.directory, `.${basename(this.filePath)}.${randomUUID()}.tmp`);
|
|
125
|
+
try {
|
|
126
|
+
await writeFile(temporaryPath, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
127
|
+
await rename(temporaryPath, this.filePath);
|
|
128
|
+
await chmod(this.filePath, 0o600);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async read(): Promise<string | undefined> {
|
|
136
|
+
if (this.disposed) return undefined;
|
|
137
|
+
try {
|
|
138
|
+
return await readFile(this.filePath, "utf8");
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Deletes the session directory, including the saved state a resumed session would
|
|
147
|
+
* read. Call only when the session itself is deleted, never on plain shutdown.
|
|
148
|
+
*/
|
|
149
|
+
async cleanup(): Promise<void> {
|
|
150
|
+
if (this.disposed) return;
|
|
151
|
+
this.disposed = true;
|
|
152
|
+
await rm(this.directory, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observation cap: bound the size of each tool result in the *effective* context.
|
|
3
|
+
*
|
|
4
|
+
* Adapted from pi-clm src/observation.ts (MIT, Copyright 2026 Emanuel Casco).
|
|
5
|
+
*
|
|
6
|
+
* One assistant turn can fan out into many parallel tool calls whose results land in the
|
|
7
|
+
* context together, before any request-boundary reminder can be acted on. Capping each
|
|
8
|
+
* result in the effective context (not in OpenCode's stored parts) keeps such a turn from
|
|
9
|
+
* jumping straight past the budget. The full output stays in the session; the marker tells
|
|
10
|
+
* the model how much was cut and that it can re-read with offset/limit when it needs more.
|
|
11
|
+
*
|
|
12
|
+
* Input is the flattened form (opencode.ts `flatten`): one `toolResult` message per tool
|
|
13
|
+
* part, `content: [{ type: "text", text }]`. Capped messages carry `capped: true` so the
|
|
14
|
+
* unflatten step writes the capped text back into the tool part.
|
|
15
|
+
*
|
|
16
|
+
* Capped messages are cached per source object so repeated transform calls produce the
|
|
17
|
+
* same object, which keeps identity-based comparisons and rendering stable.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { LiveContextMessage } from "./types.ts";
|
|
21
|
+
|
|
22
|
+
export interface ObservationCapPolicy {
|
|
23
|
+
/** Maximum characters of text kept per tool result. `undefined` disables the cap. */
|
|
24
|
+
maxCharacters?: number;
|
|
25
|
+
/** Fraction of the kept text taken from the start; the rest comes from the end. */
|
|
26
|
+
headFraction: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_OBSERVATION_CAP: ObservationCapPolicy = { headFraction: 0.8 };
|
|
30
|
+
|
|
31
|
+
/** Smallest cap accepted: below this the marker alone would crowd out the output. */
|
|
32
|
+
export const MIN_OBSERVATION_CAP = 200;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse a `CLM_OBSERVATION_CAP` value: `10000` or `10000:0.5` (characters[:head fraction]);
|
|
36
|
+
* the CLM paper used 5k head + 5k tail, i.e. `10000:0.5`. Empty, `off` and `0` disable the
|
|
37
|
+
* cap (returns `{}`). `name` labels errors.
|
|
38
|
+
*/
|
|
39
|
+
export function parseObservationCap(raw: string | undefined, name = "CLM_OBSERVATION_CAP"): Partial<ObservationCapPolicy> {
|
|
40
|
+
const value = raw?.trim();
|
|
41
|
+
if (!value || value === "off" || value === "0") return {};
|
|
42
|
+
const [charsPart, headPart, ...rest] = value.split(":");
|
|
43
|
+
const chars = Number(charsPart);
|
|
44
|
+
if (rest.length > 0 || !Number.isFinite(chars) || chars <= 0) {
|
|
45
|
+
throw new Error(`${name} must be a positive number of characters[:head fraction] or "off", got ${value}`);
|
|
46
|
+
}
|
|
47
|
+
const overrides: Partial<ObservationCapPolicy> = { maxCharacters: Math.floor(chars) };
|
|
48
|
+
if (headPart !== undefined && headPart !== "") {
|
|
49
|
+
const head = Number(headPart);
|
|
50
|
+
if (!(head > 0 && head <= 1)) throw new Error(`${name} head fraction must be in (0, 1], got ${headPart}`);
|
|
51
|
+
overrides.headFraction = head;
|
|
52
|
+
}
|
|
53
|
+
return overrides;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function resolveObservationCap(overrides: Partial<ObservationCapPolicy> | undefined): ObservationCapPolicy {
|
|
57
|
+
const merged = { ...DEFAULT_OBSERVATION_CAP, ...(overrides ?? {}) };
|
|
58
|
+
if (merged.maxCharacters !== undefined && (!Number.isFinite(merged.maxCharacters) || merged.maxCharacters < MIN_OBSERVATION_CAP)) {
|
|
59
|
+
throw new Error(`observation cap must be at least ${MIN_OBSERVATION_CAP} characters, got ${String(merged.maxCharacters)}`);
|
|
60
|
+
}
|
|
61
|
+
if (!(merged.headFraction > 0 && merged.headFraction <= 1)) {
|
|
62
|
+
throw new Error(`headFraction must be in (0, 1], got ${String(merged.headFraction)}`);
|
|
63
|
+
}
|
|
64
|
+
return merged;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface TextPart {
|
|
68
|
+
type: "text";
|
|
69
|
+
text: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isTextPart(part: unknown): part is TextPart {
|
|
73
|
+
return Boolean(part) && typeof part === "object" && (part as TextPart).type === "text" && typeof (part as TextPart).text === "string";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isHighSurrogate(code: number): boolean {
|
|
77
|
+
return code >= 0xd800 && code <= 0xdbff;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isLowSurrogate(code: number): boolean {
|
|
81
|
+
return code >= 0xdc00 && code <= 0xdfff;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function truncationMarker(shown: number, total: number): string {
|
|
85
|
+
return (
|
|
86
|
+
`\n\n[clm observation cap: ${shown.toLocaleString("en-US")} of ${total.toLocaleString("en-US")} characters shown. ` +
|
|
87
|
+
"The complete output remains in the session history; re-read the source with offset/limit if you need the rest.]"
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Capped message -> the uncapped source it was cut from. */
|
|
92
|
+
const uncapped = new WeakMap<LiveContextMessage, LiveContextMessage>();
|
|
93
|
+
|
|
94
|
+
/** The uncapped source of a capped tool result, or the message itself. */
|
|
95
|
+
export function uncappedSource(message: LiveContextMessage): LiveContextMessage {
|
|
96
|
+
return uncapped.get(message) ?? message;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Cap one tool result. Returns the same object when nothing changes. */
|
|
100
|
+
export function capToolResult(message: LiveContextMessage, policy: ObservationCapPolicy): LiveContextMessage {
|
|
101
|
+
if (policy.maxCharacters === undefined || message.role !== "toolResult") return message;
|
|
102
|
+
// Idempotent: never cap an already-capped result (also after a JSON round trip).
|
|
103
|
+
if (message.capped === true || uncapped.has(message)) return message;
|
|
104
|
+
const content = message.content;
|
|
105
|
+
const parts: unknown[] = Array.isArray(content) ? content : typeof content === "string" ? [{ type: "text", text: content }] : [];
|
|
106
|
+
const text = parts.filter(isTextPart).map((part) => part.text).join("");
|
|
107
|
+
const total = text.length;
|
|
108
|
+
if (total <= policy.maxCharacters) return message;
|
|
109
|
+
|
|
110
|
+
const headBudget = Math.floor(policy.maxCharacters * policy.headFraction);
|
|
111
|
+
const tailBudget = policy.maxCharacters - headBudget;
|
|
112
|
+
// Cut once across the concatenated text, then rebuild a single text part followed by
|
|
113
|
+
// any non-text parts (for example images), in their original order.
|
|
114
|
+
// Never split a UTF-16 surrogate pair at either cut.
|
|
115
|
+
let headEnd = headBudget;
|
|
116
|
+
if (headEnd > 0 && isHighSurrogate(text.charCodeAt(headEnd - 1))) headEnd -= 1;
|
|
117
|
+
let tailStart = total - tailBudget;
|
|
118
|
+
if (tailBudget > 0 && tailStart < total && isLowSurrogate(text.charCodeAt(tailStart))) tailStart += 1;
|
|
119
|
+
const head = text.slice(0, headEnd);
|
|
120
|
+
const tail = tailBudget > 0 ? text.slice(tailStart) : "";
|
|
121
|
+
const shown = head.length + tail.length;
|
|
122
|
+
const cut = tailBudget > 0 ? `${head}\n…[${(total - shown).toLocaleString("en-US")} characters omitted]…\n${tail}` : head;
|
|
123
|
+
const capped: LiveContextMessage = {
|
|
124
|
+
...message,
|
|
125
|
+
content: [{ type: "text", text: `${cut}${truncationMarker(shown, total)}` }, ...parts.filter((part) => !isTextPart(part))],
|
|
126
|
+
capped: true,
|
|
127
|
+
};
|
|
128
|
+
uncapped.set(capped, message);
|
|
129
|
+
return capped;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const cappedCache = new WeakMap<LiveContextMessage, Map<string, LiveContextMessage>>();
|
|
133
|
+
|
|
134
|
+
/** Cap every tool result in `messages`; returns the same array when nothing changes. */
|
|
135
|
+
export function capObservations(messages: LiveContextMessage[], policy: ObservationCapPolicy): LiveContextMessage[] {
|
|
136
|
+
if (policy.maxCharacters === undefined) return messages;
|
|
137
|
+
const key = `${policy.maxCharacters}:${policy.headFraction}`;
|
|
138
|
+
let changed = false;
|
|
139
|
+
const output = messages.map((message) => {
|
|
140
|
+
if (message.role !== "toolResult") return message;
|
|
141
|
+
let perPolicy = cappedCache.get(message);
|
|
142
|
+
if (!perPolicy) {
|
|
143
|
+
perPolicy = new Map();
|
|
144
|
+
cappedCache.set(message, perPolicy);
|
|
145
|
+
}
|
|
146
|
+
let capped = perPolicy.get(key);
|
|
147
|
+
if (!capped) {
|
|
148
|
+
capped = capToolResult(message, policy);
|
|
149
|
+
perPolicy.set(key, capped);
|
|
150
|
+
}
|
|
151
|
+
if (capped !== message) changed = true;
|
|
152
|
+
return capped;
|
|
153
|
+
});
|
|
154
|
+
return changed ? output : messages;
|
|
155
|
+
}
|