@martian-engineering/lossless-claw 0.1.4 → 0.1.5

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
@@ -49,6 +49,13 @@ type PluginEnvSnapshot = {
49
49
 
50
50
  type ReadEnvFn = (key: string) => string | undefined;
51
51
 
52
+ type CompleteSimpleOptions = {
53
+ apiKey?: string;
54
+ maxTokens: number;
55
+ temperature?: number;
56
+ reasoning?: string;
57
+ };
58
+
52
59
  /** Capture plugin env values once during initialization. */
53
60
  function snapshotPluginEnv(env: NodeJS.ProcessEnv = process.env): PluginEnvSnapshot {
54
61
  return {
@@ -130,13 +137,17 @@ type PiAiModule = {
130
137
  contextWindow?: number;
131
138
  maxTokens?: number;
132
139
  },
133
- request: { messages: Array<{ role: string; content: unknown; timestamp?: number }> },
140
+ request: {
141
+ systemPrompt?: string;
142
+ messages: Array<{ role: string; content: unknown; timestamp?: number }>;
143
+ },
134
144
  options: {
135
145
  apiKey?: string;
136
146
  maxTokens: number;
137
147
  temperature?: number;
148
+ reasoning?: string;
138
149
  },
139
- ) => Promise<{ content?: Array<{ type: string; text?: string }> }>;
150
+ ) => Promise<Record<string, unknown> & { content?: Array<{ type: string; text?: string }> }>;
140
151
  getModel?: (provider: string, modelId: string) => unknown;
141
152
  getModels?: (provider: string) => unknown[];
142
153
  getEnvApiKey?: (provider: string) => string | undefined;
@@ -173,6 +184,39 @@ function inferApiFromProvider(provider: string): string {
173
184
  return map[normalized] ?? "openai-responses";
174
185
  }
175
186
 
187
+ /** Codex Responses rejects `temperature`; omit it for that API family. */
188
+ export function shouldOmitTemperatureForApi(api: string | undefined): boolean {
189
+ return (api ?? "").trim().toLowerCase() === "openai-codex-responses";
190
+ }
191
+
192
+ /** Build provider-aware options for pi-ai completeSimple. */
193
+ export function buildCompleteSimpleOptions(params: {
194
+ api: string | undefined;
195
+ apiKey: string | undefined;
196
+ maxTokens: number;
197
+ temperature: number | undefined;
198
+ reasoning: string | undefined;
199
+ }): CompleteSimpleOptions {
200
+ const options: CompleteSimpleOptions = {
201
+ apiKey: params.apiKey,
202
+ maxTokens: params.maxTokens,
203
+ };
204
+
205
+ if (
206
+ typeof params.temperature === "number" &&
207
+ Number.isFinite(params.temperature) &&
208
+ !shouldOmitTemperatureForApi(params.api)
209
+ ) {
210
+ options.temperature = params.temperature;
211
+ }
212
+
213
+ if (typeof params.reasoning === "string" && params.reasoning.trim()) {
214
+ options.reasoning = params.reasoning.trim();
215
+ }
216
+
217
+ return options;
218
+ }
219
+
176
220
  /** Select provider-specific config values with case-insensitive provider keys. */
177
221
  function findProviderConfigValue<T>(
178
222
  map: Record<string, T> | undefined,
@@ -566,8 +610,10 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
566
610
  agentDir,
567
611
  runtimeConfig,
568
612
  messages,
613
+ system,
569
614
  maxTokens,
570
615
  temperature,
616
+ reasoning,
571
617
  }) => {
572
618
  try {
573
619
  const piAiModuleId = "@mariozechner/pi-ai";
@@ -644,24 +690,62 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
644
690
  });
645
691
  }
646
692
 
693
+ const completeOptions = buildCompleteSimpleOptions({
694
+ api: resolvedModel.api,
695
+ apiKey: resolvedApiKey,
696
+ maxTokens,
697
+ temperature,
698
+ reasoning,
699
+ });
700
+
647
701
  const result = await mod.completeSimple(
648
702
  resolvedModel,
649
703
  {
704
+ ...(typeof system === "string" && system.trim()
705
+ ? { systemPrompt: system.trim() }
706
+ : {}),
650
707
  messages: messages.map((message) => ({
651
708
  role: message.role,
652
709
  content: message.content,
653
710
  timestamp: Date.now(),
654
711
  })),
655
712
  },
656
- {
657
- apiKey: resolvedApiKey,
658
- maxTokens,
659
- temperature,
660
- },
713
+ completeOptions,
661
714
  );
662
715
 
716
+ if (!isRecord(result)) {
717
+ return {
718
+ content: [],
719
+ request_provider: providerId,
720
+ request_model: modelId,
721
+ request_api: resolvedModel.api,
722
+ request_reasoning:
723
+ typeof reasoning === "string" && reasoning.trim() ? reasoning.trim() : "(none)",
724
+ request_has_system:
725
+ typeof system === "string" && system.trim().length > 0 ? "true" : "false",
726
+ request_temperature:
727
+ typeof completeOptions.temperature === "number"
728
+ ? String(completeOptions.temperature)
729
+ : "(omitted)",
730
+ request_temperature_sent:
731
+ typeof completeOptions.temperature === "number" ? "true" : "false",
732
+ };
733
+ }
734
+
663
735
  return {
664
- content: Array.isArray(result?.content) ? result.content : [],
736
+ ...result,
737
+ content: Array.isArray(result.content) ? result.content : [],
738
+ request_provider: providerId,
739
+ request_model: modelId,
740
+ request_api: resolvedModel.api,
741
+ request_reasoning:
742
+ typeof reasoning === "string" && reasoning.trim() ? reasoning.trim() : "(none)",
743
+ request_has_system: typeof system === "string" && system.trim().length > 0 ? "true" : "false",
744
+ request_temperature:
745
+ typeof completeOptions.temperature === "number"
746
+ ? String(completeOptions.temperature)
747
+ : "(omitted)",
748
+ request_temperature_sent: typeof completeOptions.temperature === "number" ? "true" : "false",
665
749
  };
666
750
  } catch (err) {
667
751
  console.error(`[lcm] completeSimple error:`, err instanceof Error ? err.message : err);
@@ -715,8 +799,8 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
715
799
  }
716
800
 
717
801
  const provider = (
718
- providerHint?.trim() ||
719
802
  envSnapshot.lcmSummaryProvider ||
803
+ providerHint?.trim() ||
720
804
  envSnapshot.openclawProvider ||
721
805
  "openai"
722
806
  ).trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martian-engineering/lossless-claw",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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",
package/src/summarize.ts CHANGED
@@ -24,6 +24,14 @@ export type LcmSummarizerLegacyParams = {
24
24
  type SummaryMode = "normal" | "aggressive";
25
25
 
26
26
  const DEFAULT_CONDENSED_TARGET_TOKENS = 2000;
27
+ const LCM_SUMMARIZER_SYSTEM_PROMPT =
28
+ "You are a context-compaction summarization engine. Follow user instructions exactly and return plain text summary content only.";
29
+ const DIAGNOSTIC_MAX_DEPTH = 4;
30
+ const DIAGNOSTIC_MAX_ARRAY_ITEMS = 8;
31
+ const DIAGNOSTIC_MAX_OBJECT_KEYS = 16;
32
+ const DIAGNOSTIC_MAX_CHARS = 1200;
33
+ const DIAGNOSTIC_SENSITIVE_KEY_PATTERN =
34
+ /(api[-_]?key|authorization|token|secret|password|cookie|set-cookie|private[-_]?key|bearer)/i;
27
35
 
28
36
  /** Normalize provider ids for stable config/profile lookup. */
29
37
  function normalizeProviderId(provider: string): string {
@@ -193,6 +201,202 @@ function formatBlockTypes(blockTypes: string[]): string {
193
201
  return blockTypes.join(",");
194
202
  }
195
203
 
204
+ /** Truncate long diagnostic text values to keep logs bounded and readable. */
205
+ function truncateDiagnosticText(value: string, maxChars = DIAGNOSTIC_MAX_CHARS): string {
206
+ if (value.length <= maxChars) {
207
+ return value;
208
+ }
209
+ return `${value.slice(0, maxChars)}...[truncated:${value.length - maxChars} chars]`;
210
+ }
211
+
212
+ /** Build a JSON-safe, redacted, depth-limited clone for diagnostic logging. */
213
+ function sanitizeForDiagnostics(value: unknown, depth = 0): unknown {
214
+ if (depth >= DIAGNOSTIC_MAX_DEPTH) {
215
+ return "[max-depth]";
216
+ }
217
+ if (typeof value === "string") {
218
+ return truncateDiagnosticText(value);
219
+ }
220
+ if (
221
+ value === null ||
222
+ typeof value === "number" ||
223
+ typeof value === "boolean" ||
224
+ typeof value === "bigint"
225
+ ) {
226
+ return value;
227
+ }
228
+ if (value === undefined) {
229
+ return "[undefined]";
230
+ }
231
+ if (typeof value === "function") {
232
+ return "[function]";
233
+ }
234
+ if (typeof value === "symbol") {
235
+ return "[symbol]";
236
+ }
237
+ if (Array.isArray(value)) {
238
+ const head = value
239
+ .slice(0, DIAGNOSTIC_MAX_ARRAY_ITEMS)
240
+ .map((entry) => sanitizeForDiagnostics(entry, depth + 1));
241
+ if (value.length > DIAGNOSTIC_MAX_ARRAY_ITEMS) {
242
+ head.push(`[+${value.length - DIAGNOSTIC_MAX_ARRAY_ITEMS} more items]`);
243
+ }
244
+ return head;
245
+ }
246
+ if (!isRecord(value)) {
247
+ return String(value);
248
+ }
249
+
250
+ const out: Record<string, unknown> = {};
251
+ const entries = Object.entries(value);
252
+ for (const [key, entry] of entries.slice(0, DIAGNOSTIC_MAX_OBJECT_KEYS)) {
253
+ out[key] = DIAGNOSTIC_SENSITIVE_KEY_PATTERN.test(key)
254
+ ? "[redacted]"
255
+ : sanitizeForDiagnostics(entry, depth + 1);
256
+ }
257
+ if (entries.length > DIAGNOSTIC_MAX_OBJECT_KEYS) {
258
+ out.__truncated_keys__ = entries.length - DIAGNOSTIC_MAX_OBJECT_KEYS;
259
+ }
260
+ return out;
261
+ }
262
+
263
+ /** Encode diagnostic payloads in a compact JSON string with safety guards. */
264
+ function formatDiagnosticPayload(value: unknown): string {
265
+ try {
266
+ const json = JSON.stringify(sanitizeForDiagnostics(value));
267
+ if (!json) {
268
+ return "\"\"";
269
+ }
270
+ return truncateDiagnosticText(json);
271
+ } catch {
272
+ return "\"[unserializable]\"";
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Extract safe diagnostic metadata from a provider response envelope.
278
+ *
279
+ * Picks common metadata fields (request id, model echo, usage counters) without
280
+ * leaking secrets like API keys or auth tokens. The result object from
281
+ * `deps.complete` is typed narrowly but real provider responses carry extra
282
+ * fields that are useful for debugging empty-summary incidents.
283
+ */
284
+ function extractResponseDiagnostics(result: unknown): string {
285
+ if (!isRecord(result)) {
286
+ return "";
287
+ }
288
+
289
+ const parts: string[] = [];
290
+
291
+ // Envelope-shape diagnostics for empty-block incidents.
292
+ const topLevelKeys = Object.keys(result).slice(0, 24);
293
+ if (topLevelKeys.length > 0) {
294
+ parts.push(`keys=${topLevelKeys.join(",")}`);
295
+ }
296
+ if ("content" in result) {
297
+ const contentVal = result.content;
298
+ if (Array.isArray(contentVal)) {
299
+ parts.push(`content_kind=array`);
300
+ parts.push(`content_len=${contentVal.length}`);
301
+ } else if (contentVal === null) {
302
+ parts.push(`content_kind=null`);
303
+ } else {
304
+ parts.push(`content_kind=${typeof contentVal}`);
305
+ }
306
+ parts.push(`content_preview=${formatDiagnosticPayload(contentVal)}`);
307
+ } else {
308
+ parts.push("content_kind=missing");
309
+ }
310
+
311
+ // Preview common non-content payload envelopes used by provider SDKs.
312
+ const envelopePayload: Record<string, unknown> = {};
313
+ for (const key of ["summary", "output", "message", "response"]) {
314
+ if (key in result) {
315
+ envelopePayload[key] = result[key];
316
+ }
317
+ }
318
+ if (Object.keys(envelopePayload).length > 0) {
319
+ parts.push(`payload_preview=${formatDiagnosticPayload(envelopePayload)}`);
320
+ }
321
+
322
+ // Request / response id — present in most provider envelopes.
323
+ for (const key of ["id", "request_id", "x-request-id"]) {
324
+ const val = result[key];
325
+ if (typeof val === "string" && val.trim()) {
326
+ parts.push(`${key}=${val.trim()}`);
327
+ }
328
+ }
329
+
330
+ // Model echo — useful when the provider selects a different checkpoint.
331
+ if (typeof result.model === "string" && result.model.trim()) {
332
+ parts.push(`resp_model=${result.model.trim()}`);
333
+ }
334
+ if (typeof result.provider === "string" && result.provider.trim()) {
335
+ parts.push(`resp_provider=${result.provider.trim()}`);
336
+ }
337
+ for (const key of [
338
+ "request_provider",
339
+ "request_model",
340
+ "request_api",
341
+ "request_reasoning",
342
+ "request_has_system",
343
+ "request_temperature",
344
+ "request_temperature_sent",
345
+ ]) {
346
+ const val = result[key];
347
+ if (typeof val === "string" && val.trim()) {
348
+ parts.push(`${key}=${val.trim()}`);
349
+ }
350
+ }
351
+
352
+ // Usage counters — safe numeric diagnostics.
353
+ if (isRecord(result.usage)) {
354
+ const u = result.usage;
355
+ const tokens: string[] = [];
356
+ for (const k of [
357
+ "prompt_tokens",
358
+ "completion_tokens",
359
+ "total_tokens",
360
+ "input",
361
+ "output",
362
+ "cacheRead",
363
+ "cacheWrite",
364
+ ]) {
365
+ if (typeof u[k] === "number") {
366
+ tokens.push(`${k}=${u[k]}`);
367
+ }
368
+ }
369
+ if (tokens.length > 0) {
370
+ parts.push(tokens.join(","));
371
+ }
372
+ }
373
+
374
+ // Finish reason — helps explain empty content.
375
+ const finishReason =
376
+ typeof result.finish_reason === "string"
377
+ ? result.finish_reason
378
+ : typeof result.stopReason === "string"
379
+ ? result.stopReason
380
+ : typeof result.stop_reason === "string"
381
+ ? result.stop_reason
382
+ : undefined;
383
+ if (finishReason) {
384
+ parts.push(`finish=${finishReason}`);
385
+ }
386
+
387
+ // Provider-level error payloads (most useful when finish=error and content is empty).
388
+ const errorMessage = result.errorMessage;
389
+ if (typeof errorMessage === "string" && errorMessage.trim()) {
390
+ parts.push(`error_message=${truncateDiagnosticText(errorMessage.trim(), 400)}`);
391
+ }
392
+ const errorPayload = result.error;
393
+ if (errorPayload !== undefined) {
394
+ parts.push(`error_preview=${formatDiagnosticPayload(errorPayload)}`);
395
+ }
396
+
397
+ return parts.join("; ");
398
+ }
399
+
196
400
  /**
197
401
  * Resolve a practical target token count for leaf and condensed summaries.
198
402
  * Aggressive leaf mode intentionally aims lower so compaction converges faster.
@@ -522,6 +726,7 @@ export async function createLcmSummarizeFromLegacyParams(params: {
522
726
  authProfileId,
523
727
  agentDir,
524
728
  runtimeConfig: params.legacyParams.config,
729
+ system: LCM_SUMMARIZER_SYSTEM_PROMPT,
525
730
  messages: [
526
731
  {
527
732
  role: "user",
@@ -533,17 +738,111 @@ export async function createLcmSummarizeFromLegacyParams(params: {
533
738
  });
534
739
 
535
740
  const normalized = normalizeCompletionSummary(result.content);
536
- const summary = normalized.summary;
741
+ let summary = normalized.summary;
742
+ let summarySource: "content" | "envelope" | "retry" | "fallback" = "content";
537
743
 
744
+ // --- Empty-summary hardening: envelope → retry → deterministic fallback ---
538
745
  if (!summary) {
746
+ // Envelope-aware extraction: some providers place summary text in
747
+ // top-level response fields (output, message, response) rather than
748
+ // inside the content array. Re-run normalization against the full
749
+ // response envelope before spending an API call on a retry.
750
+ const envelopeNormalized = normalizeCompletionSummary(result);
751
+ if (envelopeNormalized.summary) {
752
+ summary = envelopeNormalized.summary;
753
+ summarySource = "envelope";
754
+ console.error(
755
+ `[lcm] recovered summary from response envelope; provider=${provider}; model=${model}; ` +
756
+ `block_types=${formatBlockTypes(envelopeNormalized.blockTypes)}; source=envelope`,
757
+ );
758
+ }
759
+ }
760
+
761
+ if (!summary) {
762
+ const responseDiag = extractResponseDiagnostics(result);
763
+ const diagParts = [
764
+ `[lcm] empty normalized summary on first attempt`,
765
+ `provider=${provider}`,
766
+ `model=${model}`,
767
+ `block_types=${formatBlockTypes(normalized.blockTypes)}`,
768
+ `response_blocks=${result.content.length}`,
769
+ ];
770
+ if (responseDiag) {
771
+ diagParts.push(responseDiag);
772
+ }
773
+ console.error(`${diagParts.join("; ")}; retrying with conservative settings`);
774
+
775
+ // Single retry with conservative parameters: low temperature and low
776
+ // reasoning budget to coax a textual response from providers that
777
+ // sometimes return reasoning-only or empty blocks on the first pass.
778
+ try {
779
+ const retryResult = await params.deps.complete({
780
+ provider,
781
+ model,
782
+ apiKey,
783
+ providerApi,
784
+ authProfileId,
785
+ agentDir,
786
+ runtimeConfig: params.legacyParams.config,
787
+ system: LCM_SUMMARIZER_SYSTEM_PROMPT,
788
+ messages: [
789
+ {
790
+ role: "user",
791
+ content: prompt,
792
+ },
793
+ ],
794
+ maxTokens: targetTokens,
795
+ temperature: 0.05,
796
+ reasoning: "low",
797
+ });
798
+
799
+ const retryNormalized = normalizeCompletionSummary(retryResult.content);
800
+ summary = retryNormalized.summary;
801
+
802
+ if (summary) {
803
+ summarySource = "retry";
804
+ console.error(
805
+ `[lcm] retry succeeded; provider=${provider}; model=${model}; ` +
806
+ `block_types=${formatBlockTypes(retryNormalized.blockTypes)}; source=retry`,
807
+ );
808
+ } else {
809
+ const retryDiag = extractResponseDiagnostics(retryResult);
810
+ const retryParts = [
811
+ `[lcm] retry also returned empty summary`,
812
+ `provider=${provider}`,
813
+ `model=${model}`,
814
+ `block_types=${formatBlockTypes(retryNormalized.blockTypes)}`,
815
+ `response_blocks=${retryResult.content.length}`,
816
+ ];
817
+ if (retryDiag) {
818
+ retryParts.push(retryDiag);
819
+ }
820
+ console.error(`${retryParts.join("; ")}; falling back to truncation`);
821
+ }
822
+ } catch (retryErr) {
823
+ // Retry is best-effort; log and proceed to deterministic fallback.
824
+ console.error(
825
+ `[lcm] retry failed; provider=${provider} model=${model}; error=${
826
+ retryErr instanceof Error ? retryErr.message : String(retryErr)
827
+ }; falling back to truncation`,
828
+ );
829
+ }
830
+ }
831
+
832
+ if (!summary) {
833
+ summarySource = "fallback";
539
834
  console.error(
540
- `[lcm] summarize empty normalized summary; provider=${provider} model=${model} block_types=${formatBlockTypes(
541
- normalized.blockTypes,
542
- )}; response_blocks=${result.content.length}; falling back to truncation`,
835
+ `[lcm] all extraction attempts exhausted; provider=${provider}; model=${model}; source=fallback`,
543
836
  );
544
837
  return buildDeterministicFallbackSummary(text, targetTokens);
545
838
  }
546
839
 
840
+ if (summarySource !== "content") {
841
+ console.error(
842
+ `[lcm] summary resolved via non-content path; provider=${provider}; model=${model}; source=${summarySource}`,
843
+ );
844
+ }
845
+
547
846
  return summary;
548
847
  };
549
848
  }
package/src/types.ts CHANGED
@@ -11,6 +11,17 @@ import type { LcmConfig } from "./db/config.js";
11
11
  * Minimal LLM completion interface needed by LCM for summarization.
12
12
  * Matches the signature of completeSimple from @mariozechner/pi-ai.
13
13
  */
14
+ export type CompletionContentBlock = {
15
+ type: string;
16
+ text?: string;
17
+ [key: string]: unknown;
18
+ };
19
+
20
+ export type CompletionResult = {
21
+ content: CompletionContentBlock[];
22
+ [key: string]: unknown;
23
+ };
24
+
14
25
  export type CompleteFn = (params: {
15
26
  provider?: string;
16
27
  model: string;
@@ -24,7 +35,7 @@ export type CompleteFn = (params: {
24
35
  maxTokens: number;
25
36
  temperature?: number;
26
37
  reasoning?: string;
27
- }) => Promise<{ content: Array<{ type: string; text?: string }> }>;
38
+ }) => Promise<CompletionResult>;
28
39
 
29
40
  /**
30
41
  * Gateway RPC call interface.