@martian-engineering/lossless-claw 0.4.0 → 0.5.1

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.
@@ -512,6 +512,25 @@ export function formatFileReference(input: {
512
512
  ].join("\n");
513
513
  }
514
514
 
515
+ export function formatToolOutputReference(input: {
516
+ fileId: string;
517
+ toolName?: string;
518
+ byteSize: number;
519
+ summary: string;
520
+ }): string {
521
+ const toolName = input.toolName?.trim() || "unknown";
522
+ const byteSize = Math.max(0, input.byteSize);
523
+
524
+ return [
525
+ `[LCM Tool Output: ${input.fileId} | tool=${toolName} | ${byteSize.toLocaleString("en-US")} bytes]`,
526
+ "",
527
+ "Exploration Summary:",
528
+ input.summary.trim() || "(no summary available)",
529
+ "",
530
+ "Use lcm_describe with the file id to inspect the full output.",
531
+ ].join("\n");
532
+ }
533
+
515
534
  export async function generateExplorationSummary(input: ExplorationSummaryInput): Promise<string> {
516
535
  const extension = extensionFromNameOrMime(input.fileName, input.mimeType);
517
536
 
@@ -100,6 +100,17 @@ type RuntimeModelAuth = {
100
100
  const MODEL_AUTH_PR_URL = "https://github.com/openclaw/openclaw/pull/41090";
101
101
  const MODEL_AUTH_MERGE_COMMIT = "4790e40";
102
102
  const MODEL_AUTH_REQUIRED_RELEASE = "the first OpenClaw release after 2026.3.8";
103
+ const AUTH_ERROR_TEXT_PATTERN =
104
+ /\b401\b|unauthorized|unauthorised|invalid[_ -]?token|invalid[_ -]?api[_ -]?key|authentication failed|authorization failed|missing scope|insufficient scope|model\.request\b/i;
105
+ const AUTH_ERROR_STATUS_KEYS = ["status", "statusCode", "status_code"] as const;
106
+ const AUTH_ERROR_NESTED_KEYS = ["error", "response", "cause", "details", "data", "body"] as const;
107
+
108
+ type CompletionBridgeErrorInfo = {
109
+ kind: "provider_auth";
110
+ statusCode?: number;
111
+ code?: string;
112
+ message?: string;
113
+ };
103
114
 
104
115
  /** Capture plugin env values once during initialization. */
105
116
  function snapshotPluginEnv(env: NodeJS.ProcessEnv = process.env): PluginEnvSnapshot {
@@ -115,6 +126,93 @@ function snapshotPluginEnv(env: NodeJS.ProcessEnv = process.env): PluginEnvSnaps
115
126
  };
116
127
  }
117
128
 
129
+ function truncateErrorMessage(message: string, maxChars = 240): string {
130
+ return message.length <= maxChars ? message : `${message.slice(0, maxChars)}...`;
131
+ }
132
+
133
+ function collectErrorText(value: unknown, out: string[], depth = 0): void {
134
+ if (depth >= 4) {
135
+ return;
136
+ }
137
+ if (typeof value === "string") {
138
+ const trimmed = value.trim();
139
+ if (trimmed) {
140
+ out.push(trimmed);
141
+ }
142
+ return;
143
+ }
144
+ if (Array.isArray(value)) {
145
+ for (const entry of value.slice(0, 8)) {
146
+ collectErrorText(entry, out, depth + 1);
147
+ }
148
+ return;
149
+ }
150
+ if (!isRecord(value)) {
151
+ return;
152
+ }
153
+
154
+ for (const entry of Object.values(value).slice(0, 12)) {
155
+ collectErrorText(entry, out, depth + 1);
156
+ }
157
+ }
158
+
159
+ function extractErrorStatusCode(value: unknown, depth = 0): number | undefined {
160
+ if (depth >= 4 || !isRecord(value)) {
161
+ return undefined;
162
+ }
163
+
164
+ for (const key of AUTH_ERROR_STATUS_KEYS) {
165
+ const candidate = value[key];
166
+ if (typeof candidate === "number" && Number.isFinite(candidate)) {
167
+ return Math.trunc(candidate);
168
+ }
169
+ if (typeof candidate === "string") {
170
+ const parsed = Number.parseInt(candidate, 10);
171
+ if (Number.isFinite(parsed)) {
172
+ return parsed;
173
+ }
174
+ }
175
+ }
176
+
177
+ for (const key of AUTH_ERROR_NESTED_KEYS) {
178
+ const nested = value[key];
179
+ const statusCode = extractErrorStatusCode(nested, depth + 1);
180
+ if (statusCode !== undefined) {
181
+ return statusCode;
182
+ }
183
+ }
184
+
185
+ return undefined;
186
+ }
187
+
188
+ function detectProviderAuthError(error: unknown): CompletionBridgeErrorInfo | undefined {
189
+ const statusCode = extractErrorStatusCode(error);
190
+ const textParts: string[] = [];
191
+ collectErrorText(error, textParts);
192
+ const normalizedMessage = textParts.join(" ").replace(/\s+/g, " ").trim();
193
+
194
+ if (statusCode !== 401 && !AUTH_ERROR_TEXT_PATTERN.test(normalizedMessage)) {
195
+ return undefined;
196
+ }
197
+
198
+ const directCode =
199
+ isRecord(error) && typeof error.code === "string" && error.code.trim()
200
+ ? error.code.trim()
201
+ : isRecord(error) &&
202
+ isRecord(error.error) &&
203
+ typeof error.error.code === "string" &&
204
+ error.error.code.trim()
205
+ ? error.error.code.trim()
206
+ : undefined;
207
+
208
+ return {
209
+ kind: "provider_auth",
210
+ ...(statusCode !== undefined ? { statusCode } : {}),
211
+ ...(directCode ? { code: directCode } : {}),
212
+ ...(normalizedMessage ? { message: truncateErrorMessage(normalizedMessage) } : {}),
213
+ };
214
+ }
215
+
118
216
  /** Read OpenClaw's configured default model from the validated runtime config. */
119
217
  function readDefaultModelFromConfig(config: unknown): string {
120
218
  if (!config || typeof config !== "object") {
@@ -740,6 +838,53 @@ async function resolveApiKeyFromAuthProfiles(params: {
740
838
  const expires = credential.expires;
741
839
  const isExpired =
742
840
  typeof expires === "number" && Number.isFinite(expires) && expires > 0 && Date.now() >= expires;
841
+ const shouldPreferOAuthHelper =
842
+ typeof params.piAiModule.getOAuthApiKey === "function" &&
843
+ normalizeProviderId(params.provider) === "openai-codex";
844
+
845
+ if (shouldPreferOAuthHelper) {
846
+ try {
847
+ const oauthCredential = {
848
+ access: credential.access ?? "",
849
+ refresh: credential.refresh ?? "",
850
+ expires: typeof credential.expires === "number" ? credential.expires : 0,
851
+ ...(typeof credential.projectId === "string" ? { projectId: credential.projectId } : {}),
852
+ ...(typeof credential.accountId === "string" ? { accountId: credential.accountId } : {}),
853
+ };
854
+ const refreshed = await params.piAiModule.getOAuthApiKey(params.provider, {
855
+ [params.provider]: oauthCredential,
856
+ });
857
+ if (refreshed?.apiKey) {
858
+ mergedStore.profiles[profileId] = {
859
+ ...credential,
860
+ ...refreshed.newCredentials,
861
+ type: "oauth",
862
+ };
863
+ if (persistPath) {
864
+ try {
865
+ writeFileSync(
866
+ persistPath,
867
+ JSON.stringify(
868
+ {
869
+ version: 1,
870
+ profiles: mergedStore.profiles,
871
+ ...(mergedStore.order ? { order: mergedStore.order } : {}),
872
+ },
873
+ null,
874
+ 2,
875
+ ),
876
+ "utf8",
877
+ );
878
+ } catch {
879
+ // Ignore persistence errors: refreshed credentials remain usable in-memory for this run.
880
+ }
881
+ }
882
+ return refreshed.apiKey;
883
+ }
884
+ } catch {
885
+ // Fall back to the cached access token below when helper resolution fails.
886
+ }
887
+ }
743
888
 
744
889
  if (!isExpired && access) {
745
890
  if (
@@ -1016,6 +1161,26 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1016
1161
  };
1017
1162
 
1018
1163
  let resolvedApiKey = apiKey?.trim();
1164
+ if (!resolvedApiKey && modelAuth) {
1165
+ try {
1166
+ resolvedApiKey = resolveApiKeyFromAuthResult(
1167
+ await modelAuth.getApiKeyForModel({
1168
+ model: buildModelAuthLookupModel({
1169
+ provider: providerId,
1170
+ model: modelId,
1171
+ api: resolvedModel.api,
1172
+ }),
1173
+ cfg: api.config,
1174
+ ...(authProfileId ? { profileId: authProfileId } : {}),
1175
+ }),
1176
+ );
1177
+ } catch (err) {
1178
+ console.error(
1179
+ `[lcm] modelAuth.getApiKeyForModel FAILED:`,
1180
+ err instanceof Error ? err.message : err,
1181
+ );
1182
+ }
1183
+ }
1019
1184
  if (!resolvedApiKey && modelAuth) {
1020
1185
  try {
1021
1186
  resolvedApiKey = resolveApiKeyFromAuthResult(
@@ -1032,13 +1197,13 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1032
1197
  );
1033
1198
  }
1034
1199
  }
1035
- if (!resolvedApiKey && !modelAuth) {
1200
+ if (!resolvedApiKey) {
1036
1201
  resolvedApiKey = resolveApiKey(providerId, readEnv);
1037
1202
  }
1038
- if (!resolvedApiKey && !modelAuth && typeof mod.getEnvApiKey === "function") {
1203
+ if (!resolvedApiKey && typeof mod.getEnvApiKey === "function") {
1039
1204
  resolvedApiKey = mod.getEnvApiKey(providerId)?.trim();
1040
1205
  }
1041
- if (!resolvedApiKey && !modelAuth) {
1206
+ if (!resolvedApiKey) {
1042
1207
  resolvedApiKey = await resolveApiKeyFromAuthProfiles({
1043
1208
  provider: providerId,
1044
1209
  authProfileId,
@@ -1072,6 +1237,19 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1072
1237
  temperature,
1073
1238
  reasoning,
1074
1239
  });
1240
+ const requestMetadata = {
1241
+ request_provider: providerId,
1242
+ request_model: modelId,
1243
+ request_api: resolvedModel.api,
1244
+ request_reasoning:
1245
+ typeof reasoning === "string" && reasoning.trim() ? reasoning.trim() : "(none)",
1246
+ request_has_system: typeof system === "string" && system.trim().length > 0 ? "true" : "false",
1247
+ request_temperature:
1248
+ typeof completeOptions.temperature === "number"
1249
+ ? String(completeOptions.temperature)
1250
+ : "(omitted)",
1251
+ request_temperature_sent: typeof completeOptions.temperature === "number" ? "true" : "false",
1252
+ };
1075
1253
 
1076
1254
  const result = await mod.completeSimple(
1077
1255
  resolvedModel,
@@ -1091,40 +1269,22 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
1091
1269
  if (!isRecord(result)) {
1092
1270
  return {
1093
1271
  content: [],
1094
- request_provider: providerId,
1095
- request_model: modelId,
1096
- request_api: resolvedModel.api,
1097
- request_reasoning:
1098
- typeof reasoning === "string" && reasoning.trim() ? reasoning.trim() : "(none)",
1099
- request_has_system:
1100
- typeof system === "string" && system.trim().length > 0 ? "true" : "false",
1101
- request_temperature:
1102
- typeof completeOptions.temperature === "number"
1103
- ? String(completeOptions.temperature)
1104
- : "(omitted)",
1105
- request_temperature_sent:
1106
- typeof completeOptions.temperature === "number" ? "true" : "false",
1272
+ ...requestMetadata,
1107
1273
  };
1108
1274
  }
1109
1275
 
1110
1276
  return {
1111
1277
  ...result,
1112
1278
  content: Array.isArray(result.content) ? result.content : [],
1113
- request_provider: providerId,
1114
- request_model: modelId,
1115
- request_api: resolvedModel.api,
1116
- request_reasoning:
1117
- typeof reasoning === "string" && reasoning.trim() ? reasoning.trim() : "(none)",
1118
- request_has_system: typeof system === "string" && system.trim().length > 0 ? "true" : "false",
1119
- request_temperature:
1120
- typeof completeOptions.temperature === "number"
1121
- ? String(completeOptions.temperature)
1122
- : "(omitted)",
1123
- request_temperature_sent: typeof completeOptions.temperature === "number" ? "true" : "false",
1279
+ ...requestMetadata,
1124
1280
  };
1125
1281
  } catch (err) {
1126
1282
  console.error(`[lcm] completeSimple error:`, err instanceof Error ? err.message : err);
1127
- return { content: [] };
1283
+ const authError = detectProviderAuthError(err);
1284
+ return {
1285
+ content: [],
1286
+ ...(authError ? { error: authError } : {}),
1287
+ };
1128
1288
  }
1129
1289
  },
1130
1290
  callGateway: async (params) => {
@@ -1,7 +1,7 @@
1
1
  import type { DatabaseSync } from "node:sqlite";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { sanitizeFts5Query } from "./fts5-sanitize.js";
4
- import { buildLikeSearchPlan, createFallbackSnippet } from "./full-text-fallback.js";
4
+ import { buildLikeSearchPlan, containsCjk, createFallbackSnippet } from "./full-text-fallback.js";
5
5
 
6
6
  export type ConversationId = number;
7
7
  export type MessageId = number;
@@ -205,6 +205,47 @@ function toMessagePartRecord(row: MessagePartRow): MessagePartRecord {
205
205
  };
206
206
  }
207
207
 
208
+ function normalizeMessageContentForFullTextIndex(content: string): string | null {
209
+ const trimmed = content.trim();
210
+ if (!trimmed) {
211
+ return null;
212
+ }
213
+
214
+ const isExternalizedReference =
215
+ trimmed.startsWith("[LCM File:") || trimmed.startsWith("[LCM Tool Output:");
216
+ if (!isExternalizedReference) {
217
+ return content;
218
+ }
219
+
220
+ const lines = trimmed
221
+ .split(/\r?\n/)
222
+ .map((line) => line.trim())
223
+ .filter((line) => line.length > 0);
224
+ if (lines.length === 0) {
225
+ return null;
226
+ }
227
+
228
+ const header = lines[0] ?? "";
229
+ const summaryLines: string[] = [];
230
+ let inSummary = false;
231
+ for (let index = 1; index < lines.length; index += 1) {
232
+ const line = lines[index]!;
233
+ if (line === "Exploration Summary:") {
234
+ inSummary = true;
235
+ continue;
236
+ }
237
+ if (line.startsWith("Use lcm_describe")) {
238
+ continue;
239
+ }
240
+ if (inSummary) {
241
+ summaryLines.push(line);
242
+ }
243
+ }
244
+
245
+ const normalized = [header, ...summaryLines].filter((line) => line.length > 0).join("\n");
246
+ return normalized || null;
247
+ }
248
+
208
249
  // ── ConversationStore ─────────────────────────────────────────────────────────
209
250
 
210
251
  export class ConversationStore {
@@ -622,6 +663,17 @@ export class ConversationStore {
622
663
  const limit = input.limit ?? 50;
623
664
 
624
665
  if (input.mode === "full_text") {
666
+ // FTS5 unicode61 can return incomplete matches for CJK text, so route
667
+ // those queries through the existing LIKE fallback path immediately.
668
+ if (containsCjk(input.query)) {
669
+ return this.searchLike(
670
+ input.query,
671
+ limit,
672
+ input.conversationId,
673
+ input.since,
674
+ input.before,
675
+ );
676
+ }
625
677
  if (this.fts5Available) {
626
678
  try {
627
679
  return this.searchFullText(
@@ -650,10 +702,14 @@ export class ConversationStore {
650
702
  if (!this.fts5Available) {
651
703
  return;
652
704
  }
705
+ const normalizedContent = normalizeMessageContentForFullTextIndex(content);
706
+ if (!normalizedContent) {
707
+ return;
708
+ }
653
709
  try {
654
710
  this.db
655
711
  .prepare(`INSERT INTO messages_fts(rowid, content) VALUES (?, ?)`)
656
- .run(messageId, content);
712
+ .run(messageId, normalizedContent);
657
713
  } catch {
658
714
  // Full-text indexing is optional. Message persistence must still succeed.
659
715
  }
@@ -748,14 +804,24 @@ export class ConversationStore {
748
804
  )
749
805
  .all(...args) as unknown as MessageRow[];
750
806
 
751
- return rows.map((row) => ({
752
- messageId: row.message_id,
753
- conversationId: row.conversation_id,
754
- role: row.role,
755
- snippet: createFallbackSnippet(row.content, plan.terms),
756
- createdAt: new Date(row.created_at),
757
- rank: 0,
758
- }));
807
+ return rows
808
+ .map((row) => {
809
+ const normalizedContent = normalizeMessageContentForFullTextIndex(row.content) ?? row.content;
810
+ const haystack = normalizedContent.toLowerCase();
811
+ const matchesAllTerms = plan.terms.every((term) => haystack.includes(term));
812
+ if (!matchesAllTerms) {
813
+ return null;
814
+ }
815
+ return {
816
+ messageId: row.message_id,
817
+ conversationId: row.conversation_id,
818
+ role: row.role,
819
+ snippet: createFallbackSnippet(normalizedContent, plan.terms),
820
+ createdAt: new Date(row.created_at),
821
+ rank: 0,
822
+ };
823
+ })
824
+ .filter((row): row is MessageSearchResult => row !== null);
759
825
  }
760
826
 
761
827
  private searchRegex(
@@ -1,4 +1,13 @@
1
1
  const RAW_TERM_RE = /"([^"]+)"|(\S+)/g;
2
+
3
+ /**
4
+ * Detect whether a query contains CJK characters that FTS5 unicode61
5
+ * tokenizer cannot index properly.
6
+ */
7
+ const CJK_RE = /[\u2E80-\u9FFF\u3400-\u4DBF\uF900-\uFAFF\uAC00-\uD7AF\u3040-\u309F\u30A0-\u30FF]/;
8
+ export function containsCjk(text: string): boolean {
9
+ return CJK_RE.test(text);
10
+ }
2
11
  const EDGE_PUNCTUATION_RE = /^[`'"()[\]{}<>.,:;!?*_+=|\\/-]+|[`'"()[\]{}<>.,:;!?*_+=|\\/-]+$/g;
3
12
 
4
13
  export type LikeSearchPlan = {
@@ -26,4 +26,6 @@ export type {
26
26
  SummarySearchResult,
27
27
  CreateLargeFileInput,
28
28
  LargeFileRecord,
29
+ UpsertConversationBootstrapStateInput,
30
+ ConversationBootstrapStateRecord,
29
31
  } from "./summary-store.js";