@martian-engineering/lossless-claw 0.2.7 → 0.3.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/index.ts +638 -27
- package/package.json +9 -2
- package/src/assembler.ts +58 -2
- package/src/compaction.ts +1 -1
- package/src/engine.ts +24 -1
- package/src/tools/lcm-describe-tool.ts +11 -5
- package/src/tools/lcm-grep-tool.ts +6 -4
package/index.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* DAG-based conversation summarization with incremental compaction,
|
|
5
5
|
* full-text search, and sub-agent expansion.
|
|
6
6
|
*/
|
|
7
|
-
import { readFileSync } from "node:fs";
|
|
7
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
8
9
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
9
10
|
import { resolveLcmConfig } from "./src/db/config.js";
|
|
10
11
|
import { LcmContextEngine } from "./src/engine.js";
|
|
@@ -45,8 +46,12 @@ type PluginEnvSnapshot = {
|
|
|
45
46
|
pluginSummaryProvider: string;
|
|
46
47
|
openclawProvider: string;
|
|
47
48
|
openclawDefaultModel: string;
|
|
49
|
+
agentDir: string;
|
|
50
|
+
home: string;
|
|
48
51
|
};
|
|
49
52
|
|
|
53
|
+
type ReadEnvFn = (key: string) => string | undefined;
|
|
54
|
+
|
|
50
55
|
type CompleteSimpleOptions = {
|
|
51
56
|
apiKey?: string;
|
|
52
57
|
maxTokens: number;
|
|
@@ -90,6 +95,10 @@ type RuntimeModelAuth = {
|
|
|
90
95
|
}) => Promise<RuntimeModelAuthResult | undefined>;
|
|
91
96
|
};
|
|
92
97
|
|
|
98
|
+
const MODEL_AUTH_PR_URL = "https://github.com/openclaw/openclaw/pull/41090";
|
|
99
|
+
const MODEL_AUTH_MERGE_COMMIT = "4790e40";
|
|
100
|
+
const MODEL_AUTH_REQUIRED_RELEASE = "the first OpenClaw release after 2026.3.8";
|
|
101
|
+
|
|
93
102
|
/** Capture plugin env values once during initialization. */
|
|
94
103
|
function snapshotPluginEnv(env: NodeJS.ProcessEnv = process.env): PluginEnvSnapshot {
|
|
95
104
|
return {
|
|
@@ -99,6 +108,8 @@ function snapshotPluginEnv(env: NodeJS.ProcessEnv = process.env): PluginEnvSnaps
|
|
|
99
108
|
pluginSummaryProvider: "",
|
|
100
109
|
openclawProvider: env.OPENCLAW_PROVIDER?.trim() ?? "",
|
|
101
110
|
openclawDefaultModel: "",
|
|
111
|
+
agentDir: env.OPENCLAW_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim() || "",
|
|
112
|
+
home: env.HOME?.trim() ?? "",
|
|
102
113
|
};
|
|
103
114
|
}
|
|
104
115
|
|
|
@@ -117,6 +128,71 @@ function readDefaultModelFromConfig(config: unknown): string {
|
|
|
117
128
|
return typeof primary === "string" ? primary.trim() : "";
|
|
118
129
|
}
|
|
119
130
|
|
|
131
|
+
/** Resolve common provider API keys from environment. */
|
|
132
|
+
function resolveApiKey(provider: string, readEnv: ReadEnvFn): string | undefined {
|
|
133
|
+
const keyMap: Record<string, string[]> = {
|
|
134
|
+
openai: ["OPENAI_API_KEY"],
|
|
135
|
+
anthropic: ["ANTHROPIC_API_KEY"],
|
|
136
|
+
google: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
|
|
137
|
+
groq: ["GROQ_API_KEY"],
|
|
138
|
+
xai: ["XAI_API_KEY"],
|
|
139
|
+
mistral: ["MISTRAL_API_KEY"],
|
|
140
|
+
together: ["TOGETHER_API_KEY"],
|
|
141
|
+
openrouter: ["OPENROUTER_API_KEY"],
|
|
142
|
+
"github-copilot": ["GITHUB_COPILOT_API_KEY", "GITHUB_TOKEN"],
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const providerKey = provider.trim().toLowerCase();
|
|
146
|
+
const keys = keyMap[providerKey] ?? [];
|
|
147
|
+
const normalizedProviderEnv = `${providerKey.replace(/[^a-z0-9]/g, "_").toUpperCase()}_API_KEY`;
|
|
148
|
+
keys.push(normalizedProviderEnv);
|
|
149
|
+
|
|
150
|
+
for (const key of keys) {
|
|
151
|
+
const value = readEnv(key)?.trim();
|
|
152
|
+
if (value) {
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A SecretRef pointing to a value inside secrets.json via a nested path. */
|
|
160
|
+
type SecretRef = {
|
|
161
|
+
source?: string;
|
|
162
|
+
provider?: string;
|
|
163
|
+
id: string;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
type SecretProviderConfig = {
|
|
167
|
+
source?: string;
|
|
168
|
+
path?: string;
|
|
169
|
+
mode?: string;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
type AuthProfileCredential =
|
|
173
|
+
| { type: "api_key"; provider: string; key?: string; keyRef?: SecretRef; email?: string }
|
|
174
|
+
| { type: "token"; provider: string; token?: string; tokenRef?: SecretRef; expires?: number; email?: string }
|
|
175
|
+
| ({
|
|
176
|
+
type: "oauth";
|
|
177
|
+
provider: string;
|
|
178
|
+
access?: string;
|
|
179
|
+
refresh?: string;
|
|
180
|
+
expires?: number;
|
|
181
|
+
email?: string;
|
|
182
|
+
} & Record<string, unknown>);
|
|
183
|
+
|
|
184
|
+
type AuthProfileStore = {
|
|
185
|
+
profiles: Record<string, AuthProfileCredential>;
|
|
186
|
+
order?: Record<string, string[]>;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
type PiAiOAuthCredentials = {
|
|
190
|
+
refresh: string;
|
|
191
|
+
access: string;
|
|
192
|
+
expires: number;
|
|
193
|
+
[key: string]: unknown;
|
|
194
|
+
};
|
|
195
|
+
|
|
120
196
|
type PiAiModule = {
|
|
121
197
|
completeSimple?: (
|
|
122
198
|
model: {
|
|
@@ -148,6 +224,11 @@ type PiAiModule = {
|
|
|
148
224
|
) => Promise<Record<string, unknown> & { content?: Array<{ type: string; text?: string }> }>;
|
|
149
225
|
getModel?: (provider: string, modelId: string) => unknown;
|
|
150
226
|
getModels?: (provider: string) => unknown[];
|
|
227
|
+
getEnvApiKey?: (provider: string) => string | undefined;
|
|
228
|
+
getOAuthApiKey?: (
|
|
229
|
+
providerId: string,
|
|
230
|
+
credentials: Record<string, PiAiOAuthCredentials>,
|
|
231
|
+
) => Promise<{ apiKey: string; newCredentials: PiAiOAuthCredentials } | null>;
|
|
151
232
|
};
|
|
152
233
|
|
|
153
234
|
/** Narrow unknown values to plain objects. */
|
|
@@ -251,14 +332,11 @@ function resolveProviderApiFromRuntimeConfig(
|
|
|
251
332
|
return typeof api === "string" && api.trim() ? api.trim() : undefined;
|
|
252
333
|
}
|
|
253
334
|
|
|
254
|
-
/** Resolve runtime.modelAuth from plugin runtime
|
|
255
|
-
function getRuntimeModelAuth(api: OpenClawPluginApi): RuntimeModelAuth {
|
|
335
|
+
/** Resolve runtime.modelAuth from plugin runtime when available. */
|
|
336
|
+
function getRuntimeModelAuth(api: OpenClawPluginApi): RuntimeModelAuth | undefined {
|
|
256
337
|
const runtime = api.runtime as OpenClawPluginApi["runtime"] & {
|
|
257
338
|
modelAuth?: RuntimeModelAuth;
|
|
258
339
|
};
|
|
259
|
-
if (!runtime.modelAuth) {
|
|
260
|
-
throw new Error("OpenClaw runtime.modelAuth is required by lossless-claw.");
|
|
261
|
-
}
|
|
262
340
|
return runtime.modelAuth;
|
|
263
341
|
}
|
|
264
342
|
|
|
@@ -292,6 +370,407 @@ function resolveApiKeyFromAuthResult(auth: RuntimeModelAuthResult | undefined):
|
|
|
292
370
|
return apiKey ? apiKey : undefined;
|
|
293
371
|
}
|
|
294
372
|
|
|
373
|
+
function buildLegacyAuthFallbackWarning(): string {
|
|
374
|
+
return [
|
|
375
|
+
"[lcm] OpenClaw runtime.modelAuth is unavailable; using legacy auth-profiles fallback.",
|
|
376
|
+
`Stock lossless-claw 0.2.7 expects OpenClaw plugin runtime support from PR #41090 (${MODEL_AUTH_PR_URL}).`,
|
|
377
|
+
`OpenClaw 2026.3.8 and 2026.3.8-beta.1 do not include merge commit ${MODEL_AUTH_MERGE_COMMIT};`,
|
|
378
|
+
`${MODEL_AUTH_REQUIRED_RELEASE} is required for stock lossless-claw 0.2.7 without this fallback patch.`,
|
|
379
|
+
].join(" ");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Parse auth-profiles JSON into a minimal store shape. */
|
|
383
|
+
function parseAuthProfileStore(raw: string): AuthProfileStore | undefined {
|
|
384
|
+
try {
|
|
385
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
386
|
+
if (!isRecord(parsed) || !isRecord(parsed.profiles)) {
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const profiles: Record<string, AuthProfileCredential> = {};
|
|
391
|
+
for (const [profileId, value] of Object.entries(parsed.profiles)) {
|
|
392
|
+
if (!isRecord(value)) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const type = value.type;
|
|
396
|
+
const provider = typeof value.provider === "string" ? value.provider.trim() : "";
|
|
397
|
+
if (!provider || (type !== "api_key" && type !== "token" && type !== "oauth")) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
profiles[profileId] = value as AuthProfileCredential;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const rawOrder = isRecord(parsed.order) ? parsed.order : undefined;
|
|
404
|
+
const order: Record<string, string[]> | undefined = rawOrder
|
|
405
|
+
? Object.entries(rawOrder).reduce<Record<string, string[]>>((acc, [provider, value]) => {
|
|
406
|
+
if (!Array.isArray(value)) {
|
|
407
|
+
return acc;
|
|
408
|
+
}
|
|
409
|
+
const ids = value
|
|
410
|
+
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
|
|
411
|
+
.filter(Boolean);
|
|
412
|
+
if (ids.length > 0) {
|
|
413
|
+
acc[provider] = ids;
|
|
414
|
+
}
|
|
415
|
+
return acc;
|
|
416
|
+
}, {})
|
|
417
|
+
: undefined;
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
profiles,
|
|
421
|
+
...(order && Object.keys(order).length > 0 ? { order } : {}),
|
|
422
|
+
};
|
|
423
|
+
} catch {
|
|
424
|
+
return undefined;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Merge auth stores, letting later stores override earlier profiles/order. */
|
|
429
|
+
function mergeAuthProfileStores(stores: AuthProfileStore[]): AuthProfileStore | undefined {
|
|
430
|
+
if (stores.length === 0) {
|
|
431
|
+
return undefined;
|
|
432
|
+
}
|
|
433
|
+
const merged: AuthProfileStore = { profiles: {} };
|
|
434
|
+
for (const store of stores) {
|
|
435
|
+
merged.profiles = { ...merged.profiles, ...store.profiles };
|
|
436
|
+
if (store.order) {
|
|
437
|
+
merged.order = { ...(merged.order ?? {}), ...store.order };
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return merged;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Determine candidate auth store paths ordered by precedence. */
|
|
444
|
+
function resolveAuthStorePaths(params: { agentDir?: string; envSnapshot: PluginEnvSnapshot }): string[] {
|
|
445
|
+
const paths: string[] = [];
|
|
446
|
+
const directAgentDir = params.agentDir?.trim();
|
|
447
|
+
if (directAgentDir) {
|
|
448
|
+
paths.push(join(directAgentDir, "auth-profiles.json"));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const envAgentDir = params.envSnapshot.agentDir;
|
|
452
|
+
if (envAgentDir) {
|
|
453
|
+
paths.push(join(envAgentDir, "auth-profiles.json"));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const home = params.envSnapshot.home;
|
|
457
|
+
if (home) {
|
|
458
|
+
paths.push(join(home, ".openclaw", "agents", "main", "agent", "auth-profiles.json"));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return [...new Set(paths)];
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Build profile selection order for provider auth lookup. */
|
|
465
|
+
function resolveAuthProfileCandidates(params: {
|
|
466
|
+
provider: string;
|
|
467
|
+
store: AuthProfileStore;
|
|
468
|
+
authProfileId?: string;
|
|
469
|
+
runtimeConfig?: unknown;
|
|
470
|
+
}): string[] {
|
|
471
|
+
const candidates: string[] = [];
|
|
472
|
+
const normalizedProvider = normalizeProviderId(params.provider);
|
|
473
|
+
const push = (value: string | undefined) => {
|
|
474
|
+
const profileId = value?.trim();
|
|
475
|
+
if (!profileId) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (!candidates.includes(profileId)) {
|
|
479
|
+
candidates.push(profileId);
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
push(params.authProfileId);
|
|
484
|
+
|
|
485
|
+
const storeOrder = findProviderConfigValue(params.store.order, params.provider);
|
|
486
|
+
for (const profileId of storeOrder ?? []) {
|
|
487
|
+
push(profileId);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (isRecord(params.runtimeConfig)) {
|
|
491
|
+
const auth = params.runtimeConfig.auth;
|
|
492
|
+
if (isRecord(auth)) {
|
|
493
|
+
const order = findProviderConfigValue(
|
|
494
|
+
isRecord(auth.order) ? (auth.order as Record<string, unknown>) : undefined,
|
|
495
|
+
params.provider,
|
|
496
|
+
);
|
|
497
|
+
if (Array.isArray(order)) {
|
|
498
|
+
for (const profileId of order) {
|
|
499
|
+
if (typeof profileId === "string") {
|
|
500
|
+
push(profileId);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
for (const [profileId, credential] of Object.entries(params.store.profiles)) {
|
|
508
|
+
if (normalizeProviderId(credential.provider) === normalizedProvider) {
|
|
509
|
+
push(profileId);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return candidates;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Resolve a SecretRef (tokenRef/keyRef) to a credential string.
|
|
518
|
+
*
|
|
519
|
+
* OpenClaw's auth-profiles support a level of indirection: instead of storing
|
|
520
|
+
* the raw API key or token inline, a credential can reference it via a
|
|
521
|
+
* SecretRef. Two resolution strategies are supported:
|
|
522
|
+
*
|
|
523
|
+
* 1. `source: "env"` — read the value from an environment variable whose
|
|
524
|
+
* name is `ref.id` (e.g. `{ source: "env", id: "ANTHROPIC_API_KEY" }`).
|
|
525
|
+
*
|
|
526
|
+
* 2. File-based — resolve against a configured `secrets.providers.<provider>`
|
|
527
|
+
* file provider when available. JSON-mode providers walk slash-delimited
|
|
528
|
+
* paths, while singleValue providers use the sentinel id `value`.
|
|
529
|
+
*
|
|
530
|
+
* 3. Legacy fallback — when no file provider config is available, fall back to
|
|
531
|
+
* `~/.openclaw/secrets.json` for backward compatibility.
|
|
532
|
+
*/
|
|
533
|
+
function resolveSecretRef(params: {
|
|
534
|
+
ref: SecretRef | undefined;
|
|
535
|
+
home: string;
|
|
536
|
+
config?: unknown;
|
|
537
|
+
}): string | undefined {
|
|
538
|
+
const ref = params.ref;
|
|
539
|
+
if (!ref?.id) return undefined;
|
|
540
|
+
|
|
541
|
+
// source: env — read directly from environment variable
|
|
542
|
+
if (ref.source === "env") {
|
|
543
|
+
const val = process.env[ref.id]?.trim();
|
|
544
|
+
return val || undefined;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// File-based provider config — use configured file provider when present.
|
|
548
|
+
try {
|
|
549
|
+
const providers = isRecord(params.config)
|
|
550
|
+
? (params.config as { secrets?: { providers?: Record<string, unknown> } }).secrets?.providers
|
|
551
|
+
: undefined;
|
|
552
|
+
const providerName = ref.provider?.trim() || "default";
|
|
553
|
+
const provider =
|
|
554
|
+
providers && isRecord(providers)
|
|
555
|
+
? providers[providerName]
|
|
556
|
+
: undefined;
|
|
557
|
+
if (isRecord(provider) && provider.source === "file" && typeof provider.path === "string") {
|
|
558
|
+
const configuredPath = provider.path.trim();
|
|
559
|
+
const filePath =
|
|
560
|
+
configuredPath.startsWith("~/") && params.home
|
|
561
|
+
? join(params.home, configuredPath.slice(2))
|
|
562
|
+
: configuredPath;
|
|
563
|
+
if (!filePath) {
|
|
564
|
+
return undefined;
|
|
565
|
+
}
|
|
566
|
+
const raw = readFileSync(filePath, "utf8");
|
|
567
|
+
if (provider.mode === "singleValue") {
|
|
568
|
+
if (ref.id.trim() !== "value") {
|
|
569
|
+
return undefined;
|
|
570
|
+
}
|
|
571
|
+
const value = raw.trim();
|
|
572
|
+
return value || undefined;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const secrets = JSON.parse(raw) as Record<string, unknown>;
|
|
576
|
+
const parts = ref.id.replace(/^\//, "").split("/");
|
|
577
|
+
let current: unknown = secrets;
|
|
578
|
+
for (const part of parts) {
|
|
579
|
+
if (!current || typeof current !== "object") return undefined;
|
|
580
|
+
current = (current as Record<string, unknown>)[part];
|
|
581
|
+
}
|
|
582
|
+
return typeof current === "string" && current.trim() ? current.trim() : undefined;
|
|
583
|
+
}
|
|
584
|
+
} catch {
|
|
585
|
+
// Fall through to the legacy secrets.json lookup below.
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Legacy file fallback (source: "file" or unset) — read from ~/.openclaw/secrets.json
|
|
589
|
+
try {
|
|
590
|
+
const secretsPath = join(params.home, ".openclaw", "secrets.json");
|
|
591
|
+
const raw = readFileSync(secretsPath, "utf8");
|
|
592
|
+
const secrets = JSON.parse(raw) as Record<string, unknown>;
|
|
593
|
+
const parts = ref.id.replace(/^\//, "").split("/");
|
|
594
|
+
let current: unknown = secrets;
|
|
595
|
+
for (const part of parts) {
|
|
596
|
+
if (!current || typeof current !== "object") return undefined;
|
|
597
|
+
current = (current as Record<string, unknown>)[part];
|
|
598
|
+
}
|
|
599
|
+
return typeof current === "string" && current.trim() ? current.trim() : undefined;
|
|
600
|
+
} catch {
|
|
601
|
+
return undefined;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Resolve OAuth/api-key/token credentials from auth-profiles store. */
|
|
606
|
+
async function resolveApiKeyFromAuthProfiles(params: {
|
|
607
|
+
provider: string;
|
|
608
|
+
authProfileId?: string;
|
|
609
|
+
agentDir?: string;
|
|
610
|
+
runtimeConfig?: unknown;
|
|
611
|
+
appConfig?: unknown;
|
|
612
|
+
piAiModule: PiAiModule;
|
|
613
|
+
envSnapshot: PluginEnvSnapshot;
|
|
614
|
+
}): Promise<string | undefined> {
|
|
615
|
+
const storesWithPaths = resolveAuthStorePaths({
|
|
616
|
+
agentDir: params.agentDir,
|
|
617
|
+
envSnapshot: params.envSnapshot,
|
|
618
|
+
})
|
|
619
|
+
.map((path) => {
|
|
620
|
+
try {
|
|
621
|
+
const parsed = parseAuthProfileStore(readFileSync(path, "utf8"));
|
|
622
|
+
return parsed ? { path, store: parsed } : undefined;
|
|
623
|
+
} catch {
|
|
624
|
+
return undefined;
|
|
625
|
+
}
|
|
626
|
+
})
|
|
627
|
+
.filter((entry): entry is { path: string; store: AuthProfileStore } => !!entry);
|
|
628
|
+
if (storesWithPaths.length === 0) {
|
|
629
|
+
return undefined;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const mergedStore = mergeAuthProfileStores(storesWithPaths.map((entry) => entry.store));
|
|
633
|
+
if (!mergedStore) {
|
|
634
|
+
return undefined;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const candidates = resolveAuthProfileCandidates({
|
|
638
|
+
provider: params.provider,
|
|
639
|
+
store: mergedStore,
|
|
640
|
+
authProfileId: params.authProfileId,
|
|
641
|
+
runtimeConfig: params.runtimeConfig,
|
|
642
|
+
});
|
|
643
|
+
if (candidates.length === 0) {
|
|
644
|
+
return undefined;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const persistPath =
|
|
648
|
+
params.agentDir?.trim() ? join(params.agentDir.trim(), "auth-profiles.json") : storesWithPaths[0]?.path;
|
|
649
|
+
const secretConfig = (() => {
|
|
650
|
+
if (isRecord(params.runtimeConfig)) {
|
|
651
|
+
const runtimeProviders = (params.runtimeConfig as {
|
|
652
|
+
secrets?: { providers?: Record<string, unknown> };
|
|
653
|
+
}).secrets?.providers;
|
|
654
|
+
if (isRecord(runtimeProviders) && Object.keys(runtimeProviders).length > 0) {
|
|
655
|
+
return params.runtimeConfig;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return params.appConfig ?? params.runtimeConfig;
|
|
659
|
+
})();
|
|
660
|
+
|
|
661
|
+
for (const profileId of candidates) {
|
|
662
|
+
const credential = mergedStore.profiles[profileId];
|
|
663
|
+
if (!credential) {
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
if (normalizeProviderId(credential.provider) !== normalizeProviderId(params.provider)) {
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (credential.type === "api_key") {
|
|
671
|
+
const key =
|
|
672
|
+
credential.key?.trim() ||
|
|
673
|
+
resolveSecretRef({
|
|
674
|
+
ref: credential.keyRef,
|
|
675
|
+
home: params.envSnapshot.home,
|
|
676
|
+
config: secretConfig,
|
|
677
|
+
});
|
|
678
|
+
if (key) {
|
|
679
|
+
return key;
|
|
680
|
+
}
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (credential.type === "token") {
|
|
685
|
+
const token =
|
|
686
|
+
credential.token?.trim() ||
|
|
687
|
+
resolveSecretRef({
|
|
688
|
+
ref: credential.tokenRef,
|
|
689
|
+
home: params.envSnapshot.home,
|
|
690
|
+
config: secretConfig,
|
|
691
|
+
});
|
|
692
|
+
if (!token) {
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
const expires = credential.expires;
|
|
696
|
+
if (typeof expires === "number" && Number.isFinite(expires) && expires > 0 && Date.now() >= expires) {
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
return token;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const access = credential.access?.trim();
|
|
703
|
+
const expires = credential.expires;
|
|
704
|
+
const isExpired =
|
|
705
|
+
typeof expires === "number" && Number.isFinite(expires) && expires > 0 && Date.now() >= expires;
|
|
706
|
+
|
|
707
|
+
if (!isExpired && access) {
|
|
708
|
+
if (
|
|
709
|
+
(credential.provider === "google-gemini-cli" || credential.provider === "google-antigravity") &&
|
|
710
|
+
typeof credential.projectId === "string" &&
|
|
711
|
+
credential.projectId.trim()
|
|
712
|
+
) {
|
|
713
|
+
return JSON.stringify({
|
|
714
|
+
token: access,
|
|
715
|
+
projectId: credential.projectId.trim(),
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
return access;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (typeof params.piAiModule.getOAuthApiKey !== "function") {
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
try {
|
|
726
|
+
const oauthCredential = {
|
|
727
|
+
access: credential.access ?? "",
|
|
728
|
+
refresh: credential.refresh ?? "",
|
|
729
|
+
expires: typeof credential.expires === "number" ? credential.expires : 0,
|
|
730
|
+
...(typeof credential.projectId === "string" ? { projectId: credential.projectId } : {}),
|
|
731
|
+
...(typeof credential.accountId === "string" ? { accountId: credential.accountId } : {}),
|
|
732
|
+
};
|
|
733
|
+
const refreshed = await params.piAiModule.getOAuthApiKey(params.provider, {
|
|
734
|
+
[params.provider]: oauthCredential,
|
|
735
|
+
});
|
|
736
|
+
if (!refreshed?.apiKey) {
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
mergedStore.profiles[profileId] = {
|
|
740
|
+
...credential,
|
|
741
|
+
...refreshed.newCredentials,
|
|
742
|
+
type: "oauth",
|
|
743
|
+
};
|
|
744
|
+
if (persistPath) {
|
|
745
|
+
try {
|
|
746
|
+
writeFileSync(
|
|
747
|
+
persistPath,
|
|
748
|
+
JSON.stringify(
|
|
749
|
+
{
|
|
750
|
+
version: 1,
|
|
751
|
+
profiles: mergedStore.profiles,
|
|
752
|
+
...(mergedStore.order ? { order: mergedStore.order } : {}),
|
|
753
|
+
},
|
|
754
|
+
null,
|
|
755
|
+
2,
|
|
756
|
+
),
|
|
757
|
+
"utf8",
|
|
758
|
+
);
|
|
759
|
+
} catch {
|
|
760
|
+
// Ignore persistence errors: refreshed credentials remain usable in-memory for this run.
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return refreshed.apiKey;
|
|
764
|
+
} catch {
|
|
765
|
+
if (access) {
|
|
766
|
+
return access;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return undefined;
|
|
772
|
+
}
|
|
773
|
+
|
|
295
774
|
/** Build a minimal but useful sub-agent prompt. */
|
|
296
775
|
function buildSubagentSystemPrompt(params: {
|
|
297
776
|
depth: number;
|
|
@@ -353,6 +832,7 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
353
832
|
const envSnapshot = snapshotPluginEnv();
|
|
354
833
|
envSnapshot.openclawDefaultModel = readDefaultModelFromConfig(api.config);
|
|
355
834
|
const modelAuth = getRuntimeModelAuth(api);
|
|
835
|
+
const readEnv: ReadEnvFn = (key) => process.env[key];
|
|
356
836
|
const pluginConfig =
|
|
357
837
|
api.pluginConfig && typeof api.pluginConfig === "object" && !Array.isArray(api.pluginConfig)
|
|
358
838
|
? api.pluginConfig
|
|
@@ -371,6 +851,10 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
371
851
|
}
|
|
372
852
|
}
|
|
373
853
|
|
|
854
|
+
if (!modelAuth) {
|
|
855
|
+
api.logger.warn(buildLegacyAuthFallbackWarning());
|
|
856
|
+
}
|
|
857
|
+
|
|
374
858
|
return {
|
|
375
859
|
config,
|
|
376
860
|
complete: async ({
|
|
@@ -401,11 +885,23 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
401
885
|
return { content: [] };
|
|
402
886
|
}
|
|
403
887
|
|
|
888
|
+
// When runtimeConfig is undefined (e.g. resolveLargeFileTextSummarizer
|
|
889
|
+
// passes legacyParams without config), fall back to the plugin API so
|
|
890
|
+
// provider-level baseUrl/headers/apiKey are always resolvable.
|
|
891
|
+
let effectiveRuntimeConfig = runtimeConfig;
|
|
892
|
+
if (!isRecord(effectiveRuntimeConfig)) {
|
|
893
|
+
try {
|
|
894
|
+
effectiveRuntimeConfig = api.runtime.config.loadConfig();
|
|
895
|
+
} catch {
|
|
896
|
+
// loadConfig may not be available in all contexts; leave undefined.
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
404
900
|
const knownModel =
|
|
405
901
|
typeof mod.getModel === "function" ? mod.getModel(providerId, modelId) : undefined;
|
|
406
902
|
const fallbackApi =
|
|
407
903
|
providerApi?.trim() ||
|
|
408
|
-
resolveProviderApiFromRuntimeConfig(
|
|
904
|
+
resolveProviderApiFromRuntimeConfig(effectiveRuntimeConfig, providerId) ||
|
|
409
905
|
(() => {
|
|
410
906
|
if (typeof mod.getModels !== "function") {
|
|
411
907
|
return undefined;
|
|
@@ -419,6 +915,21 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
419
915
|
})() ||
|
|
420
916
|
inferApiFromProvider(providerId);
|
|
421
917
|
|
|
918
|
+
// Resolve provider-level config (baseUrl, headers, etc.) from runtime config.
|
|
919
|
+
// Custom/proxy providers (e.g. bailian, local proxies) store their baseUrl and
|
|
920
|
+
// apiKey under models.providers.<provider> in openclaw.json. Without this
|
|
921
|
+
// lookup the resolved model object lacks baseUrl, which crashes pi-ai's
|
|
922
|
+
// detectCompat() ("Cannot read properties of undefined (reading 'includes')"),
|
|
923
|
+
// and the apiKey is unresolvable, causing 401 errors. See #19.
|
|
924
|
+
const providerLevelConfig: Record<string, unknown> = (() => {
|
|
925
|
+
if (!isRecord(effectiveRuntimeConfig)) return {};
|
|
926
|
+
const providers = (effectiveRuntimeConfig as { models?: { providers?: Record<string, unknown> } })
|
|
927
|
+
.models?.providers;
|
|
928
|
+
if (!providers) return {};
|
|
929
|
+
const cfg = findProviderConfigValue(providers, providerId);
|
|
930
|
+
return isRecord(cfg) ? cfg : {};
|
|
931
|
+
})();
|
|
932
|
+
|
|
422
933
|
const resolvedModel =
|
|
423
934
|
isRecord(knownModel) &&
|
|
424
935
|
typeof knownModel.api === "string" &&
|
|
@@ -429,6 +940,18 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
429
940
|
id: knownModel.id,
|
|
430
941
|
provider: knownModel.provider,
|
|
431
942
|
api: knownModel.api,
|
|
943
|
+
// Merge baseUrl/headers from provider config if not already on the model.
|
|
944
|
+
// Always set baseUrl to a string — pi-ai's detectCompat() crashes when
|
|
945
|
+
// baseUrl is undefined.
|
|
946
|
+
baseUrl:
|
|
947
|
+
typeof knownModel.baseUrl === "string"
|
|
948
|
+
? knownModel.baseUrl
|
|
949
|
+
: typeof providerLevelConfig.baseUrl === "string"
|
|
950
|
+
? providerLevelConfig.baseUrl
|
|
951
|
+
: "",
|
|
952
|
+
...(knownModel.headers == null && isRecord(providerLevelConfig.headers)
|
|
953
|
+
? { headers: providerLevelConfig.headers }
|
|
954
|
+
: {}),
|
|
432
955
|
}
|
|
433
956
|
: {
|
|
434
957
|
id: modelId,
|
|
@@ -445,10 +968,18 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
445
968
|
},
|
|
446
969
|
contextWindow: 200_000,
|
|
447
970
|
maxTokens: 8_000,
|
|
971
|
+
// Always set baseUrl to a string — pi-ai's detectCompat() crashes when
|
|
972
|
+
// baseUrl is undefined.
|
|
973
|
+
baseUrl: typeof providerLevelConfig.baseUrl === "string"
|
|
974
|
+
? providerLevelConfig.baseUrl
|
|
975
|
+
: "",
|
|
976
|
+
...(isRecord(providerLevelConfig.headers)
|
|
977
|
+
? { headers: providerLevelConfig.headers }
|
|
978
|
+
: {}),
|
|
448
979
|
};
|
|
449
980
|
|
|
450
981
|
let resolvedApiKey = apiKey?.trim();
|
|
451
|
-
if (!resolvedApiKey) {
|
|
982
|
+
if (!resolvedApiKey && modelAuth) {
|
|
452
983
|
try {
|
|
453
984
|
resolvedApiKey = resolveApiKeyFromAuthResult(
|
|
454
985
|
await modelAuth.resolveApiKeyForProvider({
|
|
@@ -464,6 +995,38 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
464
995
|
);
|
|
465
996
|
}
|
|
466
997
|
}
|
|
998
|
+
if (!resolvedApiKey && !modelAuth) {
|
|
999
|
+
resolvedApiKey = resolveApiKey(providerId, readEnv);
|
|
1000
|
+
}
|
|
1001
|
+
if (!resolvedApiKey && !modelAuth && typeof mod.getEnvApiKey === "function") {
|
|
1002
|
+
resolvedApiKey = mod.getEnvApiKey(providerId)?.trim();
|
|
1003
|
+
}
|
|
1004
|
+
if (!resolvedApiKey && !modelAuth) {
|
|
1005
|
+
resolvedApiKey = await resolveApiKeyFromAuthProfiles({
|
|
1006
|
+
provider: providerId,
|
|
1007
|
+
authProfileId,
|
|
1008
|
+
agentDir,
|
|
1009
|
+
appConfig: api.config,
|
|
1010
|
+
runtimeConfig: effectiveRuntimeConfig,
|
|
1011
|
+
piAiModule: mod,
|
|
1012
|
+
envSnapshot,
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
// Fallback: read apiKey from models.providers config (e.g. proxy providers
|
|
1016
|
+
// with keys like "not-needed-for-cli-proxy").
|
|
1017
|
+
if (!resolvedApiKey && isRecord(effectiveRuntimeConfig)) {
|
|
1018
|
+
const providers = (effectiveRuntimeConfig as { models?: { providers?: Record<string, unknown> } })
|
|
1019
|
+
.models?.providers;
|
|
1020
|
+
if (providers) {
|
|
1021
|
+
const providerCfg = findProviderConfigValue(providers, providerId);
|
|
1022
|
+
if (isRecord(providerCfg) && typeof providerCfg.apiKey === "string") {
|
|
1023
|
+
const cfgKey = providerCfg.apiKey.trim();
|
|
1024
|
+
if (cfgKey) {
|
|
1025
|
+
resolvedApiKey = cfgKey;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
467
1030
|
|
|
468
1031
|
const completeOptions = buildCompleteSimpleOptions({
|
|
469
1032
|
api: resolvedModel.api,
|
|
@@ -587,28 +1150,76 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
|
|
|
587
1150
|
return { provider, model: raw };
|
|
588
1151
|
},
|
|
589
1152
|
getApiKey: async (provider, model, options) => {
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
1153
|
+
if (modelAuth) {
|
|
1154
|
+
try {
|
|
1155
|
+
const modelAuthKey = resolveApiKeyFromAuthResult(
|
|
1156
|
+
await modelAuth.getApiKeyForModel({
|
|
1157
|
+
model: buildModelAuthLookupModel({ provider, model }),
|
|
1158
|
+
cfg: api.config,
|
|
1159
|
+
...(options?.profileId ? { profileId: options.profileId } : {}),
|
|
1160
|
+
...(options?.preferredProfile ? { preferredProfile: options.preferredProfile } : {}),
|
|
1161
|
+
}),
|
|
1162
|
+
);
|
|
1163
|
+
if (modelAuthKey) {
|
|
1164
|
+
return modelAuthKey;
|
|
1165
|
+
}
|
|
1166
|
+
} catch {
|
|
1167
|
+
// Fall through to auth-profile lookup for older OpenClaw runtimes.
|
|
1168
|
+
}
|
|
601
1169
|
}
|
|
1170
|
+
|
|
1171
|
+
const envKey = resolveApiKey(provider, readEnv);
|
|
1172
|
+
if (envKey) {
|
|
1173
|
+
return envKey;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const piAiModuleId = "@mariozechner/pi-ai";
|
|
1177
|
+
const mod = (await import(piAiModuleId)) as PiAiModule;
|
|
1178
|
+
return resolveApiKeyFromAuthProfiles({
|
|
1179
|
+
provider,
|
|
1180
|
+
authProfileId: options?.profileId,
|
|
1181
|
+
agentDir: api.resolvePath("."),
|
|
1182
|
+
runtimeConfig: api.config,
|
|
1183
|
+
piAiModule: mod,
|
|
1184
|
+
envSnapshot,
|
|
1185
|
+
});
|
|
602
1186
|
},
|
|
603
1187
|
requireApiKey: async (provider, model, options) => {
|
|
604
|
-
const key = await
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
1188
|
+
const key = await (async () => {
|
|
1189
|
+
if (modelAuth) {
|
|
1190
|
+
try {
|
|
1191
|
+
const modelAuthKey = resolveApiKeyFromAuthResult(
|
|
1192
|
+
await modelAuth.getApiKeyForModel({
|
|
1193
|
+
model: buildModelAuthLookupModel({ provider, model }),
|
|
1194
|
+
cfg: api.config,
|
|
1195
|
+
...(options?.profileId ? { profileId: options.profileId } : {}),
|
|
1196
|
+
...(options?.preferredProfile ? { preferredProfile: options.preferredProfile } : {}),
|
|
1197
|
+
}),
|
|
1198
|
+
);
|
|
1199
|
+
if (modelAuthKey) {
|
|
1200
|
+
return modelAuthKey;
|
|
1201
|
+
}
|
|
1202
|
+
} catch {
|
|
1203
|
+
// Fall through to auth-profile lookup for older OpenClaw runtimes.
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const envKey = resolveApiKey(provider, readEnv);
|
|
1208
|
+
if (envKey) {
|
|
1209
|
+
return envKey;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
const piAiModuleId = "@mariozechner/pi-ai";
|
|
1213
|
+
const mod = (await import(piAiModuleId)) as PiAiModule;
|
|
1214
|
+
return resolveApiKeyFromAuthProfiles({
|
|
1215
|
+
provider,
|
|
1216
|
+
authProfileId: options?.profileId,
|
|
1217
|
+
agentDir: api.resolvePath("."),
|
|
1218
|
+
runtimeConfig: api.config,
|
|
1219
|
+
piAiModule: mod,
|
|
1220
|
+
envSnapshot,
|
|
1221
|
+
});
|
|
1222
|
+
})();
|
|
612
1223
|
if (!key) {
|
|
613
1224
|
throw new Error(`Missing API key for provider '${provider}' (model '${model}').`);
|
|
614
1225
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@martian-engineering/lossless-claw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Lossless Context Management plugin for OpenClaw — DAG-based conversation summarization with incremental compaction",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -24,7 +24,10 @@
|
|
|
24
24
|
"LICENSE"
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
|
-
"
|
|
27
|
+
"changeset": "changeset",
|
|
28
|
+
"release:verify": "npm test && npm pack --dry-run",
|
|
29
|
+
"test": "vitest run --dir test",
|
|
30
|
+
"version-packages": "changeset version"
|
|
28
31
|
},
|
|
29
32
|
"dependencies": {
|
|
30
33
|
"@mariozechner/pi-agent-core": "*",
|
|
@@ -32,12 +35,16 @@
|
|
|
32
35
|
"@sinclair/typebox": "0.34.48"
|
|
33
36
|
},
|
|
34
37
|
"devDependencies": {
|
|
38
|
+
"@changesets/cli": "^2.30.0",
|
|
35
39
|
"typescript": "^5.7.0",
|
|
36
40
|
"vitest": "^3.0.0"
|
|
37
41
|
},
|
|
38
42
|
"peerDependencies": {
|
|
39
43
|
"openclaw": "*"
|
|
40
44
|
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
41
48
|
"openclaw": {
|
|
42
49
|
"extensions": [
|
|
43
50
|
"./index.ts"
|
package/src/assembler.ts
CHANGED
|
@@ -262,6 +262,10 @@ function toolResultBlockFromPart(part: MessagePartRecord, rawType?: string): unk
|
|
|
262
262
|
const output = parseStoredValue(part.toolOutput) ?? part.textContent ?? "";
|
|
263
263
|
const block: Record<string, unknown> = { type, output };
|
|
264
264
|
|
|
265
|
+
if (typeof part.toolName === "string" && part.toolName.length > 0) {
|
|
266
|
+
block.name = part.toolName;
|
|
267
|
+
}
|
|
268
|
+
|
|
265
269
|
if (type === "function_call_output") {
|
|
266
270
|
if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) {
|
|
267
271
|
block.call_id = part.toolCallId;
|
|
@@ -395,6 +399,10 @@ function pickToolCallId(parts: MessagePartRecord[]): string | undefined {
|
|
|
395
399
|
if (!decoded || typeof decoded !== "object") {
|
|
396
400
|
continue;
|
|
397
401
|
}
|
|
402
|
+
const metadataToolCallId = (decoded as { toolCallId?: unknown }).toolCallId;
|
|
403
|
+
if (typeof metadataToolCallId === "string" && metadataToolCallId.length > 0) {
|
|
404
|
+
return metadataToolCallId;
|
|
405
|
+
}
|
|
398
406
|
const raw = (decoded as { raw?: unknown }).raw;
|
|
399
407
|
if (!raw || typeof raw !== "object") {
|
|
400
408
|
continue;
|
|
@@ -411,6 +419,49 @@ function pickToolCallId(parts: MessagePartRecord[]): string | undefined {
|
|
|
411
419
|
return undefined;
|
|
412
420
|
}
|
|
413
421
|
|
|
422
|
+
function pickToolName(parts: MessagePartRecord[]): string | undefined {
|
|
423
|
+
for (const part of parts) {
|
|
424
|
+
if (typeof part.toolName === "string" && part.toolName.length > 0) {
|
|
425
|
+
return part.toolName;
|
|
426
|
+
}
|
|
427
|
+
const decoded = parseJson(part.metadata);
|
|
428
|
+
if (!decoded || typeof decoded !== "object") {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
const metadataToolName = (decoded as { toolName?: unknown }).toolName;
|
|
432
|
+
if (typeof metadataToolName === "string" && metadataToolName.length > 0) {
|
|
433
|
+
return metadataToolName;
|
|
434
|
+
}
|
|
435
|
+
const raw = (decoded as { raw?: unknown }).raw;
|
|
436
|
+
if (!raw || typeof raw !== "object") {
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const maybe = (raw as { name?: unknown }).name;
|
|
440
|
+
if (typeof maybe === "string" && maybe.length > 0) {
|
|
441
|
+
return maybe;
|
|
442
|
+
}
|
|
443
|
+
const maybeCamel = (raw as { toolName?: unknown }).toolName;
|
|
444
|
+
if (typeof maybeCamel === "string" && maybeCamel.length > 0) {
|
|
445
|
+
return maybeCamel;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return undefined;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function pickToolIsError(parts: MessagePartRecord[]): boolean | undefined {
|
|
452
|
+
for (const part of parts) {
|
|
453
|
+
const decoded = parseJson(part.metadata);
|
|
454
|
+
if (!decoded || typeof decoded !== "object") {
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
const metadataIsError = (decoded as { isError?: unknown }).isError;
|
|
458
|
+
if (typeof metadataIsError === "boolean") {
|
|
459
|
+
return metadataIsError;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return undefined;
|
|
463
|
+
}
|
|
464
|
+
|
|
414
465
|
/** Format a Date for XML attributes in the agent's timezone. */
|
|
415
466
|
function formatDateForAttribute(date: Date, timezone?: string): string {
|
|
416
467
|
const tz = timezone ?? "UTC";
|
|
@@ -674,12 +725,15 @@ export class ContextAssembler {
|
|
|
674
725
|
|
|
675
726
|
const parts = await this.conversationStore.getMessageParts(msg.messageId);
|
|
676
727
|
const roleFromStore = toRuntimeRole(msg.role, parts);
|
|
677
|
-
const
|
|
728
|
+
const isToolResult = roleFromStore === "toolResult";
|
|
729
|
+
const toolCallId = isToolResult ? pickToolCallId(parts) : undefined;
|
|
730
|
+
const toolName = isToolResult ? (pickToolName(parts) ?? "unknown") : undefined;
|
|
731
|
+
const toolIsError = isToolResult ? pickToolIsError(parts) : undefined;
|
|
678
732
|
// Tool results without a call id cannot be serialized for Anthropic-compatible APIs.
|
|
679
733
|
// This happens for legacy/bootstrap rows that have role=tool but no message_parts.
|
|
680
734
|
// Preserve the text by degrading to assistant content instead of emitting invalid toolResult.
|
|
681
735
|
const role: "user" | "assistant" | "toolResult" =
|
|
682
|
-
|
|
736
|
+
isToolResult && !toolCallId ? "assistant" : roleFromStore;
|
|
683
737
|
const content = contentFromParts(parts, role, msg.content);
|
|
684
738
|
const contentText =
|
|
685
739
|
typeof content === "string" ? content : (JSON.stringify(content) ?? msg.content);
|
|
@@ -713,6 +767,8 @@ export class ContextAssembler {
|
|
|
713
767
|
role,
|
|
714
768
|
content,
|
|
715
769
|
...(toolCallId ? { toolCallId } : {}),
|
|
770
|
+
...(toolName ? { toolName } : {}),
|
|
771
|
+
...(role === "toolResult" && toolIsError !== undefined ? { isError: toolIsError } : {}),
|
|
716
772
|
} as AgentMessage),
|
|
717
773
|
tokens: tokenCount,
|
|
718
774
|
isMessage: true,
|
package/src/compaction.ts
CHANGED
|
@@ -86,7 +86,7 @@ function estimateTokens(content: string): number {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/** Format a timestamp as `YYYY-MM-DD HH:mm TZ` for prompt source text. */
|
|
89
|
-
function formatTimestamp(value: Date, timezone: string = "UTC"): string {
|
|
89
|
+
export function formatTimestamp(value: Date, timezone: string = "UTC"): string {
|
|
90
90
|
try {
|
|
91
91
|
const fmt = new Intl.DateTimeFormat("en-CA", {
|
|
92
92
|
timeZone: timezone,
|
package/src/engine.ts
CHANGED
|
@@ -60,6 +60,10 @@ function safeString(value: unknown): string | undefined {
|
|
|
60
60
|
return typeof value === "string" ? value : undefined;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function safeBoolean(value: unknown): boolean | undefined {
|
|
64
|
+
return typeof value === "boolean" ? value : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
63
67
|
function appendTextValue(value: unknown, out: string[]): void {
|
|
64
68
|
if (typeof value === "string") {
|
|
65
69
|
out.push(value);
|
|
@@ -264,6 +268,12 @@ function buildMessageParts(params: {
|
|
|
264
268
|
safeString(topLevel.tool_use_id) ??
|
|
265
269
|
safeString(topLevel.call_id) ??
|
|
266
270
|
safeString(topLevel.id);
|
|
271
|
+
const topLevelToolName =
|
|
272
|
+
safeString(topLevel.toolName) ??
|
|
273
|
+
safeString(topLevel.tool_name);
|
|
274
|
+
const topLevelIsError =
|
|
275
|
+
safeBoolean(topLevel.isError) ??
|
|
276
|
+
safeBoolean(topLevel.is_error);
|
|
267
277
|
|
|
268
278
|
// BashExecutionMessage: preserve a synthetic text part so output is round-trippable.
|
|
269
279
|
if (!("content" in message) && "command" in message && "output" in message) {
|
|
@@ -307,6 +317,9 @@ function buildMessageParts(params: {
|
|
|
307
317
|
textContent: message.content,
|
|
308
318
|
metadata: toJson({
|
|
309
319
|
originalRole: role,
|
|
320
|
+
toolCallId: topLevelToolCallId,
|
|
321
|
+
toolName: topLevelToolName,
|
|
322
|
+
isError: topLevelIsError,
|
|
310
323
|
}),
|
|
311
324
|
},
|
|
312
325
|
];
|
|
@@ -351,7 +364,8 @@ function buildMessageParts(params: {
|
|
|
351
364
|
toolName:
|
|
352
365
|
safeString(metadataRecord?.name) ??
|
|
353
366
|
safeString(metadataRecord?.toolName) ??
|
|
354
|
-
safeString(metadataRecord?.tool_name)
|
|
367
|
+
safeString(metadataRecord?.tool_name) ??
|
|
368
|
+
topLevelToolName,
|
|
355
369
|
toolInput:
|
|
356
370
|
metadataRecord?.input !== undefined
|
|
357
371
|
? toJson(metadataRecord.input)
|
|
@@ -368,6 +382,9 @@ function buildMessageParts(params: {
|
|
|
368
382
|
: (safeString(metadataRecord?.tool_output) ?? null),
|
|
369
383
|
metadata: toJson({
|
|
370
384
|
originalRole: role,
|
|
385
|
+
toolCallId: topLevelToolCallId,
|
|
386
|
+
toolName: topLevelToolName,
|
|
387
|
+
isError: topLevelIsError,
|
|
371
388
|
rawType: block.type,
|
|
372
389
|
raw: metadataRecord ?? message.content[ordinal],
|
|
373
390
|
}),
|
|
@@ -563,6 +580,12 @@ export class LcmContextEngine implements ContextEngine {
|
|
|
563
580
|
};
|
|
564
581
|
|
|
565
582
|
private config: LcmConfig;
|
|
583
|
+
|
|
584
|
+
/** Get the configured timezone, falling back to system timezone. */
|
|
585
|
+
get timezone(): string {
|
|
586
|
+
return this.config.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
587
|
+
}
|
|
588
|
+
|
|
566
589
|
private conversationStore: ConversationStore;
|
|
567
590
|
private summaryStore: SummaryStore;
|
|
568
591
|
private assembler: ContextAssembler;
|
|
@@ -8,6 +8,7 @@ import type { LcmDependencies } from "../types.js";
|
|
|
8
8
|
import type { AnyAgentTool } from "./common.js";
|
|
9
9
|
import { jsonResult } from "./common.js";
|
|
10
10
|
import { resolveLcmConversationScope } from "./lcm-conversation-scope.js";
|
|
11
|
+
import { formatTimestamp } from "../compaction.js";
|
|
11
12
|
|
|
12
13
|
const LcmDescribeSchema = Type.Object({
|
|
13
14
|
id: Type.String({
|
|
@@ -40,8 +41,12 @@ function normalizeRequestedTokenCap(value: unknown): number | undefined {
|
|
|
40
41
|
return Math.max(1, Math.trunc(value));
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
function formatIso(value: Date | null | undefined): string {
|
|
44
|
-
|
|
44
|
+
function formatIso(value: Date | null | undefined, timezone?: string): string {
|
|
45
|
+
if (!(value instanceof Date)) return "-";
|
|
46
|
+
if (timezone) {
|
|
47
|
+
return formatTimestamp(value, timezone);
|
|
48
|
+
}
|
|
49
|
+
return value.toISOString();
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
export function createLcmDescribeTool(input: {
|
|
@@ -61,6 +66,7 @@ export function createLcmDescribeTool(input: {
|
|
|
61
66
|
parameters: LcmDescribeSchema,
|
|
62
67
|
async execute(_toolCallId, params) {
|
|
63
68
|
const retrieval = input.lcm.getRetrieval();
|
|
69
|
+
const timezone = input.lcm.timezone;
|
|
64
70
|
const p = params as Record<string, unknown>;
|
|
65
71
|
const id = (p.id as string).trim();
|
|
66
72
|
const conversationScope = await resolveLcmConversationScope({
|
|
@@ -152,7 +158,7 @@ export function createLcmDescribeTool(input: {
|
|
|
152
158
|
lines.push(
|
|
153
159
|
`meta conv=${s.conversationId} kind=${s.kind} depth=${s.depth} tok=${s.tokenCount} ` +
|
|
154
160
|
`descTok=${s.descendantTokenCount} srcTok=${s.sourceMessageTokenCount} ` +
|
|
155
|
-
`desc=${s.descendantCount} range=${formatIso(s.earliestAt)}..${formatIso(s.latestAt)} ` +
|
|
161
|
+
`desc=${s.descendantCount} range=${formatIso(s.earliestAt, timezone)}..${formatIso(s.latestAt, timezone)} ` +
|
|
156
162
|
`budgetCap=${resolvedTokenCap}`,
|
|
157
163
|
);
|
|
158
164
|
if (s.parentIds.length > 0) {
|
|
@@ -167,7 +173,7 @@ export function createLcmDescribeTool(input: {
|
|
|
167
173
|
`d${node.depthFromRoot} ${node.summaryId} k=${node.kind} tok=${node.tokenCount} ` +
|
|
168
174
|
`descTok=${node.descendantTokenCount} srcTok=${node.sourceMessageTokenCount} ` +
|
|
169
175
|
`desc=${node.descendantCount} child=${node.childCount} ` +
|
|
170
|
-
`range=${formatIso(node.earliestAt)}..${formatIso(node.latestAt)} ` +
|
|
176
|
+
`range=${formatIso(node.earliestAt, timezone)}..${formatIso(node.latestAt, timezone)} ` +
|
|
171
177
|
`cost[s=${node.costs.summariesOnly},m=${node.costs.withMessages}] ` +
|
|
172
178
|
`budget[s=${node.budgetFit.summariesOnly ? "in" : "over"},` +
|
|
173
179
|
`m=${node.budgetFit.withMessages ? "in" : "over"}]`,
|
|
@@ -205,7 +211,7 @@ export function createLcmDescribeTool(input: {
|
|
|
205
211
|
if (f.byteSize != null) {
|
|
206
212
|
lines.push(`**Size:** ${f.byteSize.toLocaleString()} bytes`);
|
|
207
213
|
}
|
|
208
|
-
lines.push(`**Created:** ${f.createdAt
|
|
214
|
+
lines.push(`**Created:** ${formatIso(f.createdAt, timezone)}`);
|
|
209
215
|
if (f.explorationSummary) {
|
|
210
216
|
lines.push("");
|
|
211
217
|
lines.push("## Exploration Summary");
|
|
@@ -4,6 +4,7 @@ import type { LcmDependencies } from "../types.js";
|
|
|
4
4
|
import type { AnyAgentTool } from "./common.js";
|
|
5
5
|
import { jsonResult } from "./common.js";
|
|
6
6
|
import { parseIsoTimestampParam, resolveLcmConversationScope } from "./lcm-conversation-scope.js";
|
|
7
|
+
import { formatTimestamp } from "../compaction.js";
|
|
7
8
|
|
|
8
9
|
const MAX_RESULT_CHARS = 40_000; // ~10k tokens
|
|
9
10
|
|
|
@@ -83,6 +84,7 @@ export function createLcmGrepTool(input: {
|
|
|
83
84
|
parameters: LcmGrepSchema,
|
|
84
85
|
async execute(_toolCallId, params) {
|
|
85
86
|
const retrieval = input.lcm.getRetrieval();
|
|
87
|
+
const timezone = input.lcm.timezone;
|
|
86
88
|
|
|
87
89
|
const p = params as Record<string, unknown>;
|
|
88
90
|
const pattern = (p.pattern as string).trim();
|
|
@@ -139,8 +141,8 @@ export function createLcmGrepTool(input: {
|
|
|
139
141
|
}
|
|
140
142
|
if (since || before) {
|
|
141
143
|
lines.push(
|
|
142
|
-
`**Time filter:** ${since ? `since ${since
|
|
143
|
-
before ? `before ${before
|
|
144
|
+
`**Time filter:** ${since ? `since ${formatTimestamp(since, timezone)}` : "since -∞"} | ${
|
|
145
|
+
before ? `before ${formatTimestamp(before, timezone)}` : "before +∞"
|
|
144
146
|
}`,
|
|
145
147
|
);
|
|
146
148
|
}
|
|
@@ -154,7 +156,7 @@ export function createLcmGrepTool(input: {
|
|
|
154
156
|
lines.push("");
|
|
155
157
|
for (const msg of result.messages) {
|
|
156
158
|
const snippet = truncateSnippet(msg.snippet);
|
|
157
|
-
const line = `- [msg#${msg.messageId}] (${msg.role}, ${msg.createdAt
|
|
159
|
+
const line = `- [msg#${msg.messageId}] (${msg.role}, ${formatTimestamp(msg.createdAt, timezone)}): ${snippet}`;
|
|
158
160
|
if (currentChars + line.length > MAX_RESULT_CHARS) {
|
|
159
161
|
lines.push("*(truncated — more results available)*");
|
|
160
162
|
break;
|
|
@@ -170,7 +172,7 @@ export function createLcmGrepTool(input: {
|
|
|
170
172
|
lines.push("");
|
|
171
173
|
for (const sum of result.summaries) {
|
|
172
174
|
const snippet = truncateSnippet(sum.snippet);
|
|
173
|
-
const line = `- [${sum.summaryId}] (${sum.kind}, ${sum.createdAt
|
|
175
|
+
const line = `- [${sum.summaryId}] (${sum.kind}, ${formatTimestamp(sum.createdAt, timezone)}): ${snippet}`;
|
|
174
176
|
if (currentChars + line.length > MAX_RESULT_CHARS) {
|
|
175
177
|
lines.push("*(truncated — more results available)*");
|
|
176
178
|
break;
|