@martian-engineering/lossless-claw 0.2.8 → 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 CHANGED
@@ -156,9 +156,22 @@ function resolveApiKey(provider: string, readEnv: ReadEnvFn): string | undefined
156
156
  return undefined;
157
157
  }
158
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
+
159
172
  type AuthProfileCredential =
160
- | { type: "api_key"; provider: string; key?: string; email?: string }
161
- | { type: "token"; provider: string; token?: string; expires?: number; email?: string }
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 }
162
175
  | ({
163
176
  type: "oauth";
164
177
  provider: string;
@@ -500,12 +513,102 @@ function resolveAuthProfileCandidates(params: {
500
513
  return candidates;
501
514
  }
502
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
+
503
605
  /** Resolve OAuth/api-key/token credentials from auth-profiles store. */
504
606
  async function resolveApiKeyFromAuthProfiles(params: {
505
607
  provider: string;
506
608
  authProfileId?: string;
507
609
  agentDir?: string;
508
610
  runtimeConfig?: unknown;
611
+ appConfig?: unknown;
509
612
  piAiModule: PiAiModule;
510
613
  envSnapshot: PluginEnvSnapshot;
511
614
  }): Promise<string | undefined> {
@@ -543,6 +646,17 @@ async function resolveApiKeyFromAuthProfiles(params: {
543
646
 
544
647
  const persistPath =
545
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
+ })();
546
660
 
547
661
  for (const profileId of candidates) {
548
662
  const credential = mergedStore.profiles[profileId];
@@ -554,7 +668,13 @@ async function resolveApiKeyFromAuthProfiles(params: {
554
668
  }
555
669
 
556
670
  if (credential.type === "api_key") {
557
- const key = credential.key?.trim();
671
+ const key =
672
+ credential.key?.trim() ||
673
+ resolveSecretRef({
674
+ ref: credential.keyRef,
675
+ home: params.envSnapshot.home,
676
+ config: secretConfig,
677
+ });
558
678
  if (key) {
559
679
  return key;
560
680
  }
@@ -562,7 +682,13 @@ async function resolveApiKeyFromAuthProfiles(params: {
562
682
  }
563
683
 
564
684
  if (credential.type === "token") {
565
- const token = credential.token?.trim();
685
+ const token =
686
+ credential.token?.trim() ||
687
+ resolveSecretRef({
688
+ ref: credential.tokenRef,
689
+ home: params.envSnapshot.home,
690
+ config: secretConfig,
691
+ });
566
692
  if (!token) {
567
693
  continue;
568
694
  }
@@ -759,11 +885,23 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
759
885
  return { content: [] };
760
886
  }
761
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
+
762
900
  const knownModel =
763
901
  typeof mod.getModel === "function" ? mod.getModel(providerId, modelId) : undefined;
764
902
  const fallbackApi =
765
903
  providerApi?.trim() ||
766
- resolveProviderApiFromRuntimeConfig(runtimeConfig, providerId) ||
904
+ resolveProviderApiFromRuntimeConfig(effectiveRuntimeConfig, providerId) ||
767
905
  (() => {
768
906
  if (typeof mod.getModels !== "function") {
769
907
  return undefined;
@@ -777,6 +915,21 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
777
915
  })() ||
778
916
  inferApiFromProvider(providerId);
779
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
+
780
933
  const resolvedModel =
781
934
  isRecord(knownModel) &&
782
935
  typeof knownModel.api === "string" &&
@@ -787,6 +940,18 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
787
940
  id: knownModel.id,
788
941
  provider: knownModel.provider,
789
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
+ : {}),
790
955
  }
791
956
  : {
792
957
  id: modelId,
@@ -803,6 +968,14 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
803
968
  },
804
969
  contextWindow: 200_000,
805
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
+ : {}),
806
979
  };
807
980
 
808
981
  let resolvedApiKey = apiKey?.trim();
@@ -833,11 +1006,27 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
833
1006
  provider: providerId,
834
1007
  authProfileId,
835
1008
  agentDir,
836
- runtimeConfig,
1009
+ appConfig: api.config,
1010
+ runtimeConfig: effectiveRuntimeConfig,
837
1011
  piAiModule: mod,
838
1012
  envSnapshot,
839
1013
  });
840
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
+ }
841
1030
 
842
1031
  const completeOptions = buildCompleteSimpleOptions({
843
1032
  api: resolvedModel.api,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martian-engineering/lossless-claw",
3
- "version": "0.2.8",
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
- "test": "vitest run --dir test"
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 toolCallId = roleFromStore === "toolResult" ? pickToolCallId(parts) : undefined;
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
- roleFromStore === "toolResult" && !toolCallId ? "assistant" : roleFromStore;
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
- return value instanceof Date ? value.toISOString() : "-";
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.toISOString()}`);
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.toISOString()}` : "since -∞"} | ${
143
- before ? `before ${before.toISOString()}` : "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.toISOString()}): ${snippet}`;
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.toISOString()}): ${snippet}`;
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;