@lore-co/cli 0.1.17 → 0.1.18

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.
Files changed (43) hide show
  1. package/README.md +63 -14
  2. package/dist/ask.d.ts.map +1 -1
  3. package/dist/ask.js +3 -26
  4. package/dist/ask.js.map +1 -1
  5. package/dist/cli.d.ts +8 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +566 -39
  8. package/dist/cli.js.map +1 -1
  9. package/dist/context-fallback.d.ts +37 -0
  10. package/dist/context-fallback.d.ts.map +1 -0
  11. package/dist/context-fallback.js +259 -0
  12. package/dist/context-fallback.js.map +1 -0
  13. package/dist/generated-assets.d.ts +4 -4
  14. package/dist/generated-assets.d.ts.map +1 -1
  15. package/dist/generated-assets.js +4 -4
  16. package/dist/generated-assets.js.map +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/invocation-health-writer.d.ts +19 -0
  22. package/dist/invocation-health-writer.d.ts.map +1 -0
  23. package/dist/invocation-health-writer.js +131 -0
  24. package/dist/invocation-health-writer.js.map +1 -0
  25. package/dist/reliability-store.d.ts +283 -0
  26. package/dist/reliability-store.d.ts.map +1 -0
  27. package/dist/reliability-store.js +1913 -0
  28. package/dist/reliability-store.js.map +1 -0
  29. package/dist/runtime-version.d.ts +2 -0
  30. package/dist/runtime-version.d.ts.map +1 -0
  31. package/dist/runtime-version.js +5 -0
  32. package/dist/runtime-version.js.map +1 -0
  33. package/dist/runtime.d.ts.map +1 -1
  34. package/dist/runtime.js +1041 -152
  35. package/dist/runtime.js.map +1 -1
  36. package/dist/self-host.d.ts +1 -1
  37. package/dist/self-host.js +2 -2
  38. package/dist/signed-snapshot.d.ts +59 -0
  39. package/dist/signed-snapshot.d.ts.map +1 -0
  40. package/dist/signed-snapshot.js +303 -0
  41. package/dist/signed-snapshot.js.map +1 -0
  42. package/dist/update.js +3 -3
  43. package/package.json +19 -3
package/dist/runtime.js CHANGED
@@ -1,15 +1,17 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { access, chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile, } from "node:fs/promises";
3
- import { constants as fsConstants } from "node:fs";
2
+ import { access, chmod, mkdir, readFile, stat, } from "node:fs/promises";
3
+ import { constants as fsConstants, realpathSync } from "node:fs";
4
4
  import { execFile as execFileCallback } from "node:child_process";
5
5
  import { dirname, parse, relative, resolve } from "node:path";
6
6
  import { homedir } from "node:os";
7
- import { pathToFileURL } from "node:url";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
8
  import { promisify } from "node:util";
9
9
  import { boundedUtf8Text, repositoryScopeFromGitRoot, } from "./repository.js";
10
- const RUNTIME_VERSION = typeof __LORE_VERSION__ === "string" && __LORE_VERSION__ !== ""
11
- ? __LORE_VERSION__
12
- : "0.1.17";
10
+ import { ReliabilityStore, ReliabilityStoreError, atomicWriteJson as durableAtomicWriteJson, classifyRetryFailure, createIntegrationInvocationAttempt, durableUnlink, recordLocalGuardMetric, recordLocalRetrievalMetric, } from "./reliability-store.js";
11
+ import { writeInvocationAttemptBounded, writeInvocationCompletionBounded, } from "./invocation-health-writer.js";
12
+ import { SignedSnapshotError, verifySignedSnapshot, } from "./signed-snapshot.js";
13
+ import { CONTEXT_CACHE_MAX_STALE_MS, isContextTransportFailure, selectCachedContext, } from "./context-fallback.js";
14
+ import { RUNTIME_VERSION } from "./runtime-version.js";
13
15
  const IS_STANDALONE_RUNTIME = typeof __LORE_STANDALONE__ === "boolean" && __LORE_STANDALONE__;
14
16
  export const COMMAND_HOOK_AGENT_NAMES = [
15
17
  "codex",
@@ -22,11 +24,33 @@ function isCommandHookAgent(value) {
22
24
  return typeof value === "string" && COMMAND_HOOK_AGENTS.has(value);
23
25
  }
24
26
  const MAX_STDIN_BYTES = 1024 * 1024;
27
+ const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
25
28
  const MAX_CONTEXT_CHARS = 12_000;
26
29
  const PENDING_ASSISTANT_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
27
- const MAX_QUEUE_ITEMS = 100;
28
30
  const MAX_GIT_DIFF_BYTES = 256 * 1024;
29
31
  const MAX_GIT_FILES = 100;
32
+ const RELIABILITY_V1_MEDIA_TYPE = "application/vnd.lore.reliability-v1+json";
33
+ const TRUST_KEY_REFRESH_TTL_MS = 60 * 60_000;
34
+ const TRUST_KEY_REFRESH_STATE_KEY = "snapshot-trust-keys";
35
+ const CONTEXT_NOTICE_COOLDOWN_MS = 15 * 60_000;
36
+ const RELIABILITY_REASON_CODES = new Set([
37
+ "live_success",
38
+ "idempotent_replay",
39
+ "semantic_unavailable",
40
+ "lexical_fallback",
41
+ "live_unavailable",
42
+ "cached_context",
43
+ "cached_policy",
44
+ "no_usable_cache",
45
+ "capture_queued",
46
+ "local_persistence_failed",
47
+ "governed_rule",
48
+ "policy_unavailable",
49
+ "policy_expired",
50
+ "user_override",
51
+ "invalid_response",
52
+ "integration_unsupported",
53
+ ]);
30
54
  const NON_SECRET_TOKEN_VALUES = new Set([
31
55
  "available",
32
56
  "configured",
@@ -81,6 +105,18 @@ export async function readLineageMetadata(home, environment = process.env) {
81
105
  function isObject(value) {
82
106
  return typeof value === "object" && value !== null && !Array.isArray(value);
83
107
  }
108
+ function canonicalJson(value) {
109
+ if (Array.isArray(value)) {
110
+ return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
111
+ }
112
+ if (isObject(value)) {
113
+ return `{${Object.keys(value)
114
+ .sort()
115
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
116
+ .join(",")}}`;
117
+ }
118
+ return JSON.stringify(value) ?? "null";
119
+ }
84
120
  function stringField(value) {
85
121
  return typeof value === "string" && value.trim() !== ""
86
122
  ? value
@@ -181,6 +217,48 @@ function normalizeHookInput(input, agent, environment) {
181
217
  sessionId,
182
218
  };
183
219
  }
220
+ class InvalidHookInputError extends Error {
221
+ constructor() {
222
+ super("Hook input does not match the configured agent event");
223
+ this.name = "InvalidHookInputError";
224
+ }
225
+ }
226
+ function validHookInput(input, agent, eventName) {
227
+ const recognized = agent === "polytoken"
228
+ ? eventName === "UserPromptSubmit" || eventName === "AssistantResponse"
229
+ : agent === "cursor"
230
+ ? eventName === "UserPromptSubmit" ||
231
+ eventName === "AssistantResponse" ||
232
+ eventName === "PreToolUse" ||
233
+ eventName === "SessionEnd"
234
+ : eventName === "UserPromptSubmit" ||
235
+ eventName === "PreToolUse" ||
236
+ eventName === "Stop" ||
237
+ eventName === "SessionEnd";
238
+ if (!recognized) {
239
+ return false;
240
+ }
241
+ if (eventName === "UserPromptSubmit") {
242
+ return stringField(input.prompt) !== undefined;
243
+ }
244
+ if (eventName === "Stop" || eventName === "AssistantResponse") {
245
+ return stringField(input.last_assistant_message) !== undefined;
246
+ }
247
+ if (eventName === "PreToolUse") {
248
+ const toolName = stringField(input.tool_name);
249
+ if (toolName === undefined || !isObject(input.tool_input)) {
250
+ return false;
251
+ }
252
+ if (GUARD_EDIT_TOOLS.has(toolName)) {
253
+ return true;
254
+ }
255
+ if (GUARD_SHELL_TOOLS.has(toolName)) {
256
+ return stringField(input.tool_input.command) !== undefined;
257
+ }
258
+ return true;
259
+ }
260
+ return true;
261
+ }
184
262
  function sha256(value) {
185
263
  return createHash("sha256").update(value).digest("hex");
186
264
  }
@@ -227,22 +305,16 @@ export function redactSecrets(text) {
227
305
  })
228
306
  .replace(/\b((?:set-)?cookie\s*:\s*)[^\r\n]+/giu, (_match, prefix) => `${prefix}[REDACTED:AUTHORIZATION]`);
229
307
  }
230
- async function atomicWriteJson(path, value, mode = 0o600) {
308
+ async function atomicWriteJson(path, value) {
231
309
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
232
- const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
233
- await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, {
234
- encoding: "utf8",
235
- mode,
236
- flag: "wx",
237
- });
238
- await rename(temporaryPath, path);
239
- await chmod(path, mode);
310
+ await chmod(dirname(path), 0o700);
311
+ await durableAtomicWriteJson(path, value);
240
312
  }
241
313
  async function readRuntimeConfig(home) {
242
314
  try {
243
315
  const parsed = JSON.parse(await readFile(resolve(loreDirectory(home), "config.json"), "utf8"));
244
316
  if (!isObject(parsed) ||
245
- parsed.version !== 1 ||
317
+ (parsed.version !== 1 && parsed.version !== 2) ||
246
318
  typeof parsed.apiUrl !== "string" ||
247
319
  typeof parsed.token !== "string" ||
248
320
  !Array.isArray(parsed.agents)) {
@@ -259,10 +331,15 @@ async function readRuntimeConfig(home) {
259
331
  /^https?:\/\//u.test(parsed.dashboardUrl)
260
332
  ? parsed.dashboardUrl.replace(/\/+$/u, "")
261
333
  : undefined;
334
+ const workspaceId = typeof parsed.workspaceId === "string" &&
335
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(parsed.workspaceId)
336
+ ? parsed.workspaceId
337
+ : undefined;
262
338
  return {
263
- version: 1,
339
+ version: parsed.version,
264
340
  apiUrl: parsed.apiUrl,
265
341
  ...(dashboardUrl === undefined ? {} : { dashboardUrl }),
342
+ ...(workspaceId === undefined ? {} : { workspaceId }),
266
343
  token: parsed.token,
267
344
  agents,
268
345
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
@@ -291,11 +368,10 @@ async function savePending(input, agent, sessionId, now, home) {
291
368
  capturedAt: now.toISOString(),
292
369
  });
293
370
  }
294
- async function consumePending(agent, sessionId, home) {
371
+ async function readPending(agent, sessionId, home) {
295
372
  const path = pendingPath(agent, sessionId, home);
296
373
  try {
297
374
  const parsed = JSON.parse(await readFile(path, "utf8"));
298
- await rm(path, { force: true });
299
375
  if (!isObject(parsed) ||
300
376
  parsed.agent !== agent ||
301
377
  parsed.sessionId !== sessionId ||
@@ -323,6 +399,9 @@ async function consumePending(agent, sessionId, home) {
323
399
  return null;
324
400
  }
325
401
  }
402
+ async function clearPending(agent, sessionId, home) {
403
+ await durableUnlink(pendingPath(agent, sessionId, home), true);
404
+ }
326
405
  function normalizedRepositoryPath(value) {
327
406
  const parts = [];
328
407
  for (const part of value.replaceAll("\\", "/").split("/")) {
@@ -539,6 +618,50 @@ function observationsUrl(apiUrl) {
539
618
  function contextDeliveriesUrl(apiUrl) {
540
619
  return `${apiUrl.replace(/\/+$/u, "")}/v1/context/deliveries`;
541
620
  }
621
+ class RuntimeHttpError extends Error {
622
+ httpStatus;
623
+ constructor(message, httpStatus) {
624
+ super(message);
625
+ this.name = "RuntimeHttpError";
626
+ this.httpStatus = httpStatus;
627
+ }
628
+ }
629
+ async function boundedResponseJson(response) {
630
+ const declaredLength = response.headers.get("content-length");
631
+ if (declaredLength !== null &&
632
+ Number.isFinite(Number(declaredLength)) &&
633
+ Number(declaredLength) > MAX_RESPONSE_BYTES) {
634
+ await response.body?.cancel();
635
+ throw new Error("Lore response exceeds the reliability size limit");
636
+ }
637
+ if (response.body === null) {
638
+ return null;
639
+ }
640
+ const reader = response.body.getReader();
641
+ const chunks = [];
642
+ let bytes = 0;
643
+ try {
644
+ for (;;) {
645
+ const chunk = await reader.read();
646
+ if (chunk.done) {
647
+ break;
648
+ }
649
+ bytes += chunk.value.byteLength;
650
+ if (bytes > MAX_RESPONSE_BYTES) {
651
+ await reader.cancel();
652
+ throw new Error("Lore response exceeds the reliability size limit");
653
+ }
654
+ chunks.push(chunk.value);
655
+ }
656
+ }
657
+ finally {
658
+ reader.releaseLock();
659
+ }
660
+ if (bytes === 0) {
661
+ return null;
662
+ }
663
+ return JSON.parse(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)), bytes).toString("utf8"));
664
+ }
542
665
  function boundedContext(value) {
543
666
  return Array.from(value.trim()).slice(0, MAX_CONTEXT_CHARS).join("");
544
667
  }
@@ -638,11 +761,276 @@ function deliveryFromResponse(value) {
638
761
  delivered,
639
762
  };
640
763
  }
641
- async function postTurn(config, request, fetchImplementation) {
764
+ class IncompatibleReliabilityError extends Error {
765
+ incompatible = true;
766
+ }
767
+ async function protocolResponseJson(response) {
768
+ try {
769
+ return await boundedResponseJson(response);
770
+ }
771
+ catch (error) {
772
+ if (isContextTransportFailure(error)) {
773
+ throw error;
774
+ }
775
+ throw new IncompatibleReliabilityError("Lore response is not valid bounded JSON");
776
+ }
777
+ }
778
+ function deliveryFromSignedContext(payload, expectedRequest) {
779
+ const request = isObject(payload.request) ? payload.request : null;
780
+ if (request === null ||
781
+ canonicalJson(request) !== canonicalJson(expectedRequest) ||
782
+ typeof payload.eventId !== "string" ||
783
+ typeof payload.receiptId !== "string" ||
784
+ typeof payload.context !== "string" ||
785
+ !Array.isArray(payload.memories) ||
786
+ !Array.isArray(payload.hits) ||
787
+ !isObject(payload.packing)) {
788
+ throw new IncompatibleReliabilityError("Lore context snapshot does not bind the request or signed delivery");
789
+ }
790
+ return deliveryFromResponse({
791
+ context: payload.context,
792
+ receipt: { id: payload.receiptId },
793
+ memories: payload.memories,
794
+ hits: payload.hits,
795
+ });
796
+ }
797
+ function retrievalReliability(value, payload) {
798
+ const metadata = isObject(value) ? value : null;
799
+ const freshness = isObject(metadata?.freshness)
800
+ ? metadata.freshness
801
+ : null;
802
+ const policy = isObject(metadata?.policy) ? metadata.policy : null;
803
+ const reasons = Array.isArray(metadata?.reasons)
804
+ ? metadata.reasons.filter((reason) => typeof reason === "string")
805
+ : [];
806
+ const policySource = policy?.source;
807
+ const policyLoadedAt = policy?.loadedAt;
808
+ const policyValidUntil = policy?.validUntil;
809
+ if (metadata === null ||
810
+ metadata.contractVersion !== "reliability-v1" ||
811
+ Object.keys(metadata).length !== 9 ||
812
+ metadata.operation !== "retrieval" ||
813
+ !(metadata.requestId === null ||
814
+ typeof metadata.requestId === "string") ||
815
+ !["ok", "degraded", "failed"].includes(String(metadata.status)) ||
816
+ !["live", "cache", "none"].includes(String(metadata.source)) ||
817
+ !["none", "live_lexical", "cached_context"].includes(String(metadata.fallback)) ||
818
+ freshness === null ||
819
+ Object.keys(freshness).length !== 4 ||
820
+ !["fresh", "stale", "unknown"].includes(String(freshness.state)) ||
821
+ typeof freshness.asOf !== "string" ||
822
+ freshness.asOf !== payload.asOf ||
823
+ typeof freshness.ageMs !== "number" ||
824
+ !Number.isSafeInteger(freshness.ageMs) ||
825
+ freshness.ageMs < 0 ||
826
+ typeof freshness.validUntil !== "string" ||
827
+ freshness.validUntil !== payload.validUntil ||
828
+ policy === null ||
829
+ Object.keys(policy).length !== 4 ||
830
+ !["live", "cache", "none"].includes(String(policySource)) ||
831
+ (policy.version !== null && typeof policy.version !== "string") ||
832
+ (policy.version !== null && policy.version !== payload.policyVersion) ||
833
+ !((policySource === "none" &&
834
+ policy.version === null &&
835
+ policyLoadedAt === null &&
836
+ policyValidUntil === null) ||
837
+ (["live", "cache"].includes(String(policySource)) &&
838
+ typeof policy.version === "string" &&
839
+ typeof policyLoadedAt === "string" &&
840
+ Number.isFinite(Date.parse(policyLoadedAt)) &&
841
+ (policyValidUntil === null ||
842
+ (typeof policyValidUntil === "string" &&
843
+ Number.isFinite(Date.parse(policyValidUntil)))) &&
844
+ policyValidUntil === payload.validUntil &&
845
+ policyLoadedAt ===
846
+ (policySource === "cache" ? payload.asOf : payload.issuedAt))) ||
847
+ !Array.isArray(metadata.reasons) ||
848
+ reasons.length === 0 ||
849
+ reasons.length > 8 ||
850
+ reasons.length !== metadata.reasons.length ||
851
+ new Set(reasons).size !== reasons.length ||
852
+ reasons.some((reason) => !RELIABILITY_REASON_CODES.has(reason)) ||
853
+ (metadata.requestId !== null &&
854
+ metadata.requestId !== payload.requestId) ||
855
+ (metadata.source === "live" && freshness.state !== "fresh") ||
856
+ (metadata.source === "live" && policySource !== "live") ||
857
+ (metadata.source === "cache" &&
858
+ (metadata.fallback !== "cached_context" ||
859
+ policySource !== "cache" ||
860
+ typeof policyValidUntil !== "string" ||
861
+ !["fresh", "stale"].includes(String(freshness.state)))) ||
862
+ (metadata.fallback === "live_lexical" &&
863
+ (metadata.source !== "live" ||
864
+ metadata.status !== "degraded" ||
865
+ !reasons.includes("lexical_fallback"))) ||
866
+ (metadata.fallback === "cached_context" &&
867
+ !reasons.includes("cached_context")) ||
868
+ (metadata.status === "ok" && metadata.fallback !== "none")
869
+ || (metadata.status === "degraded" && metadata.fallback === "none")
870
+ || (metadata.status === "failed" &&
871
+ (metadata.source !== "none" || metadata.fallback !== "none"))) {
872
+ throw new IncompatibleReliabilityError("Lore retrieval reliability metadata is invalid");
873
+ }
874
+ const cached = metadata.source === "cache";
875
+ const nowMs = Date.now();
876
+ return {
877
+ status: metadata.status,
878
+ source: metadata.source,
879
+ fallback: metadata.fallback,
880
+ reasons,
881
+ freshness: {
882
+ state: cached
883
+ ? nowMs < Date.parse(payload.validUntil)
884
+ ? "fresh"
885
+ : "stale"
886
+ : freshness.state,
887
+ asOf: payload.asOf,
888
+ ageMs: cached
889
+ ? Math.max(0, nowMs - Date.parse(payload.asOf))
890
+ : freshness.ageMs,
891
+ validUntil: payload.validUntil,
892
+ },
893
+ policyVersion: typeof policy.version === "string" ? policy.version : null,
894
+ };
895
+ }
896
+ function reliabilityWorkspaceKey(config) {
897
+ return (config.workspaceId ??
898
+ credentialReliabilityWorkspaceKey(config));
899
+ }
900
+ function credentialReliabilityWorkspaceKey(config) {
901
+ return `credential-${sha256(`${config.apiUrl}\0${config.token}`).slice(0, 32)}`;
902
+ }
903
+ function runtimeAccept(config) {
904
+ return config.version === 2
905
+ ? RELIABILITY_V1_MEDIA_TYPE
906
+ : "application/json";
907
+ }
908
+ async function runtimeStore(config, home) {
909
+ const store = new ReliabilityStore(reliabilityWorkspaceKey(config), {
910
+ ...(home === undefined ? {} : { home }),
911
+ });
912
+ await store.initialize();
913
+ await store.migrateLegacyQueue(queueDirectory(home));
914
+ if (config.workspaceId !== undefined) {
915
+ const credentialStore = new ReliabilityStore(credentialReliabilityWorkspaceKey(config), { ...(home === undefined ? {} : { home }) });
916
+ await credentialStore.transferPendingTo(store).catch(() => undefined);
917
+ }
918
+ return store;
919
+ }
920
+ function publicKeySet(value) {
921
+ if (!isObject(value) ||
922
+ typeof value.workspaceId !== "string" ||
923
+ !Array.isArray(value.keys)) {
924
+ throw new IncompatibleReliabilityError("Lore public key response is invalid");
925
+ }
926
+ const keys = value.keys.map((candidate) => {
927
+ if (!isObject(candidate) ||
928
+ typeof candidate.kid !== "string" ||
929
+ candidate.kty !== "OKP" ||
930
+ candidate.crv !== "Ed25519" ||
931
+ candidate.alg !== "EdDSA" ||
932
+ candidate.use !== "sig" ||
933
+ typeof candidate.x !== "string" ||
934
+ typeof candidate.notBefore !== "string" ||
935
+ !(candidate.retiredAt === null ||
936
+ typeof candidate.retiredAt === "string")) {
937
+ throw new IncompatibleReliabilityError("Lore public key response contains an invalid key");
938
+ }
939
+ return {
940
+ workspaceId: value.workspaceId,
941
+ kid: candidate.kid,
942
+ kty: "OKP",
943
+ crv: "Ed25519",
944
+ alg: "EdDSA",
945
+ use: "sig",
946
+ x: candidate.x,
947
+ notBefore: candidate.notBefore,
948
+ retiredAt: candidate.retiredAt,
949
+ };
950
+ });
951
+ if (keys.length === 0) {
952
+ throw new IncompatibleReliabilityError("Lore public key response is empty");
953
+ }
954
+ return { workspaceId: value.workspaceId, keys };
955
+ }
956
+ async function refreshTrustKeys(config, store, fetchImplementation) {
957
+ const response = await fetchImplementation(`${config.apiUrl.replace(/\/+$/u, "")}/v1/workspace/identity/keys`, {
958
+ headers: {
959
+ accept: RELIABILITY_V1_MEDIA_TYPE,
960
+ authorization: `Bearer ${config.token}`,
961
+ "user-agent": `lore-cli/${RUNTIME_VERSION}`,
962
+ },
963
+ signal: AbortSignal.timeout(config.timeoutMs ?? 2_500),
964
+ });
965
+ if (!response.ok) {
966
+ throw new RuntimeHttpError(`Lore key discovery failed with HTTP ${response.status}`, response.status);
967
+ }
968
+ const keys = publicKeySet(await protocolResponseJson(response));
969
+ if (config.workspaceId !== undefined &&
970
+ keys.workspaceId !== config.workspaceId) {
971
+ throw new IncompatibleReliabilityError("Lore public keys belong to a different workspace");
972
+ }
973
+ if (store.workspaceId === keys.workspaceId) {
974
+ await store.writePublicTrustKeys(keys.keys.map((key) => ({
975
+ ...key,
976
+ workspaceId: keys.workspaceId,
977
+ })));
978
+ await store.writeState(TRUST_KEY_REFRESH_STATE_KEY, {
979
+ refreshedAt: new Date().toISOString(),
980
+ });
981
+ }
982
+ return keys;
983
+ }
984
+ async function pinnedKeys(config, store, fetchImplementation) {
985
+ const keys = await store.readPublicTrustKeys();
986
+ const refreshState = await store.readState(TRUST_KEY_REFRESH_STATE_KEY);
987
+ const refreshedAt = isObject(refreshState)
988
+ ? refreshState.refreshedAt
989
+ : undefined;
990
+ const refreshedAtMs = typeof refreshedAt === "string" ? Date.parse(refreshedAt) : Number.NaN;
991
+ if (keys.length > 0 &&
992
+ Number.isFinite(refreshedAtMs) &&
993
+ Date.now() - refreshedAtMs < TRUST_KEY_REFRESH_TTL_MS &&
994
+ refreshedAtMs <= Date.now() + 5 * 60_000) {
995
+ return { workspaceId: store.workspaceId, keys };
996
+ }
997
+ return refreshTrustKeys(config, store, fetchImplementation);
998
+ }
999
+ async function verifySnapshot(compactJws, kind, config, store, fetchImplementation, allowExpired = false) {
1000
+ const verifyWith = (keys) => verifySignedSnapshot(compactJws, {
1001
+ workspaceId: keys.workspaceId,
1002
+ kind,
1003
+ keys,
1004
+ allowExpired,
1005
+ }).payload;
1006
+ try {
1007
+ return verifyWith(await pinnedKeys(config, store, fetchImplementation));
1008
+ }
1009
+ catch (error) {
1010
+ if (!(error instanceof SignedSnapshotError) ||
1011
+ error.code !== "UNTRUSTED_KEY") {
1012
+ throw error;
1013
+ }
1014
+ return verifyWith(await refreshTrustKeys(config, store, fetchImplementation));
1015
+ }
1016
+ }
1017
+ function committedAcknowledgement(value, externalEventId, serverEventId) {
1018
+ if (!isObject(value) || !isObject(value.acknowledgement)) {
1019
+ return false;
1020
+ }
1021
+ const acknowledgement = value.acknowledgement;
1022
+ return (acknowledgement.state === "committed_server" &&
1023
+ acknowledgement.durability === "workspace_database" &&
1024
+ acknowledgement.replayPending === false &&
1025
+ acknowledgement.eventId === serverEventId &&
1026
+ acknowledgement.idempotencyKey === externalEventId);
1027
+ }
1028
+ async function postTurn(config, request, fetchImplementation, store) {
642
1029
  const { idempotencyKey, ...body } = request;
643
1030
  const response = await fetchImplementation(turnsUrl(config.apiUrl), {
644
1031
  method: "POST",
645
1032
  headers: {
1033
+ accept: runtimeAccept(config),
646
1034
  authorization: `Bearer ${config.token}`,
647
1035
  "content-type": "application/json",
648
1036
  "idempotency-key": idempotencyKey,
@@ -652,18 +1040,70 @@ async function postTurn(config, request, fetchImplementation) {
652
1040
  signal: AbortSignal.timeout(config.timeoutMs ?? 2_500),
653
1041
  });
654
1042
  if (!response.ok) {
655
- throw new Error(`Lore turn request failed with HTTP ${response.status}`);
656
- }
657
- const text = await response.text();
658
- if (text.trim() === "") {
659
- return emptyDelivery();
1043
+ throw new RuntimeHttpError(`Lore turn request failed with HTTP ${response.status}`, response.status);
1044
+ }
1045
+ const value = await protocolResponseJson(response);
1046
+ if (config.version === 1) {
1047
+ return deliveryFromResponse(value);
1048
+ }
1049
+ if (!isObject(value) ||
1050
+ !isObject(value.turn) ||
1051
+ !isObject(value.capture) ||
1052
+ typeof value.contextSnapshot !== "string" ||
1053
+ !isObject(value.turn.event) ||
1054
+ typeof value.turn.event.id !== "string" ||
1055
+ !isObject(value.retrieval) ||
1056
+ !committedAcknowledgement(value.capture, request.eventId, value.turn.event.id)) {
1057
+ throw new IncompatibleReliabilityError("Lore turn response has no matching durable acknowledgement");
1058
+ }
1059
+ const payload = await verifySnapshot(value.contextSnapshot, "context", config, store, fetchImplementation, true);
1060
+ if (payload.eventId !== value.turn.event.id ||
1061
+ !isObject(value.turn.receipt) ||
1062
+ payload.receiptId !== value.turn.receipt.id) {
1063
+ throw new IncompatibleReliabilityError("Lore turn snapshot does not bind the returned event and receipt");
1064
+ }
1065
+ const reliability = retrievalReliability(value.retrieval, payload);
1066
+ if (Date.now() - Date.parse(payload.validUntil) >
1067
+ CONTEXT_CACHE_MAX_STALE_MS) {
1068
+ return {
1069
+ ...emptyDelivery(),
1070
+ reliability: {
1071
+ status: "failed",
1072
+ source: "none",
1073
+ fallback: "none",
1074
+ reasons: ["no_usable_cache"],
1075
+ freshness: {
1076
+ state: "unknown",
1077
+ asOf: null,
1078
+ ageMs: null,
1079
+ validUntil: null,
1080
+ },
1081
+ policyVersion: null,
1082
+ },
1083
+ };
660
1084
  }
661
- return deliveryFromResponse(JSON.parse(text));
1085
+ await store
1086
+ .writeContextSnapshot(request.eventId, {
1087
+ compactJws: value.contextSnapshot,
1088
+ })
1089
+ .catch(() => undefined);
1090
+ const delivery = deliveryFromSignedContext(payload, {
1091
+ connector: request.connector,
1092
+ eventId: request.eventId,
1093
+ sessionId: request.sessionId,
1094
+ task: {
1095
+ agent: request.agent,
1096
+ ...(request.scope === undefined ? {} : { scope: request.scope }),
1097
+ task: request.currentUser.content,
1098
+ },
1099
+ });
1100
+ return { ...delivery, reliability };
662
1101
  }
663
- async function postPromptObservation(config, request, fetchImplementation) {
1102
+ async function postPromptObservation(config, request, fetchImplementation, _store) {
664
1103
  const response = await fetchImplementation(observationsUrl(config.apiUrl), {
665
1104
  method: "POST",
666
1105
  headers: {
1106
+ accept: runtimeAccept(config),
667
1107
  authorization: `Bearer ${config.token}`,
668
1108
  "content-type": "application/json",
669
1109
  "idempotency-key": request.idempotencyKey,
@@ -695,71 +1135,254 @@ async function postPromptObservation(config, request, fetchImplementation) {
695
1135
  signal: AbortSignal.timeout(config.timeoutMs ?? 2_500),
696
1136
  });
697
1137
  if (!response.ok) {
698
- throw new Error(`Lore prompt observation failed with HTTP ${response.status}`);
1138
+ throw new RuntimeHttpError(`Lore prompt observation failed with HTTP ${response.status}`, response.status);
1139
+ }
1140
+ const value = await protocolResponseJson(response);
1141
+ if (config.version === 1) {
1142
+ return;
1143
+ }
1144
+ if (!isObject(value) ||
1145
+ !isObject(value.observation) ||
1146
+ !isObject(value.observation.event) ||
1147
+ typeof value.observation.event.id !== "string" ||
1148
+ !committedAcknowledgement(value.capture, request.eventId, value.observation.event.id)) {
1149
+ throw new IncompatibleReliabilityError("Lore observation response has no matching durable acknowledgement");
699
1150
  }
700
- await response.body?.cancel();
701
1151
  }
702
- async function getPromptContext(config, agent, sessionId, sourceEventId, prompt, gitContext, fetchImplementation) {
1152
+ async function getPromptContext(config, agent, sessionId, sourceEventId, prompt, gitContext, fetchImplementation, store) {
1153
+ const deliveryEventId = deterministicUuid(`lore-context\0${sourceEventId}`);
1154
+ const deliveryRequest = {
1155
+ connector: "lore-cli",
1156
+ eventId: deliveryEventId,
1157
+ sessionId,
1158
+ task: {
1159
+ agent,
1160
+ ...(gitContext.scope === undefined
1161
+ ? {}
1162
+ : { scope: gitContext.scope }),
1163
+ task: redactSecrets(prompt),
1164
+ ...(gitContext.diff === undefined ? {} : { diff: gitContext.diff }),
1165
+ ...(gitContext.files === undefined
1166
+ ? {}
1167
+ : { files: gitContext.files }),
1168
+ },
1169
+ };
703
1170
  const response = await fetchImplementation(contextDeliveriesUrl(config.apiUrl), {
704
1171
  method: "POST",
705
1172
  headers: {
1173
+ accept: runtimeAccept(config),
706
1174
  authorization: `Bearer ${config.token}`,
707
1175
  "content-type": "application/json",
708
1176
  "user-agent": `lore-cli/${RUNTIME_VERSION}`,
709
1177
  },
710
- body: JSON.stringify({
711
- connector: "lore-cli",
712
- eventId: deterministicUuid(`lore-context\0${sourceEventId}`),
713
- sessionId,
714
- task: {
715
- agent,
716
- task: redactSecrets(prompt),
717
- ...(gitContext.scope === undefined
718
- ? {}
719
- : { scope: gitContext.scope }),
720
- ...(gitContext.diff === undefined ? {} : { diff: gitContext.diff }),
721
- ...(gitContext.files === undefined
722
- ? {}
723
- : { files: gitContext.files }),
724
- },
725
- }),
1178
+ body: JSON.stringify(deliveryRequest),
726
1179
  signal: AbortSignal.timeout(config.timeoutMs ?? 2_500),
727
1180
  });
728
1181
  if (!response.ok) {
729
- throw new Error(`Lore context request failed with HTTP ${response.status}`);
1182
+ throw new RuntimeHttpError(`Lore context request failed with HTTP ${response.status}`, response.status);
1183
+ }
1184
+ const value = await protocolResponseJson(response);
1185
+ if (config.version === 1) {
1186
+ return deliveryFromResponse(value);
1187
+ }
1188
+ if (!isObject(value) ||
1189
+ !isObject(value.delivery) ||
1190
+ !isObject(value.reliability) ||
1191
+ typeof value.snapshot !== "string" ||
1192
+ !isObject(value.delivery.event) ||
1193
+ typeof value.delivery.event.id !== "string" ||
1194
+ !isObject(value.delivery.receipt) ||
1195
+ typeof value.delivery.receipt.id !== "string") {
1196
+ throw new IncompatibleReliabilityError("Lore context response has no signed reliability snapshot");
1197
+ }
1198
+ const payload = await verifySnapshot(value.snapshot, "context", config, store, fetchImplementation, true);
1199
+ if (payload.eventId !== value.delivery.event.id ||
1200
+ payload.receiptId !== value.delivery.receipt.id) {
1201
+ throw new IncompatibleReliabilityError("Lore context snapshot does not bind the returned event and receipt");
1202
+ }
1203
+ const reliability = retrievalReliability(value.reliability, payload);
1204
+ if (Date.now() - Date.parse(payload.validUntil) >
1205
+ CONTEXT_CACHE_MAX_STALE_MS) {
1206
+ return {
1207
+ ...emptyDelivery(),
1208
+ reliability: {
1209
+ status: "failed",
1210
+ source: "none",
1211
+ fallback: "none",
1212
+ reasons: ["no_usable_cache"],
1213
+ freshness: {
1214
+ state: "unknown",
1215
+ asOf: null,
1216
+ ageMs: null,
1217
+ validUntil: null,
1218
+ },
1219
+ policyVersion: null,
1220
+ },
1221
+ };
730
1222
  }
731
- const text = await response.text();
732
- if (text.trim() === "") {
733
- return emptyDelivery();
1223
+ await store
1224
+ .writeContextSnapshot(sourceEventId, { compactJws: value.snapshot })
1225
+ .catch(() => undefined);
1226
+ return {
1227
+ ...deliveryFromSignedContext(payload, deliveryRequest),
1228
+ reliability,
1229
+ };
1230
+ }
1231
+ async function cachedSnapshotKeys(config, store, now) {
1232
+ if (config.workspaceId === undefined || store.workspaceId !== config.workspaceId) {
1233
+ return null;
1234
+ }
1235
+ const [keys, refreshState] = await Promise.all([
1236
+ store.readPublicTrustKeys(),
1237
+ store.readState(TRUST_KEY_REFRESH_STATE_KEY),
1238
+ ]);
1239
+ const refreshedAt = isObject(refreshState)
1240
+ ? refreshState.refreshedAt
1241
+ : undefined;
1242
+ const refreshedAtMs = typeof refreshedAt === "string" ? Date.parse(refreshedAt) : Number.NaN;
1243
+ if (keys.length === 0 ||
1244
+ !Number.isFinite(refreshedAtMs) ||
1245
+ now.getTime() - refreshedAtMs >= TRUST_KEY_REFRESH_TTL_MS ||
1246
+ refreshedAtMs > now.getTime() + 5 * 60_000) {
1247
+ return null;
734
1248
  }
735
- return deliveryFromResponse(JSON.parse(text));
1249
+ return { workspaceId: config.workspaceId, keys };
736
1250
  }
737
- function queueDirectory(home) {
738
- return resolve(loreDirectory(home), "queue");
1251
+ async function getCachedPromptContext(config, prompt, gitContext, store, now) {
1252
+ const keys = await cachedSnapshotKeys(config, store, now);
1253
+ if (keys === null || config.workspaceId === undefined) {
1254
+ return null;
1255
+ }
1256
+ const scan = await store.listContextSnapshots();
1257
+ const candidates = [];
1258
+ for (const record of scan.records) {
1259
+ const value = isObject(record.value) ? record.value : null;
1260
+ if (value === null || typeof value.compactJws !== "string") {
1261
+ continue;
1262
+ }
1263
+ try {
1264
+ const payload = verifySignedSnapshot(value.compactJws, {
1265
+ workspaceId: config.workspaceId,
1266
+ kind: "context",
1267
+ keys,
1268
+ allowExpired: true,
1269
+ now,
1270
+ }).payload;
1271
+ candidates.push({
1272
+ cacheKey: record.cacheKey,
1273
+ compactJws: value.compactJws,
1274
+ payload,
1275
+ });
1276
+ }
1277
+ catch {
1278
+ // One invalid cache entry must not hide another verified candidate.
1279
+ }
1280
+ }
1281
+ const task = {
1282
+ workspaceId: config.workspaceId,
1283
+ task: redactSecrets(prompt),
1284
+ ...(gitContext.scope === undefined ? {} : { scope: gitContext.scope }),
1285
+ ...(gitContext.files === undefined ? {} : { files: gitContext.files }),
1286
+ };
1287
+ const selected = selectCachedContext(candidates, task, now);
1288
+ if (selected === null) {
1289
+ return null;
1290
+ }
1291
+ return {
1292
+ context: selected.context,
1293
+ learned: [],
1294
+ delivered: [],
1295
+ reliability: {
1296
+ status: "degraded",
1297
+ source: "cache",
1298
+ fallback: "cached_context",
1299
+ reasons: ["live_unavailable", "cached_context"],
1300
+ freshness: selected.freshness,
1301
+ policyVersion: selected.payload.policyVersion,
1302
+ },
1303
+ };
739
1304
  }
740
- async function trimQueue(home) {
741
- const directory = queueDirectory(home);
742
- let entries;
1305
+ async function getPromptContextWithFallback(config, agent, sessionId, sourceEventId, prompt, gitContext, fetchImplementation, store, now) {
743
1306
  try {
744
- entries = (await readdir(directory))
745
- .filter((entry) => entry.endsWith(".json"))
746
- .sort();
1307
+ return await getPromptContext(config, agent, sessionId, sourceEventId, prompt, gitContext, fetchImplementation, store);
747
1308
  }
748
- catch {
749
- return;
1309
+ catch (error) {
1310
+ if (isContextTransportFailure(error)) {
1311
+ const cached = await getCachedPromptContext(config, prompt, gitContext, store, now).catch(() => null);
1312
+ if (cached !== null) {
1313
+ return cached;
1314
+ }
1315
+ }
1316
+ return {
1317
+ ...emptyDelivery(),
1318
+ reliability: {
1319
+ status: "failed",
1320
+ source: "none",
1321
+ fallback: "none",
1322
+ reasons: [
1323
+ ...(isContextTransportFailure(error) ? ["live_unavailable"] : []),
1324
+ isContextTransportFailure(error)
1325
+ ? "no_usable_cache"
1326
+ : "invalid_response",
1327
+ ],
1328
+ freshness: {
1329
+ state: "unknown",
1330
+ asOf: null,
1331
+ ageMs: null,
1332
+ validUntil: null,
1333
+ },
1334
+ policyVersion: null,
1335
+ },
1336
+ };
750
1337
  }
751
- const excess = entries.length - MAX_QUEUE_ITEMS + 1;
752
- if (excess <= 0) {
753
- return;
1338
+ }
1339
+ function formatAge(ageMs) {
1340
+ if (ageMs === null || ageMs < 60_000) {
1341
+ return "less than a minute";
1342
+ }
1343
+ if (ageMs < 60 * 60_000) {
1344
+ return `${Math.floor(ageMs / 60_000)}m`;
754
1345
  }
755
- await Promise.all(entries
756
- .slice(0, excess)
757
- .map(async (entry) => rm(resolve(directory, entry), { force: true })));
1346
+ if (ageMs < 24 * 60 * 60_000) {
1347
+ return `${Math.floor(ageMs / (60 * 60_000))}h`;
1348
+ }
1349
+ return `${Math.floor(ageMs / (24 * 60 * 60_000))}d`;
1350
+ }
1351
+ async function retrievalNotice(delivery, sessionId, store, now) {
1352
+ const reliability = delivery.reliability;
1353
+ if (reliability === undefined || reliability.status === "ok") {
1354
+ return undefined;
1355
+ }
1356
+ if (reliability.fallback === "cached_context") {
1357
+ return `Lore used verified ${reliability.freshness.state} cached context (${formatAge(reliability.freshness.ageMs)} old) because live retrieval was unavailable.`;
1358
+ }
1359
+ const notice = reliability.fallback === "live_lexical"
1360
+ ? "Lore used live lexical retrieval because semantic retrieval was unavailable."
1361
+ : reliability.reasons.includes("invalid_response")
1362
+ ? "Lore context could not be trusted; continuing without injected context."
1363
+ : "Lore context is temporarily unavailable; continuing without injected context.";
1364
+ const key = `context-notice:${sessionId}`;
1365
+ const previous = await store.readState(key).catch(() => null);
1366
+ if (isObject(previous) &&
1367
+ previous.notice === notice &&
1368
+ typeof previous.at === "string" &&
1369
+ now.getTime() - Date.parse(previous.at) < CONTEXT_NOTICE_COOLDOWN_MS) {
1370
+ return undefined;
1371
+ }
1372
+ await store
1373
+ .writeState(key, { notice, at: now.toISOString() }, now)
1374
+ .catch(() => undefined);
1375
+ return notice;
1376
+ }
1377
+ function queueDirectory(home) {
1378
+ return resolve(loreDirectory(home), "queue");
758
1379
  }
759
- async function enqueue(request, home) {
760
- await trimQueue(home);
761
- const path = resolve(queueDirectory(home), `${request.request.idempotencyKey}.json`);
762
- await atomicWriteJson(path, request);
1380
+ async function enqueueCapture(store, queued) {
1381
+ return (await store.enqueue({
1382
+ kind: queued.kind,
1383
+ idempotencyKey: queued.request.eventId,
1384
+ payload: queued,
1385
+ })).entry;
763
1386
  }
764
1387
  function isTurnRequest(value) {
765
1388
  return (isObject(value) &&
@@ -799,38 +1422,57 @@ function queuedRequest(value) {
799
1422
  }
800
1423
  return null;
801
1424
  }
802
- async function retryOne(config, fetchImplementation, home) {
803
- const directory = queueDirectory(home);
804
- let first;
805
- try {
806
- first = (await readdir(directory))
807
- .filter((entry) => entry.endsWith(".json"))
808
- .sort()[0];
809
- }
810
- catch {
811
- return;
812
- }
813
- if (first === undefined) {
814
- return;
1425
+ async function flushOne(config, fetchImplementation, store) {
1426
+ const claimed = await store.claimNext({
1427
+ workerId: `native-hook:${process.pid}`,
1428
+ kinds: ["turn", "prompt"],
1429
+ });
1430
+ if (claimed === null || claimed.claim === undefined) {
1431
+ return null;
815
1432
  }
816
- const path = resolve(directory, first);
817
1433
  try {
818
- const parsed = JSON.parse(await readFile(path, "utf8"));
819
- const queued = queuedRequest(parsed);
1434
+ const queued = queuedRequest(claimed.payload);
820
1435
  if (queued === null) {
821
- await rm(path, { force: true });
822
- return;
1436
+ const failed = await store.failClaim({
1437
+ claimId: claimed.claim.id,
1438
+ classification: "incompatible",
1439
+ message: "Durable capture payload is incompatible",
1440
+ });
1441
+ return {
1442
+ idempotencyKey: claimed.idempotencyKey,
1443
+ state: failed.state === "auth-blocked" ? "auth-blocked" : "dead",
1444
+ };
823
1445
  }
1446
+ let delivery;
824
1447
  if (queued.kind === "turn") {
825
- await postTurn(config, queued.request, fetchImplementation);
1448
+ delivery = await postTurn(config, queued.request, fetchImplementation, store);
826
1449
  }
827
1450
  else {
828
- await postPromptObservation(config, queued.request, fetchImplementation);
1451
+ await postPromptObservation(config, queued.request, fetchImplementation, store);
829
1452
  }
830
- await rm(path, { force: true });
1453
+ await store.acknowledgeClaim(claimed.claim.id);
1454
+ return {
1455
+ idempotencyKey: claimed.idempotencyKey,
1456
+ state: "acknowledged",
1457
+ ...(delivery === undefined ? {} : { delivery }),
1458
+ };
831
1459
  }
832
- catch {
833
- // Retry on a later prompt. Hook failures are deliberately invisible.
1460
+ catch (error) {
1461
+ const failed = await store.failClaim({
1462
+ claimId: claimed.claim.id,
1463
+ classification: error instanceof SignedSnapshotError
1464
+ ? "incompatible"
1465
+ : classifyRetryFailure(error),
1466
+ message: error instanceof Error ? error.message : "Lore upload failed",
1467
+ });
1468
+ return {
1469
+ idempotencyKey: claimed.idempotencyKey,
1470
+ state: failed.state === "auth-blocked"
1471
+ ? "auth-blocked"
1472
+ : failed.state === "dead"
1473
+ ? "dead"
1474
+ : "ready",
1475
+ };
834
1476
  }
835
1477
  }
836
1478
  function receiptMessage(config, agent, delivery) {
@@ -979,6 +1621,7 @@ function guardResultFromResponse(value) {
979
1621
  }
980
1622
  const scope = isObject(item.scope) ? item.scope : {};
981
1623
  const source = isObject(item.source) ? item.source : {};
1624
+ const explanation = guardExplanationFromValue(item.explanation);
982
1625
  return [
983
1626
  {
984
1627
  content: redactSecrets(item.content),
@@ -992,6 +1635,7 @@ function guardResultFromResponse(value) {
992
1635
  ...(typeof source.agent === "string"
993
1636
  ? { sourceAgent: source.agent }
994
1637
  : {}),
1638
+ ...(explanation === undefined ? {} : { explanation }),
995
1639
  },
996
1640
  ];
997
1641
  })
@@ -1013,6 +1657,33 @@ function guardResultFromResponse(value) {
1013
1657
  conflictSummaries,
1014
1658
  };
1015
1659
  }
1660
+ function guardExplanationFromValue(value) {
1661
+ if (!isObject(value) ||
1662
+ typeof value.summary !== "string" ||
1663
+ !isObject(value.provenance) ||
1664
+ typeof value.provenance.agent !== "string" ||
1665
+ typeof value.status !== "string" ||
1666
+ !isObject(value.freshness) ||
1667
+ typeof value.freshness.updatedAt !== "string") {
1668
+ return undefined;
1669
+ }
1670
+ const scope = isObject(value.scope) ? value.scope : {};
1671
+ const scopeParts = [
1672
+ typeof scope.organization === "string"
1673
+ ? `org ${scope.organization}`
1674
+ : undefined,
1675
+ typeof scope.project === "string" ? `project ${scope.project}` : undefined,
1676
+ typeof scope.repo === "string" ? `repo ${scope.repo}` : undefined,
1677
+ typeof scope.path === "string" ? `path ${scope.path}` : undefined,
1678
+ typeof scope.component === "string"
1679
+ ? `component ${scope.component}`
1680
+ : undefined,
1681
+ ].filter((part) => part !== undefined);
1682
+ const session = typeof value.provenance.sessionId === "string"
1683
+ ? ` session ${value.provenance.sessionId}`
1684
+ : "";
1685
+ return `Why: ${redactSecrets(value.summary)} Source: ${value.provenance.agent}${session}. Scope: ${scopeParts.join(" · ") || "workspace-wide"}. Status: ${value.status}. Updated: ${value.freshness.updatedAt.slice(0, 10)}.`;
1686
+ }
1016
1687
  function guardItemSource(item) {
1017
1688
  const scope = item.repo !== undefined
1018
1689
  ? `${item.repo} repo`
@@ -1027,10 +1698,7 @@ function guardAssistContext(result) {
1027
1698
  const lines = ["Relevant Lore context:"];
1028
1699
  for (const item of result.items) {
1029
1700
  lines.push(`- ${item.content}`);
1030
- }
1031
- lines.push("", "Sources:");
1032
- for (const item of result.items) {
1033
- lines.push(`- ${guardItemSource(item)}`);
1701
+ lines.push(` ${item.explanation ?? `Source: ${guardItemSource(item)}.`}`);
1034
1702
  }
1035
1703
  if (result.conflictSummaries.length > 0) {
1036
1704
  lines.push("", "Conflicting Lore context (do not silently pick a winner):");
@@ -1045,17 +1713,21 @@ function guardConfirmationReason(result) {
1045
1713
  const shown = required.length > 0 ? required : result.items;
1046
1714
  const lines = [
1047
1715
  "Lore Guard: this action conflicts with a required rule.",
1048
- ...shown.map((item) => `- ${item.content} (${guardItemSource(item)})`),
1716
+ ...shown.flatMap((item) => [
1717
+ `- ${item.content}`,
1718
+ ` ${item.explanation ?? `Source: ${guardItemSource(item)}.`}`,
1719
+ ]),
1049
1720
  ];
1050
1721
  lines.push(result.confirmCommand === undefined
1051
1722
  ? "Ask the user to confirm before proceeding."
1052
1723
  : `Approve here to proceed, or the user can run: ${result.confirmCommand}`);
1053
1724
  return boundedContext(lines.join("\n"));
1054
1725
  }
1055
- async function postGuardCheck(config, request, fetchImplementation) {
1726
+ async function postGuardCheck(config, request, fetchImplementation, store) {
1056
1727
  const response = await fetchImplementation(guardCheckUrl(config.apiUrl), {
1057
1728
  method: "POST",
1058
1729
  headers: {
1730
+ accept: runtimeAccept(config),
1059
1731
  authorization: `Bearer ${config.token}`,
1060
1732
  "content-type": "application/json",
1061
1733
  "user-agent": `lore-cli/${RUNTIME_VERSION}`,
@@ -1064,9 +1736,26 @@ async function postGuardCheck(config, request, fetchImplementation) {
1064
1736
  signal: AbortSignal.timeout(Math.min(config.timeoutMs ?? GUARD_TIMEOUT_MS, GUARD_TIMEOUT_MS)),
1065
1737
  });
1066
1738
  if (!response.ok) {
1067
- throw new Error(`Lore guard check failed with HTTP ${response.status}`);
1068
- }
1069
- return guardResultFromResponse(JSON.parse(await response.text()));
1739
+ throw new RuntimeHttpError(`Lore guard check failed with HTTP ${response.status}`, response.status);
1740
+ }
1741
+ const value = await protocolResponseJson(response);
1742
+ if (config.version === 1) {
1743
+ return guardResultFromResponse(value);
1744
+ }
1745
+ if (!isObject(value) ||
1746
+ !isObject(value.check) ||
1747
+ typeof value.policyVersion !== "string" ||
1748
+ typeof value.policySnapshot !== "string") {
1749
+ throw new IncompatibleReliabilityError("Lore Guard response has no signed policy snapshot");
1750
+ }
1751
+ const payload = await verifySnapshot(value.policySnapshot, "guard_policy", config, store, fetchImplementation);
1752
+ if (payload.policyVersion !== value.policyVersion ||
1753
+ (config.workspaceId !== undefined &&
1754
+ payload.workspaceId !== config.workspaceId)) {
1755
+ throw new IncompatibleReliabilityError("Lore Guard snapshot does not bind the returned policy");
1756
+ }
1757
+ await store.writePolicySnapshot("current", value.policySnapshot);
1758
+ return guardResultFromResponse(value.check);
1070
1759
  }
1071
1760
  async function handleGuardEvent(input, agent, config, sessionId, options, now) {
1072
1761
  if (agent === "polytoken") {
@@ -1081,10 +1770,12 @@ async function handleGuardEvent(input, agent, config, sessionId, options, now) {
1081
1770
  if (trigger === null) {
1082
1771
  return undefined;
1083
1772
  }
1773
+ const store = await runtimeStore(config, options.home);
1084
1774
  const state = await readGuardState(agent, sessionId, options.home);
1085
1775
  if (state.mode === "off" &&
1086
1776
  state.modeCheckedAt !== undefined &&
1087
1777
  now.getTime() - Date.parse(state.modeCheckedAt) < GUARD_MODE_TTL_MS) {
1778
+ await recordLocalGuardMetric(store, { outcome: "reused_decision" }, now).catch(() => undefined);
1088
1779
  return undefined;
1089
1780
  }
1090
1781
  const cwd = stringField(input.cwd);
@@ -1112,24 +1803,38 @@ async function handleGuardEvent(input, agent, config, sessionId, options, now) {
1112
1803
  const cachedAt = state.keys[key];
1113
1804
  if (cachedAt !== undefined &&
1114
1805
  now.getTime() - Date.parse(cachedAt) < GUARD_KEY_COOLDOWN_MS) {
1806
+ await recordLocalGuardMetric(store, { outcome: "reused_decision" }, now).catch(() => undefined);
1115
1807
  return undefined;
1116
1808
  }
1117
- const result = await postGuardCheck(config, {
1118
- connector: "lore-cli",
1119
- agent,
1120
- sessionId,
1121
- action: trigger.action,
1122
- tool: toolName,
1123
- ...(scope?.repo === undefined ? {} : { repo: scope.repo }),
1124
- ...(scope?.path === undefined ? {} : { path: scope.path }),
1125
- ...(files.length === 0 ? {} : { files }),
1126
- ...(trigger.command === undefined
1127
- ? {}
1128
- : { command: redactSecrets(trigger.command).slice(0, 10_000) }),
1129
- }, options.fetch ?? globalThis.fetch);
1809
+ const startedAt = Date.now();
1810
+ let result;
1811
+ try {
1812
+ result = await postGuardCheck(config, {
1813
+ connector: "lore-cli",
1814
+ agent,
1815
+ sessionId,
1816
+ action: trigger.action,
1817
+ tool: toolName,
1818
+ ...(scope?.repo === undefined ? {} : { repo: scope.repo }),
1819
+ ...(scope?.path === undefined ? {} : { path: scope.path }),
1820
+ ...(files.length === 0 ? {} : { files }),
1821
+ ...(trigger.command === undefined
1822
+ ? {}
1823
+ : { command: redactSecrets(trigger.command).slice(0, 10_000) }),
1824
+ }, options.fetch ?? globalThis.fetch, store);
1825
+ }
1826
+ catch (error) {
1827
+ await recordLocalGuardMetric(store, { outcome: "failed" }, now).catch(() => undefined);
1828
+ throw error;
1829
+ }
1130
1830
  if (result === null) {
1131
- return undefined;
1831
+ await recordLocalGuardMetric(store, { outcome: "failed" }, now).catch(() => undefined);
1832
+ throw new IncompatibleReliabilityError("Lore Guard response is invalid");
1132
1833
  }
1834
+ await recordLocalGuardMetric(store, {
1835
+ outcome: "live_check",
1836
+ durationMs: Math.max(0, Date.now() - startedAt),
1837
+ }, now).catch(() => undefined);
1133
1838
  state.mode = result.mode;
1134
1839
  state.modeCheckedAt = now.toISOString();
1135
1840
  if (!result.requiresConfirmation) {
@@ -1174,14 +1879,16 @@ async function handleGuardEvent(input, agent, config, sessionId, options, now) {
1174
1879
  }
1175
1880
  export async function handleHookEvent(value, agent, options = {}) {
1176
1881
  if (!isObject(value)) {
1177
- return undefined;
1882
+ throw new InvalidHookInputError();
1178
1883
  }
1179
1884
  const normalized = normalizeHookInput(value, agent, options.environment ?? process.env);
1180
1885
  const input = normalized.input;
1181
1886
  const eventName = normalized.eventName;
1182
1887
  const sessionId = normalized.sessionId;
1183
- if (eventName === undefined || sessionId === undefined) {
1184
- return undefined;
1888
+ if (eventName === undefined ||
1889
+ sessionId === undefined ||
1890
+ !validHookInput(input, agent, eventName)) {
1891
+ throw new InvalidHookInputError();
1185
1892
  }
1186
1893
  const config = await readRuntimeConfig(options.home);
1187
1894
  if (config === null || !config.agents.includes(agent)) {
@@ -1203,20 +1910,65 @@ export async function handleHookEvent(value, agent, options = {}) {
1203
1910
  return await handleGuardEvent(input, agent, config, sessionId, options, now);
1204
1911
  }
1205
1912
  catch {
1206
- // Guard checks fail open: never stall or break a tool call.
1207
- return undefined;
1913
+ const warning = "Lore Guard is unavailable; this action is proceeding without a live policy decision.";
1914
+ if (agent === "cursor") {
1915
+ return {
1916
+ userMessage: warning,
1917
+ agentMessage: warning,
1918
+ };
1919
+ }
1920
+ return {
1921
+ hookSpecificOutput: {
1922
+ hookEventName: "PreToolUse",
1923
+ additionalContext: warning,
1924
+ },
1925
+ };
1208
1926
  }
1209
1927
  }
1210
- if (eventName !== "UserPromptSubmit") {
1211
- return undefined;
1212
- }
1213
1928
  const fetchImplementation = options.fetch ?? globalThis.fetch;
1214
- await retryOne(config, fetchImplementation, options.home);
1215
- const pending = await consumePending(agent, sessionId, options.home);
1216
1929
  const prompt = stringField(input.prompt);
1217
1930
  if (prompt === undefined) {
1218
- return undefined;
1931
+ throw new InvalidHookInputError();
1932
+ }
1933
+ const notices = [];
1934
+ let store;
1935
+ try {
1936
+ store = await runtimeStore(config, options.home);
1937
+ }
1938
+ catch (error) {
1939
+ const detail = error instanceof ReliabilityStoreError &&
1940
+ error.code === "CAPACITY_EXCEEDED"
1941
+ ? "the local outbox is full"
1942
+ : "local durable storage is unavailable";
1943
+ notices.push(`Lore could not acknowledge this capture because ${detail}. Run lore doctor.`);
1944
+ const notice = notices.join(" ");
1945
+ return agent === "polytoken"
1946
+ ? { outcome: "accept", additional_context: notice }
1947
+ : agent === "cursor"
1948
+ ? {
1949
+ continue: true,
1950
+ hookSpecificOutput: {
1951
+ hookEventName: "UserPromptSubmit",
1952
+ additionalContext: notice,
1953
+ },
1954
+ }
1955
+ : { systemMessage: notice };
1219
1956
  }
1957
+ const noticeForFlush = (result) => {
1958
+ if (result?.state === "auth-blocked") {
1959
+ notices.push("Lore retained a capture locally, but upload is blocked by authentication. Run lore connect again.");
1960
+ }
1961
+ else if (result?.state === "dead") {
1962
+ notices.push("Lore retained a capture as a dead letter because the server response was incompatible. Run lore doctor.");
1963
+ }
1964
+ };
1965
+ try {
1966
+ noticeForFlush(await flushOne(config, fetchImplementation, store));
1967
+ }
1968
+ catch {
1969
+ notices.push("Lore retained queued captures locally, but replay could not run. Run lore doctor.");
1970
+ }
1971
+ const pending = await readPending(agent, sessionId, options.home);
1220
1972
  const lineage = await readLineageMetadata(options.home, options.environment ?? process.env);
1221
1973
  let delivery = emptyDelivery();
1222
1974
  if (pending !== null) {
@@ -1224,26 +1976,61 @@ export async function handleHookEvent(value, agent, options = {}) {
1224
1976
  if (request === null) {
1225
1977
  return undefined;
1226
1978
  }
1979
+ let enqueued = false;
1227
1980
  try {
1228
- delivery = await postTurn(config, request, fetchImplementation);
1981
+ await enqueueCapture(store, { kind: "turn", request });
1982
+ enqueued = true;
1983
+ await clearPending(agent, sessionId, options.home);
1229
1984
  }
1230
- catch {
1985
+ catch (error) {
1986
+ notices.push(error instanceof ReliabilityStoreError &&
1987
+ error.code === "CAPACITY_EXCEEDED"
1988
+ ? "Lore could not acknowledge this capture because the local outbox is full. Run lore status."
1989
+ : "Lore could not acknowledge this capture because local persistence failed. Run lore doctor.");
1990
+ }
1991
+ if (enqueued) {
1231
1992
  try {
1232
- await enqueue({ kind: "turn", request }, options.home);
1993
+ const flushed = await flushOne(config, fetchImplementation, store);
1994
+ noticeForFlush(flushed);
1995
+ if (flushed?.idempotencyKey === request.eventId &&
1996
+ flushed.state === "acknowledged" &&
1997
+ flushed.delivery !== undefined) {
1998
+ delivery = flushed.delivery;
1999
+ }
2000
+ else {
2001
+ notices.push("Lore saved this capture locally; server upload is pending.");
2002
+ }
1233
2003
  }
1234
2004
  catch {
1235
- // The connector must fail open even when its local queue is unavailable.
2005
+ notices.push("Lore saved this capture locally; server upload is pending.");
1236
2006
  }
2007
+ }
2008
+ if (delivery.context === "") {
1237
2009
  try {
1238
- delivery = await getPromptContext(config, agent, sessionId, request.eventId, prompt, {
2010
+ delivery = await getPromptContextWithFallback(config, agent, sessionId, request.eventId, prompt, {
1239
2011
  ...(request.scope === undefined ? {} : { scope: request.scope }),
1240
2012
  learningScope: request.learningScope,
1241
2013
  ...(request.diff === undefined ? {} : { diff: request.diff }),
1242
2014
  ...(request.files === undefined ? {} : { files: request.files }),
1243
- }, fetchImplementation);
2015
+ }, fetchImplementation, store, now);
1244
2016
  }
1245
2017
  catch {
1246
- return undefined;
2018
+ delivery = {
2019
+ ...emptyDelivery(),
2020
+ reliability: {
2021
+ status: "failed",
2022
+ source: "none",
2023
+ fallback: "none",
2024
+ reasons: ["invalid_response"],
2025
+ freshness: {
2026
+ state: "unknown",
2027
+ asOf: null,
2028
+ ageMs: null,
2029
+ validUntil: null,
2030
+ },
2031
+ policyVersion: null,
2032
+ },
2033
+ };
1247
2034
  }
1248
2035
  }
1249
2036
  }
@@ -1253,45 +2040,99 @@ export async function handleHookEvent(value, agent, options = {}) {
1253
2040
  if (observation === null) {
1254
2041
  return undefined;
1255
2042
  }
2043
+ let enqueued = false;
1256
2044
  try {
1257
- await postPromptObservation(config, observation, fetchImplementation);
2045
+ await enqueueCapture(store, { kind: "prompt", request: observation });
2046
+ enqueued = true;
1258
2047
  }
1259
- catch {
2048
+ catch (error) {
2049
+ notices.push(error instanceof ReliabilityStoreError &&
2050
+ error.code === "CAPACITY_EXCEEDED"
2051
+ ? "Lore could not acknowledge this capture because the local outbox is full. Run lore status."
2052
+ : "Lore could not acknowledge this capture because local persistence failed. Run lore doctor.");
2053
+ }
2054
+ if (enqueued) {
1260
2055
  try {
1261
- await enqueue({ kind: "prompt", request: observation }, options.home);
2056
+ const flushed = await flushOne(config, fetchImplementation, store);
2057
+ noticeForFlush(flushed);
2058
+ if (flushed?.idempotencyKey !== observation.eventId ||
2059
+ flushed.state !== "acknowledged") {
2060
+ notices.push("Lore saved this capture locally; server upload is pending.");
2061
+ }
1262
2062
  }
1263
2063
  catch {
1264
- // The connector must fail open even when its local queue is unavailable.
2064
+ notices.push("Lore saved this capture locally; server upload is pending.");
1265
2065
  }
1266
2066
  }
1267
2067
  try {
1268
- delivery = await getPromptContext(config, agent, sessionId, observation.eventId, prompt, gitContext, fetchImplementation);
2068
+ delivery = await getPromptContextWithFallback(config, agent, sessionId, observation.eventId, prompt, gitContext, fetchImplementation, store, now);
1269
2069
  }
1270
2070
  catch {
1271
- return undefined;
2071
+ delivery = {
2072
+ ...emptyDelivery(),
2073
+ reliability: {
2074
+ status: "failed",
2075
+ source: "none",
2076
+ fallback: "none",
2077
+ reasons: ["invalid_response"],
2078
+ freshness: {
2079
+ state: "unknown",
2080
+ asOf: null,
2081
+ ageMs: null,
2082
+ validUntil: null,
2083
+ },
2084
+ policyVersion: null,
2085
+ },
2086
+ };
1272
2087
  }
1273
2088
  }
2089
+ const reliability = delivery.reliability;
2090
+ await recordLocalRetrievalMetric(store, {
2091
+ outcome: reliability?.fallback === "cached_context"
2092
+ ? "cached_fallback"
2093
+ : reliability?.fallback === "live_lexical"
2094
+ ? "live_lexical_fallback"
2095
+ : reliability?.status === "failed" ||
2096
+ reliability?.source === "none"
2097
+ ? "failed"
2098
+ : "live_primary",
2099
+ ...(reliability?.freshness.ageMs === undefined
2100
+ ? {}
2101
+ : { cacheAgeMs: reliability.freshness.ageMs }),
2102
+ }, now).catch(() => undefined);
2103
+ const contextReliabilityNotice = await retrievalNotice(delivery, sessionId, store, now);
2104
+ if (contextReliabilityNotice !== undefined) {
2105
+ notices.push(contextReliabilityNotice);
2106
+ }
2107
+ const uniqueNotices = [...new Set(notices)];
2108
+ const reliabilityNotice = uniqueNotices.length === 0 ? undefined : uniqueNotices.join(" ");
2109
+ const receipt = receiptMessage(config, agent, delivery);
1274
2110
  const systemMessage = agent === "cursor" || agent === "polytoken"
1275
2111
  ? undefined
1276
- : receiptMessage(config, agent, delivery);
1277
- if (delivery.context === "" && systemMessage === undefined) {
2112
+ : [receipt, reliabilityNotice].filter(Boolean).join(" · ") || undefined;
2113
+ const injectedContext = agent === "cursor" || agent === "polytoken"
2114
+ ? [delivery.context, reliabilityNotice]
2115
+ .filter((entry) => entry !== undefined && entry !== "")
2116
+ .join("\n\n")
2117
+ : delivery.context;
2118
+ if (injectedContext === "" && systemMessage === undefined) {
1278
2119
  return undefined;
1279
2120
  }
1280
2121
  if (agent === "polytoken") {
1281
2122
  return {
1282
2123
  outcome: "accept",
1283
- additional_context: delivery.context,
2124
+ additional_context: injectedContext,
1284
2125
  };
1285
2126
  }
1286
2127
  return {
1287
2128
  ...(agent === "cursor" ? { continue: true } : {}),
1288
2129
  ...(systemMessage === undefined ? {} : { systemMessage }),
1289
- ...(delivery.context === ""
2130
+ ...(injectedContext === ""
1290
2131
  ? {}
1291
2132
  : {
1292
2133
  hookSpecificOutput: {
1293
2134
  hookEventName: "UserPromptSubmit",
1294
- additionalContext: delivery.context,
2135
+ additionalContext: injectedContext,
1295
2136
  },
1296
2137
  }),
1297
2138
  };
@@ -1312,25 +2153,73 @@ function parseAgent(args) {
1312
2153
  const value = index < 0 ? undefined : args[index + 1];
1313
2154
  return isCommandHookAgent(value) ? value : null;
1314
2155
  }
2156
+ function nativeIntegrationId(agent) {
2157
+ return `native/${agent}`;
2158
+ }
2159
+ async function beginHookInvocation(agent) {
2160
+ const config = await readRuntimeConfig();
2161
+ if (config === null || !config.agents.includes(agent)) {
2162
+ return null;
2163
+ }
2164
+ const workspaceKey = reliabilityWorkspaceKey(config);
2165
+ const home = homeDirectory();
2166
+ const attempt = createIntegrationInvocationAttempt(nativeIntegrationId(agent), { runtimeVersion: RUNTIME_VERSION });
2167
+ const recorded = await writeInvocationAttemptBounded(workspaceKey, attempt, { home, standalone: IS_STANDALONE_RUNTIME });
2168
+ return recorded ? { workspaceKey, home, attempt } : null;
2169
+ }
2170
+ async function finishHookInvocation(tracker, outcome) {
2171
+ if (tracker === null) {
2172
+ return;
2173
+ }
2174
+ await writeInvocationCompletionBounded(tracker.workspaceKey, tracker.attempt, outcome, { home: tracker.home, standalone: IS_STANDALONE_RUNTIME }).catch(() => false);
2175
+ }
1315
2176
  export async function runHook(args = process.argv.slice(2)) {
1316
2177
  const agent = parseAgent(args);
1317
2178
  if (agent === null) {
1318
2179
  return;
1319
2180
  }
2181
+ const tracker = await beginHookInvocation(agent).catch(() => null);
2182
+ let input;
2183
+ try {
2184
+ input = await readStdin();
2185
+ }
2186
+ catch {
2187
+ await finishHookInvocation(tracker, {
2188
+ success: false,
2189
+ failureCode: "invalid_input",
2190
+ });
2191
+ return;
2192
+ }
1320
2193
  try {
1321
- const result = await handleHookEvent(await readStdin(), agent);
2194
+ const result = await handleHookEvent(input, agent);
1322
2195
  if (result !== undefined) {
1323
2196
  process.stdout.write(`${JSON.stringify(result)}\n`);
1324
2197
  }
1325
- }
1326
- catch {
2198
+ await finishHookInvocation(tracker, { success: true });
2199
+ }
2200
+ catch (error) {
2201
+ await finishHookInvocation(tracker, {
2202
+ success: false,
2203
+ failureCode: error instanceof InvalidHookInputError
2204
+ ? "invalid_input"
2205
+ : "runtime_error",
2206
+ });
1327
2207
  // Native hooks must never block or add error noise to an agent session.
1328
2208
  }
1329
2209
  }
1330
2210
  const entryPath = process.argv[1];
2211
+ const isRuntimeEntrypoint = entryPath !== undefined &&
2212
+ (() => {
2213
+ try {
2214
+ return (realpathSync(resolve(entryPath)) ===
2215
+ realpathSync(fileURLToPath(import.meta.url)));
2216
+ }
2217
+ catch {
2218
+ return import.meta.url === pathToFileURL(resolve(entryPath)).href;
2219
+ }
2220
+ })();
1331
2221
  if (!IS_STANDALONE_RUNTIME &&
1332
- entryPath !== undefined &&
1333
- import.meta.url === pathToFileURL(resolve(entryPath)).href) {
2222
+ isRuntimeEntrypoint) {
1334
2223
  void runHook();
1335
2224
  }
1336
2225
  //# sourceMappingURL=runtime.js.map