pi-cliffcompaction 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 +13 -0
- package/LICENSE +21 -0
- package/README.md +291 -0
- package/config.example.json +12 -0
- package/index.ts +185 -0
- package/lib/cliff.ts +134 -0
- package/lib/config.ts +225 -0
- package/lib/decode.ts +103 -0
- package/lib/dialects/anthropic.ts +364 -0
- package/lib/dialects/base.ts +79 -0
- package/lib/dialects/index.ts +48 -0
- package/lib/dialects/openai-chat.ts +225 -0
- package/lib/dialects/openai-responses.ts +331 -0
- package/lib/dialects/pi.ts +249 -0
- package/lib/engine.ts +558 -0
- package/lib/hashing.ts +31 -0
- package/lib/images.ts +268 -0
- package/lib/index.ts +62 -0
- package/lib/json.ts +256 -0
- package/lib/pi-hook.ts +253 -0
- package/lib/store.ts +94 -0
- package/package.json +76 -0
package/lib/config.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compaction knobs. CLIFF_* env vars and an optional JSON file; the Pi
|
|
3
|
+
* extension also reads cliffcompaction.json from the host config dir.
|
|
4
|
+
*
|
|
5
|
+
* Names match the reference implementation (arxiv:2609.26779 / GitHub
|
|
6
|
+
* nguyenvuthientrang/cliffcompaction). Paper Algorithm 1 uses thought
|
|
7
|
+
* truncation of 300 and keep-recent K turn-pairs; the open-source defaults
|
|
8
|
+
* keep assistant text unlimited and keep_recent=3 assistant-step turns.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
asObject,
|
|
16
|
+
isBoolean,
|
|
17
|
+
isNumber,
|
|
18
|
+
type JsonObject,
|
|
19
|
+
type JsonValue,
|
|
20
|
+
} from "./decode.ts";
|
|
21
|
+
|
|
22
|
+
export type Config = {
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
thresholdTokens: number;
|
|
25
|
+
keepRecent: number;
|
|
26
|
+
thoughtMaxChars: number;
|
|
27
|
+
cmdMaxChars: number;
|
|
28
|
+
resultMaxChars: number;
|
|
29
|
+
humanMaxChars: number;
|
|
30
|
+
keepThinking: boolean;
|
|
31
|
+
thinkingMaxChars: number;
|
|
32
|
+
shadow: boolean;
|
|
33
|
+
strict: boolean;
|
|
34
|
+
storeMaxEntries: number;
|
|
35
|
+
storeMaxBytes: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_CONFIG: Config = {
|
|
39
|
+
enabled: true,
|
|
40
|
+
thresholdTokens: 200_000,
|
|
41
|
+
keepRecent: 3,
|
|
42
|
+
thoughtMaxChars: 0,
|
|
43
|
+
cmdMaxChars: 150,
|
|
44
|
+
resultMaxChars: 500,
|
|
45
|
+
humanMaxChars: 20_000,
|
|
46
|
+
keepThinking: true,
|
|
47
|
+
thinkingMaxChars: 0,
|
|
48
|
+
shadow: false,
|
|
49
|
+
strict: false,
|
|
50
|
+
storeMaxEntries: 4096,
|
|
51
|
+
storeMaxBytes: 64 * 1024 * 1024,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type ConfigPatch = Partial<Config>;
|
|
55
|
+
|
|
56
|
+
function envRaw(name: string): string | undefined {
|
|
57
|
+
const value = process.env[name];
|
|
58
|
+
|
|
59
|
+
if (value === undefined) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function envInt(name: string, fallback: number): number {
|
|
67
|
+
const raw = envRaw(name);
|
|
68
|
+
|
|
69
|
+
if (raw === undefined) {
|
|
70
|
+
return fallback;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const n = Number.parseInt(raw, 10);
|
|
74
|
+
|
|
75
|
+
if (!Number.isFinite(n)) {
|
|
76
|
+
return fallback;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return n;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function envBool(name: string, fallback: boolean): boolean {
|
|
83
|
+
const raw = envRaw(name);
|
|
84
|
+
|
|
85
|
+
if (raw === undefined) {
|
|
86
|
+
return fallback;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const v = raw.trim().toLowerCase();
|
|
90
|
+
|
|
91
|
+
return v === "1" || v === "true" || v === "yes" || v === "on";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function makeConfig(patch: ConfigPatch = {}): Config {
|
|
95
|
+
return {
|
|
96
|
+
enabled: patch.enabled ?? DEFAULT_CONFIG.enabled,
|
|
97
|
+
thresholdTokens: patch.thresholdTokens ?? DEFAULT_CONFIG.thresholdTokens,
|
|
98
|
+
keepRecent: patch.keepRecent ?? DEFAULT_CONFIG.keepRecent,
|
|
99
|
+
thoughtMaxChars: patch.thoughtMaxChars ?? DEFAULT_CONFIG.thoughtMaxChars,
|
|
100
|
+
cmdMaxChars: patch.cmdMaxChars ?? DEFAULT_CONFIG.cmdMaxChars,
|
|
101
|
+
resultMaxChars: patch.resultMaxChars ?? DEFAULT_CONFIG.resultMaxChars,
|
|
102
|
+
humanMaxChars: patch.humanMaxChars ?? DEFAULT_CONFIG.humanMaxChars,
|
|
103
|
+
keepThinking: patch.keepThinking ?? DEFAULT_CONFIG.keepThinking,
|
|
104
|
+
thinkingMaxChars: patch.thinkingMaxChars ?? DEFAULT_CONFIG.thinkingMaxChars,
|
|
105
|
+
shadow: patch.shadow ?? DEFAULT_CONFIG.shadow,
|
|
106
|
+
strict: patch.strict ?? DEFAULT_CONFIG.strict,
|
|
107
|
+
storeMaxEntries: patch.storeMaxEntries ?? DEFAULT_CONFIG.storeMaxEntries,
|
|
108
|
+
storeMaxBytes: patch.storeMaxBytes ?? DEFAULT_CONFIG.storeMaxBytes,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function replaceConfig(cfg: Config, patch: ConfigPatch): Config {
|
|
113
|
+
return makeConfig({ ...cfg, ...patch });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function intField(obj: JsonObject, snake: string, camel: string, fallback: number): number {
|
|
117
|
+
const raw = obj[snake] ?? obj[camel];
|
|
118
|
+
|
|
119
|
+
if (isNumber(raw) && Number.isInteger(raw)) {
|
|
120
|
+
return raw;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return fallback;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function boolField(obj: JsonObject, snake: string, camel: string, fallback: boolean): boolean {
|
|
127
|
+
const raw = obj[snake] ?? obj[camel];
|
|
128
|
+
|
|
129
|
+
if (isBoolean(raw)) {
|
|
130
|
+
return raw;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return fallback;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Parse a JSON config object. Unknown keys ignored. */
|
|
137
|
+
export function parseConfig(raw: JsonValue, base: Config = DEFAULT_CONFIG): Config {
|
|
138
|
+
const obj = asObject(raw);
|
|
139
|
+
|
|
140
|
+
if (obj === null) {
|
|
141
|
+
return base;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return makeConfig({
|
|
145
|
+
enabled: boolField(obj, "enabled", "enabled", base.enabled),
|
|
146
|
+
thresholdTokens: intField(obj, "threshold_tokens", "thresholdTokens", base.thresholdTokens),
|
|
147
|
+
keepRecent: intField(obj, "keep_recent", "keepRecent", base.keepRecent),
|
|
148
|
+
thoughtMaxChars: intField(obj, "thought_max_chars", "thoughtMaxChars", base.thoughtMaxChars),
|
|
149
|
+
cmdMaxChars: intField(obj, "cmd_max_chars", "cmdMaxChars", base.cmdMaxChars),
|
|
150
|
+
resultMaxChars: intField(obj, "result_max_chars", "resultMaxChars", base.resultMaxChars),
|
|
151
|
+
humanMaxChars: intField(obj, "human_max_chars", "humanMaxChars", base.humanMaxChars),
|
|
152
|
+
keepThinking: boolField(obj, "keep_thinking", "keepThinking", base.keepThinking),
|
|
153
|
+
thinkingMaxChars: intField(obj, "thinking_max_chars", "thinkingMaxChars", base.thinkingMaxChars),
|
|
154
|
+
shadow: boolField(obj, "shadow", "shadow", base.shadow),
|
|
155
|
+
strict: boolField(obj, "strict", "strict", base.strict),
|
|
156
|
+
storeMaxEntries: intField(obj, "store_max_entries", "storeMaxEntries", base.storeMaxEntries),
|
|
157
|
+
storeMaxBytes: intField(obj, "store_max_bytes", "storeMaxBytes", base.storeMaxBytes),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function configFromEnv(base: Config = DEFAULT_CONFIG): Config {
|
|
162
|
+
return makeConfig({
|
|
163
|
+
enabled: envBool("CLIFF_ENABLED", base.enabled),
|
|
164
|
+
thresholdTokens: envInt("CLIFF_THRESHOLD_TOKENS", base.thresholdTokens),
|
|
165
|
+
keepRecent: envInt("CLIFF_KEEP_RECENT", base.keepRecent),
|
|
166
|
+
thoughtMaxChars: envInt("CLIFF_THOUGHT_MAX_CHARS", base.thoughtMaxChars),
|
|
167
|
+
cmdMaxChars: envInt("CLIFF_CMD_MAX_CHARS", base.cmdMaxChars),
|
|
168
|
+
resultMaxChars: envInt("CLIFF_RESULT_MAX_CHARS", base.resultMaxChars),
|
|
169
|
+
humanMaxChars: envInt("CLIFF_HUMAN_MAX_CHARS", base.humanMaxChars),
|
|
170
|
+
keepThinking: envBool("CLIFF_KEEP_THINKING", base.keepThinking),
|
|
171
|
+
thinkingMaxChars: envInt("CLIFF_THINKING_MAX_CHARS", base.thinkingMaxChars),
|
|
172
|
+
shadow: envBool("CLIFF_SHADOW", base.shadow),
|
|
173
|
+
strict: envBool("CLIFF_STRICT", base.strict),
|
|
174
|
+
storeMaxEntries: envInt("CLIFF_STORE_MAX_ENTRIES", base.storeMaxEntries),
|
|
175
|
+
storeMaxBytes: envInt("CLIFF_STORE_MAX_BYTES", base.storeMaxBytes),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function defaultConfigPath(): string {
|
|
180
|
+
const explicit = envRaw("PI_CLIFF_CONFIG") ?? envRaw("OMP_CLIFF_CONFIG");
|
|
181
|
+
|
|
182
|
+
if (explicit) {
|
|
183
|
+
return explicit;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const home = homedir();
|
|
187
|
+
const piDir = envRaw("PI_CONFIG_DIR") ?? join(home, ".pi", "agent");
|
|
188
|
+
const ompDir = envRaw("OMP_CONFIG_DIR") ?? join(home, ".omp", "agent");
|
|
189
|
+
const piPath = join(piDir, "cliffcompaction.json");
|
|
190
|
+
const ompPath = join(ompDir, "cliffcompaction.json");
|
|
191
|
+
|
|
192
|
+
if (existsSync(piPath)) {
|
|
193
|
+
return piPath;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (existsSync(ompPath)) {
|
|
197
|
+
return ompPath;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return piPath;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function loadConfigFile(path: string): Config | null {
|
|
204
|
+
if (!existsSync(path)) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const text = readFileSync(path, "utf8");
|
|
210
|
+
const parsed: JsonValue = JSON.parse(text);
|
|
211
|
+
|
|
212
|
+
return parseConfig(parsed);
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** File (if present) then CLIFF_* env overrides. */
|
|
219
|
+
export function loadConfig(): Config {
|
|
220
|
+
const fromFile = loadConfigFile(defaultConfigPath());
|
|
221
|
+
const base = fromFile ?? DEFAULT_CONFIG;
|
|
222
|
+
|
|
223
|
+
return configFromEnv(base);
|
|
224
|
+
}
|
|
225
|
+
|
package/lib/decode.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boundary decoders (anti-slop). JSON request bodies are untyped at the
|
|
3
|
+
* I/O edge; everything inside the engine consumes these predicates.
|
|
4
|
+
*
|
|
5
|
+
* Hot-path checks use constructor identity: JSON.parse and object
|
|
6
|
+
* literals never box primitives. Null-prototype objects still count as
|
|
7
|
+
* records (Object.create(null).constructor is undefined).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type JsonPrimitive = string | number | boolean | null;
|
|
11
|
+
|
|
12
|
+
export type JsonArray = JsonValue[];
|
|
13
|
+
|
|
14
|
+
export type JsonObject = { [key: string]: JsonValue };
|
|
15
|
+
|
|
16
|
+
export type JsonValue = JsonPrimitive | JsonArray | JsonObject;
|
|
17
|
+
|
|
18
|
+
export function isString(value: JsonValue | undefined | null): value is string {
|
|
19
|
+
return value !== null && value !== undefined && value.constructor === String;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isNumber(value: JsonValue | undefined | null): value is number {
|
|
23
|
+
return (
|
|
24
|
+
value !== null &&
|
|
25
|
+
value !== undefined &&
|
|
26
|
+
value.constructor === Number &&
|
|
27
|
+
Number.isFinite(value)
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isBoolean(value: JsonValue | undefined | null): value is boolean {
|
|
32
|
+
return value !== null && value !== undefined && value.constructor === Boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isNull(value: JsonValue | undefined | null): value is null {
|
|
36
|
+
return value === null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isArray(value: JsonValue | undefined | null): value is JsonArray {
|
|
40
|
+
return Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isObject(value: JsonValue | undefined | null): value is JsonObject {
|
|
44
|
+
if (value === null || value === undefined || Array.isArray(value)) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ctor = value.constructor;
|
|
49
|
+
|
|
50
|
+
return ctor === Object || ctor === undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isRecord(value: JsonValue | undefined | null): value is JsonObject {
|
|
54
|
+
return value !== null && isObject(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function asString(value: JsonValue | undefined | null, fallback = ""): string {
|
|
58
|
+
if (isString(value)) {
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return fallback;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function asBoolean(value: JsonValue | undefined | null, fallback = false): boolean {
|
|
66
|
+
if (isBoolean(value)) {
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return fallback;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function asFiniteNumber(value: JsonValue | undefined | null, fallback: number): number {
|
|
74
|
+
if (isNumber(value)) {
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return fallback;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function asObject(value: JsonValue | undefined | null): JsonObject | null {
|
|
82
|
+
if (isRecord(value)) {
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function asArray(value: JsonValue | undefined | null): JsonArray {
|
|
90
|
+
if (isArray(value)) {
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function field(obj: JsonObject, key: string): JsonValue | undefined {
|
|
98
|
+
if (!Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return obj[key];
|
|
103
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic Messages API dialect.
|
|
3
|
+
*
|
|
4
|
+
* - `system` is a top-level request field, not a message.
|
|
5
|
+
* - Tool results are user-role messages whose content blocks are typed
|
|
6
|
+
* tool_result; human prompts are user-role with text blocks.
|
|
7
|
+
* - cache_control is excluded from the canonical digest.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Config } from "../config.ts";
|
|
11
|
+
import {
|
|
12
|
+
asArray,
|
|
13
|
+
asBoolean,
|
|
14
|
+
asObject,
|
|
15
|
+
asString,
|
|
16
|
+
isArray,
|
|
17
|
+
isRecord,
|
|
18
|
+
isString,
|
|
19
|
+
type JsonArray,
|
|
20
|
+
type JsonObject,
|
|
21
|
+
type JsonValue,
|
|
22
|
+
} from "../decode.ts";
|
|
23
|
+
import { digestBytes, digestObj } from "../hashing.ts";
|
|
24
|
+
import { canonicalJson } from "../json.ts";
|
|
25
|
+
import {
|
|
26
|
+
makeDialect,
|
|
27
|
+
startsWithSummaryHeader,
|
|
28
|
+
stripTaskNotifications,
|
|
29
|
+
truncate,
|
|
30
|
+
type Dialect,
|
|
31
|
+
} from "./base.ts";
|
|
32
|
+
|
|
33
|
+
function resultText(content: JsonValue | undefined): string {
|
|
34
|
+
if (isString(content)) {
|
|
35
|
+
return content;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (isArray(content)) {
|
|
39
|
+
const parts: string[] = [];
|
|
40
|
+
|
|
41
|
+
for (const p of content) {
|
|
42
|
+
if (isRecord(p) && asString(p.type) === "text") {
|
|
43
|
+
parts.push(asString(p.text));
|
|
44
|
+
} else if (isString(p)) {
|
|
45
|
+
parts.push(p);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return parts.join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (content === undefined || content === null) {
|
|
53
|
+
return "";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return String(content);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function canonBlock(block: JsonObject): JsonArray {
|
|
60
|
+
const t = asString(block.type);
|
|
61
|
+
|
|
62
|
+
if (t === "text") {
|
|
63
|
+
return ["text", asString(block.text)];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (t === "tool_use") {
|
|
67
|
+
return ["tool_use", asString(block.id), asString(block.name), canonicalJson(block.input ?? {})];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (t === "tool_result") {
|
|
71
|
+
return [
|
|
72
|
+
"tool_result",
|
|
73
|
+
asString(block.tool_use_id),
|
|
74
|
+
resultText(block.content),
|
|
75
|
+
asBoolean(block.is_error, false),
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (t === "thinking") {
|
|
80
|
+
return ["thinking", asString(block.thinking)];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (t === "redacted_thinking") {
|
|
84
|
+
return ["redacted_thinking", asString(block.data)];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (t === "image" || t === "document") {
|
|
88
|
+
const src = asObject(block.source) ?? {};
|
|
89
|
+
const payload = asString(src.data) || asString(src.url);
|
|
90
|
+
const h = digestBytes(payload);
|
|
91
|
+
|
|
92
|
+
return [t, asString(src.type), h];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const reduced: JsonObject = {};
|
|
96
|
+
|
|
97
|
+
for (const key of Object.keys(block)) {
|
|
98
|
+
if (key !== "cache_control") {
|
|
99
|
+
reduced[key] = block[key];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return ["other", canonicalJson(reduced)];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function digestMessage(msg: JsonObject): string {
|
|
107
|
+
const content = msg.content;
|
|
108
|
+
let blocks: JsonArray = [];
|
|
109
|
+
|
|
110
|
+
if (isString(content)) {
|
|
111
|
+
blocks = [["text", content]];
|
|
112
|
+
} else if (isArray(content)) {
|
|
113
|
+
const out: JsonArray = [];
|
|
114
|
+
|
|
115
|
+
for (const b of content) {
|
|
116
|
+
if (isRecord(b)) {
|
|
117
|
+
out.push(canonBlock(b));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
blocks = out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return digestObj([asString(msg.role), blocks]);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function isAssistant(msg: JsonObject): boolean {
|
|
128
|
+
return asString(msg.role) === "assistant";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function contentStartsWithHeader(content: JsonValue | undefined): boolean {
|
|
132
|
+
if (isString(content)) {
|
|
133
|
+
return startsWithSummaryHeader(content);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (isArray(content)) {
|
|
137
|
+
for (const b of content) {
|
|
138
|
+
if (isRecord(b) && asString(b.type) === "text") {
|
|
139
|
+
return startsWithSummaryHeader(asString(b.text));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function isSummaryMessage(msg: JsonObject): boolean {
|
|
148
|
+
if (asString(msg.role) !== "user") {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return contentStartsWithHeader(msg.content);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function sessionKey(body: JsonObject): string | null {
|
|
156
|
+
const meta = asObject(body.metadata);
|
|
157
|
+
|
|
158
|
+
if (meta === null) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const raw = meta.user_id;
|
|
163
|
+
|
|
164
|
+
if (!isString(raw) || !raw.startsWith("{")) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const parsed = JSON.parse(raw);
|
|
170
|
+
const obj = asObject(parsed);
|
|
171
|
+
|
|
172
|
+
if (obj === null) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const sid = obj.session_id;
|
|
177
|
+
|
|
178
|
+
if (isString(sid) && sid) {
|
|
179
|
+
return sid;
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function summarizeAssistant(msg: JsonObject, cfg: Config): string[] {
|
|
189
|
+
const content = msg.content;
|
|
190
|
+
const texts: string[] = [];
|
|
191
|
+
const thinkings: string[] = [];
|
|
192
|
+
const sigs: string[] = [];
|
|
193
|
+
|
|
194
|
+
if (isString(content)) {
|
|
195
|
+
texts.push(content);
|
|
196
|
+
} else if (isArray(content)) {
|
|
197
|
+
for (const b of content) {
|
|
198
|
+
if (!isRecord(b)) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const t = asString(b.type);
|
|
203
|
+
|
|
204
|
+
if (t === "text") {
|
|
205
|
+
texts.push(asString(b.text));
|
|
206
|
+
} else if (t === "thinking") {
|
|
207
|
+
if (cfg.keepThinking) {
|
|
208
|
+
thinkings.push(asString(b.thinking));
|
|
209
|
+
}
|
|
210
|
+
} else if (t === "tool_use") {
|
|
211
|
+
const args = canonicalJson(b.input ?? {});
|
|
212
|
+
sigs.push("[" + asString(b.name, "?") + "] " + truncate(args, cfg.cmdMaxChars));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const lines: string[] = [];
|
|
218
|
+
const thinkingParts: string[] = [];
|
|
219
|
+
|
|
220
|
+
for (const x of thinkings) {
|
|
221
|
+
if (x.trim()) {
|
|
222
|
+
thinkingParts.push(x);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const thinking = truncate(thinkingParts.join("\n").trim(), cfg.thinkingMaxChars);
|
|
227
|
+
|
|
228
|
+
if (thinking) {
|
|
229
|
+
lines.push("thinking: " + thinking);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const thoughtParts: string[] = [];
|
|
233
|
+
|
|
234
|
+
for (const t of texts) {
|
|
235
|
+
if (t.trim()) {
|
|
236
|
+
thoughtParts.push(t);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const thought = truncate(thoughtParts.join("\n").trim(), cfg.thoughtMaxChars);
|
|
241
|
+
|
|
242
|
+
if (thought) {
|
|
243
|
+
lines.push("assistant: " + thought);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (sigs.length > 0) {
|
|
247
|
+
lines.push(sigs.join("\n"));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return lines.length > 0 ? [lines.join("\n")] : [];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function summarizeUser(msg: JsonObject, cfg: Config): string[] {
|
|
254
|
+
const parts: string[] = [];
|
|
255
|
+
const content = msg.content;
|
|
256
|
+
|
|
257
|
+
if (isString(content)) {
|
|
258
|
+
if (startsWithSummaryHeader(content)) {
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const text = stripTaskNotifications(content);
|
|
263
|
+
|
|
264
|
+
if (text.trim()) {
|
|
265
|
+
parts.push("user: " + truncate(text.trim(), cfg.humanMaxChars));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return parts;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!isArray(content)) {
|
|
272
|
+
return parts;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const b of content) {
|
|
276
|
+
if (!isRecord(b)) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const t = asString(b.type);
|
|
281
|
+
|
|
282
|
+
if (t === "text") {
|
|
283
|
+
let text = asString(b.text);
|
|
284
|
+
|
|
285
|
+
if (startsWithSummaryHeader(text)) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
text = stripTaskNotifications(text);
|
|
290
|
+
|
|
291
|
+
if (!text.trim()) {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
parts.push("user: " + truncate(text.trim(), cfg.humanMaxChars));
|
|
296
|
+
} else if (t === "tool_result") {
|
|
297
|
+
const text = resultText(b.content).trim();
|
|
298
|
+
|
|
299
|
+
if (text && text.length <= cfg.resultMaxChars) {
|
|
300
|
+
parts.push("result: " + text);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return parts;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function summarizeMessage(msg: JsonObject, cfg: Config): string[] {
|
|
309
|
+
if (isAssistant(msg)) {
|
|
310
|
+
return summarizeAssistant(msg, cfg);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (asString(msg.role) === "user") {
|
|
314
|
+
return summarizeUser(msg, cfg);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (asString(msg.role) === "system") {
|
|
318
|
+
const content = msg.content;
|
|
319
|
+
let text = "";
|
|
320
|
+
|
|
321
|
+
if (isString(content)) {
|
|
322
|
+
text = content;
|
|
323
|
+
} else {
|
|
324
|
+
const bits: string[] = [];
|
|
325
|
+
|
|
326
|
+
for (const b of asArray(content)) {
|
|
327
|
+
if (isRecord(b) && asString(b.type) === "text") {
|
|
328
|
+
bits.push(asString(b.text));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
text = bits.join("\n");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
text = text.trim();
|
|
336
|
+
|
|
337
|
+
if (text) {
|
|
338
|
+
return ["system: " + truncate(text, cfg.humanMaxChars)];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return [];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function isSystem(msg: JsonObject): boolean {
|
|
348
|
+
return asString(msg.role) === "system";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function userMessage(text: string): JsonObject {
|
|
352
|
+
return { role: "user", content: text };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export const DIALECT: Dialect = makeDialect({
|
|
356
|
+
name: "anthropic",
|
|
357
|
+
digestMessage,
|
|
358
|
+
isAssistant,
|
|
359
|
+
summarizeMessage,
|
|
360
|
+
userMessage,
|
|
361
|
+
isSummaryMessage,
|
|
362
|
+
sessionKey,
|
|
363
|
+
trimFromHead: isSystem,
|
|
364
|
+
});
|