@lore-co/cli 0.1.17 → 0.1.19

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 (46) hide show
  1. package/README.md +99 -17
  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 +37 -2
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +1071 -80
  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 +9 -1
  34. package/dist/runtime.d.ts.map +1 -1
  35. package/dist/runtime.js +1113 -153
  36. package/dist/runtime.js.map +1 -1
  37. package/dist/self-host.d.ts +15 -2
  38. package/dist/self-host.d.ts.map +1 -1
  39. package/dist/self-host.js +55 -8
  40. package/dist/self-host.js.map +1 -1
  41. package/dist/signed-snapshot.d.ts +59 -0
  42. package/dist/signed-snapshot.d.ts.map +1 -0
  43. package/dist/signed-snapshot.js +303 -0
  44. package/dist/signed-snapshot.js.map +1 -0
  45. package/dist/update.js +3 -3
  46. package/package.json +19 -3
package/dist/runtime.js CHANGED
@@ -1,19 +1,23 @@
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",
16
18
  "claude",
19
+ "copilot-cli",
20
+ "copilot-vscode",
17
21
  "cursor",
18
22
  "polytoken",
19
23
  ];
@@ -22,11 +26,33 @@ function isCommandHookAgent(value) {
22
26
  return typeof value === "string" && COMMAND_HOOK_AGENTS.has(value);
23
27
  }
24
28
  const MAX_STDIN_BYTES = 1024 * 1024;
29
+ const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
25
30
  const MAX_CONTEXT_CHARS = 12_000;
26
31
  const PENDING_ASSISTANT_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
27
- const MAX_QUEUE_ITEMS = 100;
28
32
  const MAX_GIT_DIFF_BYTES = 256 * 1024;
29
33
  const MAX_GIT_FILES = 100;
34
+ const RELIABILITY_V1_MEDIA_TYPE = "application/vnd.lore.reliability-v1+json";
35
+ const TRUST_KEY_REFRESH_TTL_MS = 60 * 60_000;
36
+ const TRUST_KEY_REFRESH_STATE_KEY = "snapshot-trust-keys";
37
+ const CONTEXT_NOTICE_COOLDOWN_MS = 15 * 60_000;
38
+ const RELIABILITY_REASON_CODES = new Set([
39
+ "live_success",
40
+ "idempotent_replay",
41
+ "semantic_unavailable",
42
+ "lexical_fallback",
43
+ "live_unavailable",
44
+ "cached_context",
45
+ "cached_policy",
46
+ "no_usable_cache",
47
+ "capture_queued",
48
+ "local_persistence_failed",
49
+ "governed_rule",
50
+ "policy_unavailable",
51
+ "policy_expired",
52
+ "user_override",
53
+ "invalid_response",
54
+ "integration_unsupported",
55
+ ]);
30
56
  const NON_SECRET_TOKEN_VALUES = new Set([
31
57
  "available",
32
58
  "configured",
@@ -81,6 +107,18 @@ export async function readLineageMetadata(home, environment = process.env) {
81
107
  function isObject(value) {
82
108
  return typeof value === "object" && value !== null && !Array.isArray(value);
83
109
  }
110
+ function canonicalJson(value) {
111
+ if (Array.isArray(value)) {
112
+ return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
113
+ }
114
+ if (isObject(value)) {
115
+ return `{${Object.keys(value)
116
+ .sort()
117
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
118
+ .join(",")}}`;
119
+ }
120
+ return JSON.stringify(value) ?? "null";
121
+ }
84
122
  function stringField(value) {
85
123
  return typeof value === "string" && value.trim() !== ""
86
124
  ? value
@@ -89,16 +127,54 @@ function stringField(value) {
89
127
  function normalizeHookInput(input, agent, environment) {
90
128
  const rawEventName = stringField(input.hook_event_name) ??
91
129
  stringField(input.event) ??
130
+ (agent === "copilot-cli"
131
+ ? stringField(environment.LORE_HOOK_EVENT)
132
+ : undefined) ??
92
133
  (agent === "polytoken"
93
134
  ? stringField(environment.POLYTOKEN_HOOK_EVENT)
94
135
  : undefined);
95
- if (agent !== "cursor" && agent !== "polytoken") {
136
+ if (agent !== "cursor" &&
137
+ agent !== "polytoken" &&
138
+ agent !== "copilot-cli") {
96
139
  return {
97
140
  input,
98
141
  eventName: rawEventName,
99
142
  sessionId: stringField(input.session_id),
100
143
  };
101
144
  }
145
+ if (agent === "copilot-cli") {
146
+ const eventName = rawEventName === "userPromptTransformed"
147
+ ? "UserPromptSubmit"
148
+ : rawEventName === "preToolUse"
149
+ ? "PreToolUse"
150
+ : rawEventName === "agentStop"
151
+ ? "Stop"
152
+ : rawEventName === "sessionEnd"
153
+ ? "SessionEnd"
154
+ : rawEventName;
155
+ const sessionId = stringField(input.sessionId) ?? stringField(input.session_id);
156
+ return {
157
+ input: {
158
+ ...input,
159
+ ...(rawEventName === undefined ? {} : { event: rawEventName }),
160
+ ...(sessionId === undefined ? {} : { session_id: sessionId }),
161
+ ...(stringField(input.transformedPrompt) === undefined
162
+ ? {}
163
+ : { transformed_prompt: input.transformedPrompt }),
164
+ ...(eventName === "PreToolUse"
165
+ ? {
166
+ tool_name: input.toolName,
167
+ tool_input: input.toolArgs,
168
+ }
169
+ : {}),
170
+ ...(eventName === "Stop"
171
+ ? { last_assistant_message: input.response }
172
+ : {}),
173
+ },
174
+ eventName,
175
+ sessionId,
176
+ };
177
+ }
102
178
  if (agent === "polytoken") {
103
179
  const eventName = rawEventName === "pre_user_prompt"
104
180
  ? "UserPromptSubmit"
@@ -181,6 +257,48 @@ function normalizeHookInput(input, agent, environment) {
181
257
  sessionId,
182
258
  };
183
259
  }
260
+ class InvalidHookInputError extends Error {
261
+ constructor() {
262
+ super("Hook input does not match the configured agent event");
263
+ this.name = "InvalidHookInputError";
264
+ }
265
+ }
266
+ function validHookInput(input, agent, eventName) {
267
+ const recognized = agent === "polytoken"
268
+ ? eventName === "UserPromptSubmit" || eventName === "AssistantResponse"
269
+ : agent === "cursor"
270
+ ? eventName === "UserPromptSubmit" ||
271
+ eventName === "AssistantResponse" ||
272
+ eventName === "PreToolUse" ||
273
+ eventName === "SessionEnd"
274
+ : eventName === "UserPromptSubmit" ||
275
+ eventName === "PreToolUse" ||
276
+ eventName === "Stop" ||
277
+ eventName === "SessionEnd";
278
+ if (!recognized) {
279
+ return false;
280
+ }
281
+ if (eventName === "UserPromptSubmit") {
282
+ return stringField(input.prompt) !== undefined;
283
+ }
284
+ if (eventName === "Stop" || eventName === "AssistantResponse") {
285
+ return stringField(input.last_assistant_message) !== undefined;
286
+ }
287
+ if (eventName === "PreToolUse") {
288
+ const toolName = stringField(input.tool_name);
289
+ if (toolName === undefined || !isObject(input.tool_input)) {
290
+ return false;
291
+ }
292
+ if (GUARD_EDIT_TOOLS.has(toolName)) {
293
+ return true;
294
+ }
295
+ if (GUARD_SHELL_TOOLS.has(toolName)) {
296
+ return stringField(input.tool_input.command) !== undefined;
297
+ }
298
+ return true;
299
+ }
300
+ return true;
301
+ }
184
302
  function sha256(value) {
185
303
  return createHash("sha256").update(value).digest("hex");
186
304
  }
@@ -227,22 +345,16 @@ export function redactSecrets(text) {
227
345
  })
228
346
  .replace(/\b((?:set-)?cookie\s*:\s*)[^\r\n]+/giu, (_match, prefix) => `${prefix}[REDACTED:AUTHORIZATION]`);
229
347
  }
230
- async function atomicWriteJson(path, value, mode = 0o600) {
348
+ async function atomicWriteJson(path, value) {
231
349
  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);
350
+ await chmod(dirname(path), 0o700);
351
+ await durableAtomicWriteJson(path, value);
240
352
  }
241
353
  async function readRuntimeConfig(home) {
242
354
  try {
243
355
  const parsed = JSON.parse(await readFile(resolve(loreDirectory(home), "config.json"), "utf8"));
244
356
  if (!isObject(parsed) ||
245
- parsed.version !== 1 ||
357
+ (parsed.version !== 1 && parsed.version !== 2) ||
246
358
  typeof parsed.apiUrl !== "string" ||
247
359
  typeof parsed.token !== "string" ||
248
360
  !Array.isArray(parsed.agents)) {
@@ -259,10 +371,15 @@ async function readRuntimeConfig(home) {
259
371
  /^https?:\/\//u.test(parsed.dashboardUrl)
260
372
  ? parsed.dashboardUrl.replace(/\/+$/u, "")
261
373
  : undefined;
374
+ const workspaceId = typeof parsed.workspaceId === "string" &&
375
+ /^[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)
376
+ ? parsed.workspaceId
377
+ : undefined;
262
378
  return {
263
- version: 1,
379
+ version: parsed.version,
264
380
  apiUrl: parsed.apiUrl,
265
381
  ...(dashboardUrl === undefined ? {} : { dashboardUrl }),
382
+ ...(workspaceId === undefined ? {} : { workspaceId }),
266
383
  token: parsed.token,
267
384
  agents,
268
385
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
@@ -291,11 +408,10 @@ async function savePending(input, agent, sessionId, now, home) {
291
408
  capturedAt: now.toISOString(),
292
409
  });
293
410
  }
294
- async function consumePending(agent, sessionId, home) {
411
+ async function readPending(agent, sessionId, home) {
295
412
  const path = pendingPath(agent, sessionId, home);
296
413
  try {
297
414
  const parsed = JSON.parse(await readFile(path, "utf8"));
298
- await rm(path, { force: true });
299
415
  if (!isObject(parsed) ||
300
416
  parsed.agent !== agent ||
301
417
  parsed.sessionId !== sessionId ||
@@ -323,6 +439,9 @@ async function consumePending(agent, sessionId, home) {
323
439
  return null;
324
440
  }
325
441
  }
442
+ async function clearPending(agent, sessionId, home) {
443
+ await durableUnlink(pendingPath(agent, sessionId, home), true);
444
+ }
326
445
  function normalizedRepositoryPath(value) {
327
446
  const parts = [];
328
447
  for (const part of value.replaceAll("\\", "/").split("/")) {
@@ -539,6 +658,50 @@ function observationsUrl(apiUrl) {
539
658
  function contextDeliveriesUrl(apiUrl) {
540
659
  return `${apiUrl.replace(/\/+$/u, "")}/v1/context/deliveries`;
541
660
  }
661
+ class RuntimeHttpError extends Error {
662
+ httpStatus;
663
+ constructor(message, httpStatus) {
664
+ super(message);
665
+ this.name = "RuntimeHttpError";
666
+ this.httpStatus = httpStatus;
667
+ }
668
+ }
669
+ async function boundedResponseJson(response) {
670
+ const declaredLength = response.headers.get("content-length");
671
+ if (declaredLength !== null &&
672
+ Number.isFinite(Number(declaredLength)) &&
673
+ Number(declaredLength) > MAX_RESPONSE_BYTES) {
674
+ await response.body?.cancel();
675
+ throw new Error("Lore response exceeds the reliability size limit");
676
+ }
677
+ if (response.body === null) {
678
+ return null;
679
+ }
680
+ const reader = response.body.getReader();
681
+ const chunks = [];
682
+ let bytes = 0;
683
+ try {
684
+ for (;;) {
685
+ const chunk = await reader.read();
686
+ if (chunk.done) {
687
+ break;
688
+ }
689
+ bytes += chunk.value.byteLength;
690
+ if (bytes > MAX_RESPONSE_BYTES) {
691
+ await reader.cancel();
692
+ throw new Error("Lore response exceeds the reliability size limit");
693
+ }
694
+ chunks.push(chunk.value);
695
+ }
696
+ }
697
+ finally {
698
+ reader.releaseLock();
699
+ }
700
+ if (bytes === 0) {
701
+ return null;
702
+ }
703
+ return JSON.parse(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)), bytes).toString("utf8"));
704
+ }
542
705
  function boundedContext(value) {
543
706
  return Array.from(value.trim()).slice(0, MAX_CONTEXT_CHARS).join("");
544
707
  }
@@ -638,11 +801,276 @@ function deliveryFromResponse(value) {
638
801
  delivered,
639
802
  };
640
803
  }
641
- async function postTurn(config, request, fetchImplementation) {
804
+ class IncompatibleReliabilityError extends Error {
805
+ incompatible = true;
806
+ }
807
+ async function protocolResponseJson(response) {
808
+ try {
809
+ return await boundedResponseJson(response);
810
+ }
811
+ catch (error) {
812
+ if (isContextTransportFailure(error)) {
813
+ throw error;
814
+ }
815
+ throw new IncompatibleReliabilityError("Lore response is not valid bounded JSON");
816
+ }
817
+ }
818
+ function deliveryFromSignedContext(payload, expectedRequest) {
819
+ const request = isObject(payload.request) ? payload.request : null;
820
+ if (request === null ||
821
+ canonicalJson(request) !== canonicalJson(expectedRequest) ||
822
+ typeof payload.eventId !== "string" ||
823
+ typeof payload.receiptId !== "string" ||
824
+ typeof payload.context !== "string" ||
825
+ !Array.isArray(payload.memories) ||
826
+ !Array.isArray(payload.hits) ||
827
+ !isObject(payload.packing)) {
828
+ throw new IncompatibleReliabilityError("Lore context snapshot does not bind the request or signed delivery");
829
+ }
830
+ return deliveryFromResponse({
831
+ context: payload.context,
832
+ receipt: { id: payload.receiptId },
833
+ memories: payload.memories,
834
+ hits: payload.hits,
835
+ });
836
+ }
837
+ function retrievalReliability(value, payload) {
838
+ const metadata = isObject(value) ? value : null;
839
+ const freshness = isObject(metadata?.freshness)
840
+ ? metadata.freshness
841
+ : null;
842
+ const policy = isObject(metadata?.policy) ? metadata.policy : null;
843
+ const reasons = Array.isArray(metadata?.reasons)
844
+ ? metadata.reasons.filter((reason) => typeof reason === "string")
845
+ : [];
846
+ const policySource = policy?.source;
847
+ const policyLoadedAt = policy?.loadedAt;
848
+ const policyValidUntil = policy?.validUntil;
849
+ if (metadata === null ||
850
+ metadata.contractVersion !== "reliability-v1" ||
851
+ Object.keys(metadata).length !== 9 ||
852
+ metadata.operation !== "retrieval" ||
853
+ !(metadata.requestId === null ||
854
+ typeof metadata.requestId === "string") ||
855
+ !["ok", "degraded", "failed"].includes(String(metadata.status)) ||
856
+ !["live", "cache", "none"].includes(String(metadata.source)) ||
857
+ !["none", "live_lexical", "cached_context"].includes(String(metadata.fallback)) ||
858
+ freshness === null ||
859
+ Object.keys(freshness).length !== 4 ||
860
+ !["fresh", "stale", "unknown"].includes(String(freshness.state)) ||
861
+ typeof freshness.asOf !== "string" ||
862
+ freshness.asOf !== payload.asOf ||
863
+ typeof freshness.ageMs !== "number" ||
864
+ !Number.isSafeInteger(freshness.ageMs) ||
865
+ freshness.ageMs < 0 ||
866
+ typeof freshness.validUntil !== "string" ||
867
+ freshness.validUntil !== payload.validUntil ||
868
+ policy === null ||
869
+ Object.keys(policy).length !== 4 ||
870
+ !["live", "cache", "none"].includes(String(policySource)) ||
871
+ (policy.version !== null && typeof policy.version !== "string") ||
872
+ (policy.version !== null && policy.version !== payload.policyVersion) ||
873
+ !((policySource === "none" &&
874
+ policy.version === null &&
875
+ policyLoadedAt === null &&
876
+ policyValidUntil === null) ||
877
+ (["live", "cache"].includes(String(policySource)) &&
878
+ typeof policy.version === "string" &&
879
+ typeof policyLoadedAt === "string" &&
880
+ Number.isFinite(Date.parse(policyLoadedAt)) &&
881
+ (policyValidUntil === null ||
882
+ (typeof policyValidUntil === "string" &&
883
+ Number.isFinite(Date.parse(policyValidUntil)))) &&
884
+ policyValidUntil === payload.validUntil &&
885
+ policyLoadedAt ===
886
+ (policySource === "cache" ? payload.asOf : payload.issuedAt))) ||
887
+ !Array.isArray(metadata.reasons) ||
888
+ reasons.length === 0 ||
889
+ reasons.length > 8 ||
890
+ reasons.length !== metadata.reasons.length ||
891
+ new Set(reasons).size !== reasons.length ||
892
+ reasons.some((reason) => !RELIABILITY_REASON_CODES.has(reason)) ||
893
+ (metadata.requestId !== null &&
894
+ metadata.requestId !== payload.requestId) ||
895
+ (metadata.source === "live" && freshness.state !== "fresh") ||
896
+ (metadata.source === "live" && policySource !== "live") ||
897
+ (metadata.source === "cache" &&
898
+ (metadata.fallback !== "cached_context" ||
899
+ policySource !== "cache" ||
900
+ typeof policyValidUntil !== "string" ||
901
+ !["fresh", "stale"].includes(String(freshness.state)))) ||
902
+ (metadata.fallback === "live_lexical" &&
903
+ (metadata.source !== "live" ||
904
+ metadata.status !== "degraded" ||
905
+ !reasons.includes("lexical_fallback"))) ||
906
+ (metadata.fallback === "cached_context" &&
907
+ !reasons.includes("cached_context")) ||
908
+ (metadata.status === "ok" && metadata.fallback !== "none")
909
+ || (metadata.status === "degraded" && metadata.fallback === "none")
910
+ || (metadata.status === "failed" &&
911
+ (metadata.source !== "none" || metadata.fallback !== "none"))) {
912
+ throw new IncompatibleReliabilityError("Lore retrieval reliability metadata is invalid");
913
+ }
914
+ const cached = metadata.source === "cache";
915
+ const nowMs = Date.now();
916
+ return {
917
+ status: metadata.status,
918
+ source: metadata.source,
919
+ fallback: metadata.fallback,
920
+ reasons,
921
+ freshness: {
922
+ state: cached
923
+ ? nowMs < Date.parse(payload.validUntil)
924
+ ? "fresh"
925
+ : "stale"
926
+ : freshness.state,
927
+ asOf: payload.asOf,
928
+ ageMs: cached
929
+ ? Math.max(0, nowMs - Date.parse(payload.asOf))
930
+ : freshness.ageMs,
931
+ validUntil: payload.validUntil,
932
+ },
933
+ policyVersion: typeof policy.version === "string" ? policy.version : null,
934
+ };
935
+ }
936
+ function reliabilityWorkspaceKey(config) {
937
+ return (config.workspaceId ??
938
+ credentialReliabilityWorkspaceKey(config));
939
+ }
940
+ function credentialReliabilityWorkspaceKey(config) {
941
+ return `credential-${sha256(`${config.apiUrl}\0${config.token}`).slice(0, 32)}`;
942
+ }
943
+ function runtimeAccept(config) {
944
+ return config.version === 2
945
+ ? RELIABILITY_V1_MEDIA_TYPE
946
+ : "application/json";
947
+ }
948
+ async function runtimeStore(config, home) {
949
+ const store = new ReliabilityStore(reliabilityWorkspaceKey(config), {
950
+ ...(home === undefined ? {} : { home }),
951
+ });
952
+ await store.initialize();
953
+ await store.migrateLegacyQueue(queueDirectory(home));
954
+ if (config.workspaceId !== undefined) {
955
+ const credentialStore = new ReliabilityStore(credentialReliabilityWorkspaceKey(config), { ...(home === undefined ? {} : { home }) });
956
+ await credentialStore.transferPendingTo(store).catch(() => undefined);
957
+ }
958
+ return store;
959
+ }
960
+ function publicKeySet(value) {
961
+ if (!isObject(value) ||
962
+ typeof value.workspaceId !== "string" ||
963
+ !Array.isArray(value.keys)) {
964
+ throw new IncompatibleReliabilityError("Lore public key response is invalid");
965
+ }
966
+ const keys = value.keys.map((candidate) => {
967
+ if (!isObject(candidate) ||
968
+ typeof candidate.kid !== "string" ||
969
+ candidate.kty !== "OKP" ||
970
+ candidate.crv !== "Ed25519" ||
971
+ candidate.alg !== "EdDSA" ||
972
+ candidate.use !== "sig" ||
973
+ typeof candidate.x !== "string" ||
974
+ typeof candidate.notBefore !== "string" ||
975
+ !(candidate.retiredAt === null ||
976
+ typeof candidate.retiredAt === "string")) {
977
+ throw new IncompatibleReliabilityError("Lore public key response contains an invalid key");
978
+ }
979
+ return {
980
+ workspaceId: value.workspaceId,
981
+ kid: candidate.kid,
982
+ kty: "OKP",
983
+ crv: "Ed25519",
984
+ alg: "EdDSA",
985
+ use: "sig",
986
+ x: candidate.x,
987
+ notBefore: candidate.notBefore,
988
+ retiredAt: candidate.retiredAt,
989
+ };
990
+ });
991
+ if (keys.length === 0) {
992
+ throw new IncompatibleReliabilityError("Lore public key response is empty");
993
+ }
994
+ return { workspaceId: value.workspaceId, keys };
995
+ }
996
+ async function refreshTrustKeys(config, store, fetchImplementation) {
997
+ const response = await fetchImplementation(`${config.apiUrl.replace(/\/+$/u, "")}/v1/workspace/identity/keys`, {
998
+ headers: {
999
+ accept: RELIABILITY_V1_MEDIA_TYPE,
1000
+ authorization: `Bearer ${config.token}`,
1001
+ "user-agent": `lore-cli/${RUNTIME_VERSION}`,
1002
+ },
1003
+ signal: AbortSignal.timeout(config.timeoutMs ?? 2_500),
1004
+ });
1005
+ if (!response.ok) {
1006
+ throw new RuntimeHttpError(`Lore key discovery failed with HTTP ${response.status}`, response.status);
1007
+ }
1008
+ const keys = publicKeySet(await protocolResponseJson(response));
1009
+ if (config.workspaceId !== undefined &&
1010
+ keys.workspaceId !== config.workspaceId) {
1011
+ throw new IncompatibleReliabilityError("Lore public keys belong to a different workspace");
1012
+ }
1013
+ if (store.workspaceId === keys.workspaceId) {
1014
+ await store.writePublicTrustKeys(keys.keys.map((key) => ({
1015
+ ...key,
1016
+ workspaceId: keys.workspaceId,
1017
+ })));
1018
+ await store.writeState(TRUST_KEY_REFRESH_STATE_KEY, {
1019
+ refreshedAt: new Date().toISOString(),
1020
+ });
1021
+ }
1022
+ return keys;
1023
+ }
1024
+ async function pinnedKeys(config, store, fetchImplementation) {
1025
+ const keys = await store.readPublicTrustKeys();
1026
+ const refreshState = await store.readState(TRUST_KEY_REFRESH_STATE_KEY);
1027
+ const refreshedAt = isObject(refreshState)
1028
+ ? refreshState.refreshedAt
1029
+ : undefined;
1030
+ const refreshedAtMs = typeof refreshedAt === "string" ? Date.parse(refreshedAt) : Number.NaN;
1031
+ if (keys.length > 0 &&
1032
+ Number.isFinite(refreshedAtMs) &&
1033
+ Date.now() - refreshedAtMs < TRUST_KEY_REFRESH_TTL_MS &&
1034
+ refreshedAtMs <= Date.now() + 5 * 60_000) {
1035
+ return { workspaceId: store.workspaceId, keys };
1036
+ }
1037
+ return refreshTrustKeys(config, store, fetchImplementation);
1038
+ }
1039
+ async function verifySnapshot(compactJws, kind, config, store, fetchImplementation, allowExpired = false) {
1040
+ const verifyWith = (keys) => verifySignedSnapshot(compactJws, {
1041
+ workspaceId: keys.workspaceId,
1042
+ kind,
1043
+ keys,
1044
+ allowExpired,
1045
+ }).payload;
1046
+ try {
1047
+ return verifyWith(await pinnedKeys(config, store, fetchImplementation));
1048
+ }
1049
+ catch (error) {
1050
+ if (!(error instanceof SignedSnapshotError) ||
1051
+ error.code !== "UNTRUSTED_KEY") {
1052
+ throw error;
1053
+ }
1054
+ return verifyWith(await refreshTrustKeys(config, store, fetchImplementation));
1055
+ }
1056
+ }
1057
+ function committedAcknowledgement(value, externalEventId, serverEventId) {
1058
+ if (!isObject(value) || !isObject(value.acknowledgement)) {
1059
+ return false;
1060
+ }
1061
+ const acknowledgement = value.acknowledgement;
1062
+ return (acknowledgement.state === "committed_server" &&
1063
+ acknowledgement.durability === "workspace_database" &&
1064
+ acknowledgement.replayPending === false &&
1065
+ acknowledgement.eventId === serverEventId &&
1066
+ acknowledgement.idempotencyKey === externalEventId);
1067
+ }
1068
+ async function postTurn(config, request, fetchImplementation, store) {
642
1069
  const { idempotencyKey, ...body } = request;
643
1070
  const response = await fetchImplementation(turnsUrl(config.apiUrl), {
644
1071
  method: "POST",
645
1072
  headers: {
1073
+ accept: runtimeAccept(config),
646
1074
  authorization: `Bearer ${config.token}`,
647
1075
  "content-type": "application/json",
648
1076
  "idempotency-key": idempotencyKey,
@@ -652,18 +1080,70 @@ async function postTurn(config, request, fetchImplementation) {
652
1080
  signal: AbortSignal.timeout(config.timeoutMs ?? 2_500),
653
1081
  });
654
1082
  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();
1083
+ throw new RuntimeHttpError(`Lore turn request failed with HTTP ${response.status}`, response.status);
1084
+ }
1085
+ const value = await protocolResponseJson(response);
1086
+ if (config.version === 1) {
1087
+ return deliveryFromResponse(value);
1088
+ }
1089
+ if (!isObject(value) ||
1090
+ !isObject(value.turn) ||
1091
+ !isObject(value.capture) ||
1092
+ typeof value.contextSnapshot !== "string" ||
1093
+ !isObject(value.turn.event) ||
1094
+ typeof value.turn.event.id !== "string" ||
1095
+ !isObject(value.retrieval) ||
1096
+ !committedAcknowledgement(value.capture, request.eventId, value.turn.event.id)) {
1097
+ throw new IncompatibleReliabilityError("Lore turn response has no matching durable acknowledgement");
1098
+ }
1099
+ const payload = await verifySnapshot(value.contextSnapshot, "context", config, store, fetchImplementation, true);
1100
+ if (payload.eventId !== value.turn.event.id ||
1101
+ !isObject(value.turn.receipt) ||
1102
+ payload.receiptId !== value.turn.receipt.id) {
1103
+ throw new IncompatibleReliabilityError("Lore turn snapshot does not bind the returned event and receipt");
1104
+ }
1105
+ const reliability = retrievalReliability(value.retrieval, payload);
1106
+ if (Date.now() - Date.parse(payload.validUntil) >
1107
+ CONTEXT_CACHE_MAX_STALE_MS) {
1108
+ return {
1109
+ ...emptyDelivery(),
1110
+ reliability: {
1111
+ status: "failed",
1112
+ source: "none",
1113
+ fallback: "none",
1114
+ reasons: ["no_usable_cache"],
1115
+ freshness: {
1116
+ state: "unknown",
1117
+ asOf: null,
1118
+ ageMs: null,
1119
+ validUntil: null,
1120
+ },
1121
+ policyVersion: null,
1122
+ },
1123
+ };
660
1124
  }
661
- return deliveryFromResponse(JSON.parse(text));
1125
+ await store
1126
+ .writeContextSnapshot(request.eventId, {
1127
+ compactJws: value.contextSnapshot,
1128
+ })
1129
+ .catch(() => undefined);
1130
+ const delivery = deliveryFromSignedContext(payload, {
1131
+ connector: request.connector,
1132
+ eventId: request.eventId,
1133
+ sessionId: request.sessionId,
1134
+ task: {
1135
+ agent: request.agent,
1136
+ ...(request.scope === undefined ? {} : { scope: request.scope }),
1137
+ task: request.currentUser.content,
1138
+ },
1139
+ });
1140
+ return { ...delivery, reliability };
662
1141
  }
663
- async function postPromptObservation(config, request, fetchImplementation) {
1142
+ async function postPromptObservation(config, request, fetchImplementation, _store) {
664
1143
  const response = await fetchImplementation(observationsUrl(config.apiUrl), {
665
1144
  method: "POST",
666
1145
  headers: {
1146
+ accept: runtimeAccept(config),
667
1147
  authorization: `Bearer ${config.token}`,
668
1148
  "content-type": "application/json",
669
1149
  "idempotency-key": request.idempotencyKey,
@@ -695,71 +1175,254 @@ async function postPromptObservation(config, request, fetchImplementation) {
695
1175
  signal: AbortSignal.timeout(config.timeoutMs ?? 2_500),
696
1176
  });
697
1177
  if (!response.ok) {
698
- throw new Error(`Lore prompt observation failed with HTTP ${response.status}`);
1178
+ throw new RuntimeHttpError(`Lore prompt observation failed with HTTP ${response.status}`, response.status);
1179
+ }
1180
+ const value = await protocolResponseJson(response);
1181
+ if (config.version === 1) {
1182
+ return;
1183
+ }
1184
+ if (!isObject(value) ||
1185
+ !isObject(value.observation) ||
1186
+ !isObject(value.observation.event) ||
1187
+ typeof value.observation.event.id !== "string" ||
1188
+ !committedAcknowledgement(value.capture, request.eventId, value.observation.event.id)) {
1189
+ throw new IncompatibleReliabilityError("Lore observation response has no matching durable acknowledgement");
699
1190
  }
700
- await response.body?.cancel();
701
1191
  }
702
- async function getPromptContext(config, agent, sessionId, sourceEventId, prompt, gitContext, fetchImplementation) {
1192
+ async function getPromptContext(config, agent, sessionId, sourceEventId, prompt, gitContext, fetchImplementation, store) {
1193
+ const deliveryEventId = deterministicUuid(`lore-context\0${sourceEventId}`);
1194
+ const deliveryRequest = {
1195
+ connector: "lore-cli",
1196
+ eventId: deliveryEventId,
1197
+ sessionId,
1198
+ task: {
1199
+ agent,
1200
+ ...(gitContext.scope === undefined
1201
+ ? {}
1202
+ : { scope: gitContext.scope }),
1203
+ task: redactSecrets(prompt),
1204
+ ...(gitContext.diff === undefined ? {} : { diff: gitContext.diff }),
1205
+ ...(gitContext.files === undefined
1206
+ ? {}
1207
+ : { files: gitContext.files }),
1208
+ },
1209
+ };
703
1210
  const response = await fetchImplementation(contextDeliveriesUrl(config.apiUrl), {
704
1211
  method: "POST",
705
1212
  headers: {
1213
+ accept: runtimeAccept(config),
706
1214
  authorization: `Bearer ${config.token}`,
707
1215
  "content-type": "application/json",
708
1216
  "user-agent": `lore-cli/${RUNTIME_VERSION}`,
709
1217
  },
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
- }),
1218
+ body: JSON.stringify(deliveryRequest),
726
1219
  signal: AbortSignal.timeout(config.timeoutMs ?? 2_500),
727
1220
  });
728
1221
  if (!response.ok) {
729
- throw new Error(`Lore context request failed with HTTP ${response.status}`);
1222
+ throw new RuntimeHttpError(`Lore context request failed with HTTP ${response.status}`, response.status);
1223
+ }
1224
+ const value = await protocolResponseJson(response);
1225
+ if (config.version === 1) {
1226
+ return deliveryFromResponse(value);
1227
+ }
1228
+ if (!isObject(value) ||
1229
+ !isObject(value.delivery) ||
1230
+ !isObject(value.reliability) ||
1231
+ typeof value.snapshot !== "string" ||
1232
+ !isObject(value.delivery.event) ||
1233
+ typeof value.delivery.event.id !== "string" ||
1234
+ !isObject(value.delivery.receipt) ||
1235
+ typeof value.delivery.receipt.id !== "string") {
1236
+ throw new IncompatibleReliabilityError("Lore context response has no signed reliability snapshot");
1237
+ }
1238
+ const payload = await verifySnapshot(value.snapshot, "context", config, store, fetchImplementation, true);
1239
+ if (payload.eventId !== value.delivery.event.id ||
1240
+ payload.receiptId !== value.delivery.receipt.id) {
1241
+ throw new IncompatibleReliabilityError("Lore context snapshot does not bind the returned event and receipt");
1242
+ }
1243
+ const reliability = retrievalReliability(value.reliability, payload);
1244
+ if (Date.now() - Date.parse(payload.validUntil) >
1245
+ CONTEXT_CACHE_MAX_STALE_MS) {
1246
+ return {
1247
+ ...emptyDelivery(),
1248
+ reliability: {
1249
+ status: "failed",
1250
+ source: "none",
1251
+ fallback: "none",
1252
+ reasons: ["no_usable_cache"],
1253
+ freshness: {
1254
+ state: "unknown",
1255
+ asOf: null,
1256
+ ageMs: null,
1257
+ validUntil: null,
1258
+ },
1259
+ policyVersion: null,
1260
+ },
1261
+ };
730
1262
  }
731
- const text = await response.text();
732
- if (text.trim() === "") {
733
- return emptyDelivery();
1263
+ await store
1264
+ .writeContextSnapshot(sourceEventId, { compactJws: value.snapshot })
1265
+ .catch(() => undefined);
1266
+ return {
1267
+ ...deliveryFromSignedContext(payload, deliveryRequest),
1268
+ reliability,
1269
+ };
1270
+ }
1271
+ async function cachedSnapshotKeys(config, store, now) {
1272
+ if (config.workspaceId === undefined || store.workspaceId !== config.workspaceId) {
1273
+ return null;
734
1274
  }
735
- return deliveryFromResponse(JSON.parse(text));
1275
+ const [keys, refreshState] = await Promise.all([
1276
+ store.readPublicTrustKeys(),
1277
+ store.readState(TRUST_KEY_REFRESH_STATE_KEY),
1278
+ ]);
1279
+ const refreshedAt = isObject(refreshState)
1280
+ ? refreshState.refreshedAt
1281
+ : undefined;
1282
+ const refreshedAtMs = typeof refreshedAt === "string" ? Date.parse(refreshedAt) : Number.NaN;
1283
+ if (keys.length === 0 ||
1284
+ !Number.isFinite(refreshedAtMs) ||
1285
+ now.getTime() - refreshedAtMs >= TRUST_KEY_REFRESH_TTL_MS ||
1286
+ refreshedAtMs > now.getTime() + 5 * 60_000) {
1287
+ return null;
1288
+ }
1289
+ return { workspaceId: config.workspaceId, keys };
736
1290
  }
737
- function queueDirectory(home) {
738
- return resolve(loreDirectory(home), "queue");
1291
+ async function getCachedPromptContext(config, prompt, gitContext, store, now) {
1292
+ const keys = await cachedSnapshotKeys(config, store, now);
1293
+ if (keys === null || config.workspaceId === undefined) {
1294
+ return null;
1295
+ }
1296
+ const scan = await store.listContextSnapshots();
1297
+ const candidates = [];
1298
+ for (const record of scan.records) {
1299
+ const value = isObject(record.value) ? record.value : null;
1300
+ if (value === null || typeof value.compactJws !== "string") {
1301
+ continue;
1302
+ }
1303
+ try {
1304
+ const payload = verifySignedSnapshot(value.compactJws, {
1305
+ workspaceId: config.workspaceId,
1306
+ kind: "context",
1307
+ keys,
1308
+ allowExpired: true,
1309
+ now,
1310
+ }).payload;
1311
+ candidates.push({
1312
+ cacheKey: record.cacheKey,
1313
+ compactJws: value.compactJws,
1314
+ payload,
1315
+ });
1316
+ }
1317
+ catch {
1318
+ // One invalid cache entry must not hide another verified candidate.
1319
+ }
1320
+ }
1321
+ const task = {
1322
+ workspaceId: config.workspaceId,
1323
+ task: redactSecrets(prompt),
1324
+ ...(gitContext.scope === undefined ? {} : { scope: gitContext.scope }),
1325
+ ...(gitContext.files === undefined ? {} : { files: gitContext.files }),
1326
+ };
1327
+ const selected = selectCachedContext(candidates, task, now);
1328
+ if (selected === null) {
1329
+ return null;
1330
+ }
1331
+ return {
1332
+ context: selected.context,
1333
+ learned: [],
1334
+ delivered: [],
1335
+ reliability: {
1336
+ status: "degraded",
1337
+ source: "cache",
1338
+ fallback: "cached_context",
1339
+ reasons: ["live_unavailable", "cached_context"],
1340
+ freshness: selected.freshness,
1341
+ policyVersion: selected.payload.policyVersion,
1342
+ },
1343
+ };
739
1344
  }
740
- async function trimQueue(home) {
741
- const directory = queueDirectory(home);
742
- let entries;
1345
+ async function getPromptContextWithFallback(config, agent, sessionId, sourceEventId, prompt, gitContext, fetchImplementation, store, now) {
743
1346
  try {
744
- entries = (await readdir(directory))
745
- .filter((entry) => entry.endsWith(".json"))
746
- .sort();
1347
+ return await getPromptContext(config, agent, sessionId, sourceEventId, prompt, gitContext, fetchImplementation, store);
747
1348
  }
748
- catch {
749
- return;
1349
+ catch (error) {
1350
+ if (isContextTransportFailure(error)) {
1351
+ const cached = await getCachedPromptContext(config, prompt, gitContext, store, now).catch(() => null);
1352
+ if (cached !== null) {
1353
+ return cached;
1354
+ }
1355
+ }
1356
+ return {
1357
+ ...emptyDelivery(),
1358
+ reliability: {
1359
+ status: "failed",
1360
+ source: "none",
1361
+ fallback: "none",
1362
+ reasons: [
1363
+ ...(isContextTransportFailure(error) ? ["live_unavailable"] : []),
1364
+ isContextTransportFailure(error)
1365
+ ? "no_usable_cache"
1366
+ : "invalid_response",
1367
+ ],
1368
+ freshness: {
1369
+ state: "unknown",
1370
+ asOf: null,
1371
+ ageMs: null,
1372
+ validUntil: null,
1373
+ },
1374
+ policyVersion: null,
1375
+ },
1376
+ };
750
1377
  }
751
- const excess = entries.length - MAX_QUEUE_ITEMS + 1;
752
- if (excess <= 0) {
753
- return;
1378
+ }
1379
+ function formatAge(ageMs) {
1380
+ if (ageMs === null || ageMs < 60_000) {
1381
+ return "less than a minute";
1382
+ }
1383
+ if (ageMs < 60 * 60_000) {
1384
+ return `${Math.floor(ageMs / 60_000)}m`;
1385
+ }
1386
+ if (ageMs < 24 * 60 * 60_000) {
1387
+ return `${Math.floor(ageMs / (60 * 60_000))}h`;
754
1388
  }
755
- await Promise.all(entries
756
- .slice(0, excess)
757
- .map(async (entry) => rm(resolve(directory, entry), { force: true })));
1389
+ return `${Math.floor(ageMs / (24 * 60 * 60_000))}d`;
758
1390
  }
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);
1391
+ async function retrievalNotice(delivery, sessionId, store, now) {
1392
+ const reliability = delivery.reliability;
1393
+ if (reliability === undefined || reliability.status === "ok") {
1394
+ return undefined;
1395
+ }
1396
+ if (reliability.fallback === "cached_context") {
1397
+ return `Lore used verified ${reliability.freshness.state} cached context (${formatAge(reliability.freshness.ageMs)} old) because live retrieval was unavailable.`;
1398
+ }
1399
+ const notice = reliability.fallback === "live_lexical"
1400
+ ? "Lore used live lexical retrieval because semantic retrieval was unavailable."
1401
+ : reliability.reasons.includes("invalid_response")
1402
+ ? "Lore context could not be trusted; continuing without injected context."
1403
+ : "Lore context is temporarily unavailable; continuing without injected context.";
1404
+ const key = `context-notice:${sessionId}`;
1405
+ const previous = await store.readState(key).catch(() => null);
1406
+ if (isObject(previous) &&
1407
+ previous.notice === notice &&
1408
+ typeof previous.at === "string" &&
1409
+ now.getTime() - Date.parse(previous.at) < CONTEXT_NOTICE_COOLDOWN_MS) {
1410
+ return undefined;
1411
+ }
1412
+ await store
1413
+ .writeState(key, { notice, at: now.toISOString() }, now)
1414
+ .catch(() => undefined);
1415
+ return notice;
1416
+ }
1417
+ function queueDirectory(home) {
1418
+ return resolve(loreDirectory(home), "queue");
1419
+ }
1420
+ async function enqueueCapture(store, queued) {
1421
+ return (await store.enqueue({
1422
+ kind: queued.kind,
1423
+ idempotencyKey: queued.request.eventId,
1424
+ payload: queued,
1425
+ })).entry;
763
1426
  }
764
1427
  function isTurnRequest(value) {
765
1428
  return (isObject(value) &&
@@ -799,38 +1462,57 @@ function queuedRequest(value) {
799
1462
  }
800
1463
  return null;
801
1464
  }
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;
1465
+ async function flushOne(config, fetchImplementation, store) {
1466
+ const claimed = await store.claimNext({
1467
+ workerId: `native-hook:${process.pid}`,
1468
+ kinds: ["turn", "prompt"],
1469
+ });
1470
+ if (claimed === null || claimed.claim === undefined) {
1471
+ return null;
815
1472
  }
816
- const path = resolve(directory, first);
817
1473
  try {
818
- const parsed = JSON.parse(await readFile(path, "utf8"));
819
- const queued = queuedRequest(parsed);
1474
+ const queued = queuedRequest(claimed.payload);
820
1475
  if (queued === null) {
821
- await rm(path, { force: true });
822
- return;
1476
+ const failed = await store.failClaim({
1477
+ claimId: claimed.claim.id,
1478
+ classification: "incompatible",
1479
+ message: "Durable capture payload is incompatible",
1480
+ });
1481
+ return {
1482
+ idempotencyKey: claimed.idempotencyKey,
1483
+ state: failed.state === "auth-blocked" ? "auth-blocked" : "dead",
1484
+ };
823
1485
  }
1486
+ let delivery;
824
1487
  if (queued.kind === "turn") {
825
- await postTurn(config, queued.request, fetchImplementation);
1488
+ delivery = await postTurn(config, queued.request, fetchImplementation, store);
826
1489
  }
827
1490
  else {
828
- await postPromptObservation(config, queued.request, fetchImplementation);
1491
+ await postPromptObservation(config, queued.request, fetchImplementation, store);
829
1492
  }
830
- await rm(path, { force: true });
1493
+ await store.acknowledgeClaim(claimed.claim.id);
1494
+ return {
1495
+ idempotencyKey: claimed.idempotencyKey,
1496
+ state: "acknowledged",
1497
+ ...(delivery === undefined ? {} : { delivery }),
1498
+ };
831
1499
  }
832
- catch {
833
- // Retry on a later prompt. Hook failures are deliberately invisible.
1500
+ catch (error) {
1501
+ const failed = await store.failClaim({
1502
+ claimId: claimed.claim.id,
1503
+ classification: error instanceof SignedSnapshotError
1504
+ ? "incompatible"
1505
+ : classifyRetryFailure(error),
1506
+ message: error instanceof Error ? error.message : "Lore upload failed",
1507
+ });
1508
+ return {
1509
+ idempotencyKey: claimed.idempotencyKey,
1510
+ state: failed.state === "auth-blocked"
1511
+ ? "auth-blocked"
1512
+ : failed.state === "dead"
1513
+ ? "dead"
1514
+ : "ready",
1515
+ };
834
1516
  }
835
1517
  }
836
1518
  function receiptMessage(config, agent, delivery) {
@@ -979,6 +1661,7 @@ function guardResultFromResponse(value) {
979
1661
  }
980
1662
  const scope = isObject(item.scope) ? item.scope : {};
981
1663
  const source = isObject(item.source) ? item.source : {};
1664
+ const explanation = guardExplanationFromValue(item.explanation);
982
1665
  return [
983
1666
  {
984
1667
  content: redactSecrets(item.content),
@@ -992,6 +1675,7 @@ function guardResultFromResponse(value) {
992
1675
  ...(typeof source.agent === "string"
993
1676
  ? { sourceAgent: source.agent }
994
1677
  : {}),
1678
+ ...(explanation === undefined ? {} : { explanation }),
995
1679
  },
996
1680
  ];
997
1681
  })
@@ -1013,6 +1697,33 @@ function guardResultFromResponse(value) {
1013
1697
  conflictSummaries,
1014
1698
  };
1015
1699
  }
1700
+ function guardExplanationFromValue(value) {
1701
+ if (!isObject(value) ||
1702
+ typeof value.summary !== "string" ||
1703
+ !isObject(value.provenance) ||
1704
+ typeof value.provenance.agent !== "string" ||
1705
+ typeof value.status !== "string" ||
1706
+ !isObject(value.freshness) ||
1707
+ typeof value.freshness.updatedAt !== "string") {
1708
+ return undefined;
1709
+ }
1710
+ const scope = isObject(value.scope) ? value.scope : {};
1711
+ const scopeParts = [
1712
+ typeof scope.organization === "string"
1713
+ ? `org ${scope.organization}`
1714
+ : undefined,
1715
+ typeof scope.project === "string" ? `project ${scope.project}` : undefined,
1716
+ typeof scope.repo === "string" ? `repo ${scope.repo}` : undefined,
1717
+ typeof scope.path === "string" ? `path ${scope.path}` : undefined,
1718
+ typeof scope.component === "string"
1719
+ ? `component ${scope.component}`
1720
+ : undefined,
1721
+ ].filter((part) => part !== undefined);
1722
+ const session = typeof value.provenance.sessionId === "string"
1723
+ ? ` session ${value.provenance.sessionId}`
1724
+ : "";
1725
+ 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)}.`;
1726
+ }
1016
1727
  function guardItemSource(item) {
1017
1728
  const scope = item.repo !== undefined
1018
1729
  ? `${item.repo} repo`
@@ -1027,10 +1738,7 @@ function guardAssistContext(result) {
1027
1738
  const lines = ["Relevant Lore context:"];
1028
1739
  for (const item of result.items) {
1029
1740
  lines.push(`- ${item.content}`);
1030
- }
1031
- lines.push("", "Sources:");
1032
- for (const item of result.items) {
1033
- lines.push(`- ${guardItemSource(item)}`);
1741
+ lines.push(` ${item.explanation ?? `Source: ${guardItemSource(item)}.`}`);
1034
1742
  }
1035
1743
  if (result.conflictSummaries.length > 0) {
1036
1744
  lines.push("", "Conflicting Lore context (do not silently pick a winner):");
@@ -1045,17 +1753,21 @@ function guardConfirmationReason(result) {
1045
1753
  const shown = required.length > 0 ? required : result.items;
1046
1754
  const lines = [
1047
1755
  "Lore Guard: this action conflicts with a required rule.",
1048
- ...shown.map((item) => `- ${item.content} (${guardItemSource(item)})`),
1756
+ ...shown.flatMap((item) => [
1757
+ `- ${item.content}`,
1758
+ ` ${item.explanation ?? `Source: ${guardItemSource(item)}.`}`,
1759
+ ]),
1049
1760
  ];
1050
1761
  lines.push(result.confirmCommand === undefined
1051
1762
  ? "Ask the user to confirm before proceeding."
1052
1763
  : `Approve here to proceed, or the user can run: ${result.confirmCommand}`);
1053
1764
  return boundedContext(lines.join("\n"));
1054
1765
  }
1055
- async function postGuardCheck(config, request, fetchImplementation) {
1766
+ async function postGuardCheck(config, request, fetchImplementation, store) {
1056
1767
  const response = await fetchImplementation(guardCheckUrl(config.apiUrl), {
1057
1768
  method: "POST",
1058
1769
  headers: {
1770
+ accept: runtimeAccept(config),
1059
1771
  authorization: `Bearer ${config.token}`,
1060
1772
  "content-type": "application/json",
1061
1773
  "user-agent": `lore-cli/${RUNTIME_VERSION}`,
@@ -1064,9 +1776,26 @@ async function postGuardCheck(config, request, fetchImplementation) {
1064
1776
  signal: AbortSignal.timeout(Math.min(config.timeoutMs ?? GUARD_TIMEOUT_MS, GUARD_TIMEOUT_MS)),
1065
1777
  });
1066
1778
  if (!response.ok) {
1067
- throw new Error(`Lore guard check failed with HTTP ${response.status}`);
1068
- }
1069
- return guardResultFromResponse(JSON.parse(await response.text()));
1779
+ throw new RuntimeHttpError(`Lore guard check failed with HTTP ${response.status}`, response.status);
1780
+ }
1781
+ const value = await protocolResponseJson(response);
1782
+ if (config.version === 1) {
1783
+ return guardResultFromResponse(value);
1784
+ }
1785
+ if (!isObject(value) ||
1786
+ !isObject(value.check) ||
1787
+ typeof value.policyVersion !== "string" ||
1788
+ typeof value.policySnapshot !== "string") {
1789
+ throw new IncompatibleReliabilityError("Lore Guard response has no signed policy snapshot");
1790
+ }
1791
+ const payload = await verifySnapshot(value.policySnapshot, "guard_policy", config, store, fetchImplementation);
1792
+ if (payload.policyVersion !== value.policyVersion ||
1793
+ (config.workspaceId !== undefined &&
1794
+ payload.workspaceId !== config.workspaceId)) {
1795
+ throw new IncompatibleReliabilityError("Lore Guard snapshot does not bind the returned policy");
1796
+ }
1797
+ await store.writePolicySnapshot("current", value.policySnapshot);
1798
+ return guardResultFromResponse(value.check);
1070
1799
  }
1071
1800
  async function handleGuardEvent(input, agent, config, sessionId, options, now) {
1072
1801
  if (agent === "polytoken") {
@@ -1081,10 +1810,12 @@ async function handleGuardEvent(input, agent, config, sessionId, options, now) {
1081
1810
  if (trigger === null) {
1082
1811
  return undefined;
1083
1812
  }
1813
+ const store = await runtimeStore(config, options.home);
1084
1814
  const state = await readGuardState(agent, sessionId, options.home);
1085
1815
  if (state.mode === "off" &&
1086
1816
  state.modeCheckedAt !== undefined &&
1087
1817
  now.getTime() - Date.parse(state.modeCheckedAt) < GUARD_MODE_TTL_MS) {
1818
+ await recordLocalGuardMetric(store, { outcome: "reused_decision" }, now).catch(() => undefined);
1088
1819
  return undefined;
1089
1820
  }
1090
1821
  const cwd = stringField(input.cwd);
@@ -1112,24 +1843,38 @@ async function handleGuardEvent(input, agent, config, sessionId, options, now) {
1112
1843
  const cachedAt = state.keys[key];
1113
1844
  if (cachedAt !== undefined &&
1114
1845
  now.getTime() - Date.parse(cachedAt) < GUARD_KEY_COOLDOWN_MS) {
1846
+ await recordLocalGuardMetric(store, { outcome: "reused_decision" }, now).catch(() => undefined);
1115
1847
  return undefined;
1116
1848
  }
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);
1849
+ const startedAt = Date.now();
1850
+ let result;
1851
+ try {
1852
+ result = await postGuardCheck(config, {
1853
+ connector: "lore-cli",
1854
+ agent,
1855
+ sessionId,
1856
+ action: trigger.action,
1857
+ tool: toolName,
1858
+ ...(scope?.repo === undefined ? {} : { repo: scope.repo }),
1859
+ ...(scope?.path === undefined ? {} : { path: scope.path }),
1860
+ ...(files.length === 0 ? {} : { files }),
1861
+ ...(trigger.command === undefined
1862
+ ? {}
1863
+ : { command: redactSecrets(trigger.command).slice(0, 10_000) }),
1864
+ }, options.fetch ?? globalThis.fetch, store);
1865
+ }
1866
+ catch (error) {
1867
+ await recordLocalGuardMetric(store, { outcome: "failed" }, now).catch(() => undefined);
1868
+ throw error;
1869
+ }
1130
1870
  if (result === null) {
1131
- return undefined;
1871
+ await recordLocalGuardMetric(store, { outcome: "failed" }, now).catch(() => undefined);
1872
+ throw new IncompatibleReliabilityError("Lore Guard response is invalid");
1132
1873
  }
1874
+ await recordLocalGuardMetric(store, {
1875
+ outcome: "live_check",
1876
+ durationMs: Math.max(0, Date.now() - startedAt),
1877
+ }, now).catch(() => undefined);
1133
1878
  state.mode = result.mode;
1134
1879
  state.modeCheckedAt = now.toISOString();
1135
1880
  if (!result.requiresConfirmation) {
@@ -1141,6 +1886,12 @@ async function handleGuardEvent(input, agent, config, sessionId, options, now) {
1141
1886
  }
1142
1887
  if (result.requiresConfirmation) {
1143
1888
  const reason = guardConfirmationReason(result);
1889
+ if (agent === "copilot-cli") {
1890
+ return {
1891
+ permissionDecision: "ask",
1892
+ permissionDecisionReason: reason,
1893
+ };
1894
+ }
1144
1895
  if (agent === "cursor") {
1145
1896
  // Cursor's "ask" verdict is unenforced upstream; deny is the only
1146
1897
  // reliable gate. The message carries the approve-and-retry path.
@@ -1161,6 +1912,11 @@ async function handleGuardEvent(input, agent, config, sessionId, options, now) {
1161
1912
  if (result.items.length === 0 && result.conflictSummaries.length === 0) {
1162
1913
  return undefined;
1163
1914
  }
1915
+ if (agent === "copilot-cli") {
1916
+ // Copilot CLI's preToolUse output has no context-injection field. Prompt
1917
+ // context was already added by userPromptTransformed.
1918
+ return undefined;
1919
+ }
1164
1920
  if (agent === "cursor") {
1165
1921
  // Cursor cannot inject agent context at the shell boundary on allow.
1166
1922
  return undefined;
@@ -1174,14 +1930,16 @@ async function handleGuardEvent(input, agent, config, sessionId, options, now) {
1174
1930
  }
1175
1931
  export async function handleHookEvent(value, agent, options = {}) {
1176
1932
  if (!isObject(value)) {
1177
- return undefined;
1933
+ throw new InvalidHookInputError();
1178
1934
  }
1179
1935
  const normalized = normalizeHookInput(value, agent, options.environment ?? process.env);
1180
1936
  const input = normalized.input;
1181
1937
  const eventName = normalized.eventName;
1182
1938
  const sessionId = normalized.sessionId;
1183
- if (eventName === undefined || sessionId === undefined) {
1184
- return undefined;
1939
+ if (eventName === undefined ||
1940
+ sessionId === undefined ||
1941
+ !validHookInput(input, agent, eventName)) {
1942
+ throw new InvalidHookInputError();
1185
1943
  }
1186
1944
  const config = await readRuntimeConfig(options.home);
1187
1945
  if (config === null || !config.agents.includes(agent)) {
@@ -1203,20 +1961,72 @@ export async function handleHookEvent(value, agent, options = {}) {
1203
1961
  return await handleGuardEvent(input, agent, config, sessionId, options, now);
1204
1962
  }
1205
1963
  catch {
1206
- // Guard checks fail open: never stall or break a tool call.
1207
- return undefined;
1964
+ const warning = "Lore Guard is unavailable; this action is proceeding without a live policy decision.";
1965
+ if (agent === "cursor") {
1966
+ return {
1967
+ userMessage: warning,
1968
+ agentMessage: warning,
1969
+ };
1970
+ }
1971
+ if (agent === "copilot-cli") {
1972
+ return undefined;
1973
+ }
1974
+ return {
1975
+ hookSpecificOutput: {
1976
+ hookEventName: "PreToolUse",
1977
+ additionalContext: warning,
1978
+ },
1979
+ };
1208
1980
  }
1209
1981
  }
1210
- if (eventName !== "UserPromptSubmit") {
1211
- return undefined;
1212
- }
1213
1982
  const fetchImplementation = options.fetch ?? globalThis.fetch;
1214
- await retryOne(config, fetchImplementation, options.home);
1215
- const pending = await consumePending(agent, sessionId, options.home);
1216
1983
  const prompt = stringField(input.prompt);
1217
1984
  if (prompt === undefined) {
1218
- return undefined;
1985
+ throw new InvalidHookInputError();
1219
1986
  }
1987
+ const notices = [];
1988
+ let store;
1989
+ try {
1990
+ store = await runtimeStore(config, options.home);
1991
+ }
1992
+ catch (error) {
1993
+ const detail = error instanceof ReliabilityStoreError &&
1994
+ error.code === "CAPACITY_EXCEEDED"
1995
+ ? "the local outbox is full"
1996
+ : "local durable storage is unavailable";
1997
+ notices.push(`Lore could not acknowledge this capture because ${detail}. Run lore doctor.`);
1998
+ const notice = notices.join(" ");
1999
+ return agent === "polytoken"
2000
+ ? { outcome: "accept", additional_context: notice }
2001
+ : agent === "cursor"
2002
+ ? {
2003
+ continue: true,
2004
+ hookSpecificOutput: {
2005
+ hookEventName: "UserPromptSubmit",
2006
+ additionalContext: notice,
2007
+ },
2008
+ }
2009
+ : agent === "copilot-cli"
2010
+ ? {
2011
+ modifiedTransformedPrompt: `${notice}\n\n${stringField(input.transformed_prompt) ?? prompt}`,
2012
+ }
2013
+ : { systemMessage: notice };
2014
+ }
2015
+ const noticeForFlush = (result) => {
2016
+ if (result?.state === "auth-blocked") {
2017
+ notices.push("Lore retained a capture locally, but upload is blocked by authentication. Run lore connect again.");
2018
+ }
2019
+ else if (result?.state === "dead") {
2020
+ notices.push("Lore retained a capture as a dead letter because the server response was incompatible. Run lore doctor.");
2021
+ }
2022
+ };
2023
+ try {
2024
+ noticeForFlush(await flushOne(config, fetchImplementation, store));
2025
+ }
2026
+ catch {
2027
+ notices.push("Lore retained queued captures locally, but replay could not run. Run lore doctor.");
2028
+ }
2029
+ const pending = await readPending(agent, sessionId, options.home);
1220
2030
  const lineage = await readLineageMetadata(options.home, options.environment ?? process.env);
1221
2031
  let delivery = emptyDelivery();
1222
2032
  if (pending !== null) {
@@ -1224,26 +2034,61 @@ export async function handleHookEvent(value, agent, options = {}) {
1224
2034
  if (request === null) {
1225
2035
  return undefined;
1226
2036
  }
2037
+ let enqueued = false;
1227
2038
  try {
1228
- delivery = await postTurn(config, request, fetchImplementation);
2039
+ await enqueueCapture(store, { kind: "turn", request });
2040
+ enqueued = true;
2041
+ await clearPending(agent, sessionId, options.home);
1229
2042
  }
1230
- catch {
2043
+ catch (error) {
2044
+ notices.push(error instanceof ReliabilityStoreError &&
2045
+ error.code === "CAPACITY_EXCEEDED"
2046
+ ? "Lore could not acknowledge this capture because the local outbox is full. Run lore status."
2047
+ : "Lore could not acknowledge this capture because local persistence failed. Run lore doctor.");
2048
+ }
2049
+ if (enqueued) {
1231
2050
  try {
1232
- await enqueue({ kind: "turn", request }, options.home);
2051
+ const flushed = await flushOne(config, fetchImplementation, store);
2052
+ noticeForFlush(flushed);
2053
+ if (flushed?.idempotencyKey === request.eventId &&
2054
+ flushed.state === "acknowledged" &&
2055
+ flushed.delivery !== undefined) {
2056
+ delivery = flushed.delivery;
2057
+ }
2058
+ else {
2059
+ notices.push("Lore saved this capture locally; server upload is pending.");
2060
+ }
1233
2061
  }
1234
2062
  catch {
1235
- // The connector must fail open even when its local queue is unavailable.
2063
+ notices.push("Lore saved this capture locally; server upload is pending.");
1236
2064
  }
2065
+ }
2066
+ if (delivery.context === "") {
1237
2067
  try {
1238
- delivery = await getPromptContext(config, agent, sessionId, request.eventId, prompt, {
2068
+ delivery = await getPromptContextWithFallback(config, agent, sessionId, request.eventId, prompt, {
1239
2069
  ...(request.scope === undefined ? {} : { scope: request.scope }),
1240
2070
  learningScope: request.learningScope,
1241
2071
  ...(request.diff === undefined ? {} : { diff: request.diff }),
1242
2072
  ...(request.files === undefined ? {} : { files: request.files }),
1243
- }, fetchImplementation);
2073
+ }, fetchImplementation, store, now);
1244
2074
  }
1245
2075
  catch {
1246
- return undefined;
2076
+ delivery = {
2077
+ ...emptyDelivery(),
2078
+ reliability: {
2079
+ status: "failed",
2080
+ source: "none",
2081
+ fallback: "none",
2082
+ reasons: ["invalid_response"],
2083
+ freshness: {
2084
+ state: "unknown",
2085
+ asOf: null,
2086
+ ageMs: null,
2087
+ validUntil: null,
2088
+ },
2089
+ policyVersion: null,
2090
+ },
2091
+ };
1247
2092
  }
1248
2093
  }
1249
2094
  }
@@ -1253,45 +2098,110 @@ export async function handleHookEvent(value, agent, options = {}) {
1253
2098
  if (observation === null) {
1254
2099
  return undefined;
1255
2100
  }
2101
+ let enqueued = false;
1256
2102
  try {
1257
- await postPromptObservation(config, observation, fetchImplementation);
2103
+ await enqueueCapture(store, { kind: "prompt", request: observation });
2104
+ enqueued = true;
1258
2105
  }
1259
- catch {
2106
+ catch (error) {
2107
+ notices.push(error instanceof ReliabilityStoreError &&
2108
+ error.code === "CAPACITY_EXCEEDED"
2109
+ ? "Lore could not acknowledge this capture because the local outbox is full. Run lore status."
2110
+ : "Lore could not acknowledge this capture because local persistence failed. Run lore doctor.");
2111
+ }
2112
+ if (enqueued) {
1260
2113
  try {
1261
- await enqueue({ kind: "prompt", request: observation }, options.home);
2114
+ const flushed = await flushOne(config, fetchImplementation, store);
2115
+ noticeForFlush(flushed);
2116
+ if (flushed?.idempotencyKey !== observation.eventId ||
2117
+ flushed.state !== "acknowledged") {
2118
+ notices.push("Lore saved this capture locally; server upload is pending.");
2119
+ }
1262
2120
  }
1263
2121
  catch {
1264
- // The connector must fail open even when its local queue is unavailable.
2122
+ notices.push("Lore saved this capture locally; server upload is pending.");
1265
2123
  }
1266
2124
  }
1267
2125
  try {
1268
- delivery = await getPromptContext(config, agent, sessionId, observation.eventId, prompt, gitContext, fetchImplementation);
2126
+ delivery = await getPromptContextWithFallback(config, agent, sessionId, observation.eventId, prompt, gitContext, fetchImplementation, store, now);
1269
2127
  }
1270
2128
  catch {
1271
- return undefined;
2129
+ delivery = {
2130
+ ...emptyDelivery(),
2131
+ reliability: {
2132
+ status: "failed",
2133
+ source: "none",
2134
+ fallback: "none",
2135
+ reasons: ["invalid_response"],
2136
+ freshness: {
2137
+ state: "unknown",
2138
+ asOf: null,
2139
+ ageMs: null,
2140
+ validUntil: null,
2141
+ },
2142
+ policyVersion: null,
2143
+ },
2144
+ };
1272
2145
  }
1273
2146
  }
2147
+ const reliability = delivery.reliability;
2148
+ await recordLocalRetrievalMetric(store, {
2149
+ outcome: reliability?.fallback === "cached_context"
2150
+ ? "cached_fallback"
2151
+ : reliability?.fallback === "live_lexical"
2152
+ ? "live_lexical_fallback"
2153
+ : reliability?.status === "failed" ||
2154
+ reliability?.source === "none"
2155
+ ? "failed"
2156
+ : "live_primary",
2157
+ ...(reliability?.freshness.ageMs === undefined
2158
+ ? {}
2159
+ : { cacheAgeMs: reliability.freshness.ageMs }),
2160
+ }, now).catch(() => undefined);
2161
+ const contextReliabilityNotice = await retrievalNotice(delivery, sessionId, store, now);
2162
+ if (contextReliabilityNotice !== undefined) {
2163
+ notices.push(contextReliabilityNotice);
2164
+ }
2165
+ const uniqueNotices = [...new Set(notices)];
2166
+ const reliabilityNotice = uniqueNotices.length === 0 ? undefined : uniqueNotices.join(" ");
2167
+ const receipt = receiptMessage(config, agent, delivery);
1274
2168
  const systemMessage = agent === "cursor" || agent === "polytoken"
1275
2169
  ? undefined
1276
- : receiptMessage(config, agent, delivery);
1277
- if (delivery.context === "" && systemMessage === undefined) {
2170
+ : [receipt, reliabilityNotice].filter(Boolean).join(" · ") || undefined;
2171
+ const injectedContext = agent === "cursor" || agent === "polytoken"
2172
+ ? [delivery.context, reliabilityNotice]
2173
+ .filter((entry) => entry !== undefined && entry !== "")
2174
+ .join("\n\n")
2175
+ : delivery.context;
2176
+ if (injectedContext === "" && systemMessage === undefined) {
1278
2177
  return undefined;
1279
2178
  }
1280
2179
  if (agent === "polytoken") {
1281
2180
  return {
1282
2181
  outcome: "accept",
1283
- additional_context: delivery.context,
2182
+ additional_context: injectedContext,
2183
+ };
2184
+ }
2185
+ if (agent === "copilot-cli") {
2186
+ const copilotContext = [delivery.context, receipt, reliabilityNotice]
2187
+ .filter((entry) => entry !== undefined && entry !== "")
2188
+ .join("\n\n");
2189
+ if (copilotContext === "") {
2190
+ return undefined;
2191
+ }
2192
+ return {
2193
+ modifiedTransformedPrompt: `${copilotContext}\n\n${stringField(input.transformed_prompt) ?? prompt}`,
1284
2194
  };
1285
2195
  }
1286
2196
  return {
1287
2197
  ...(agent === "cursor" ? { continue: true } : {}),
1288
2198
  ...(systemMessage === undefined ? {} : { systemMessage }),
1289
- ...(delivery.context === ""
2199
+ ...(injectedContext === ""
1290
2200
  ? {}
1291
2201
  : {
1292
2202
  hookSpecificOutput: {
1293
2203
  hookEventName: "UserPromptSubmit",
1294
- additionalContext: delivery.context,
2204
+ additionalContext: injectedContext,
1295
2205
  },
1296
2206
  }),
1297
2207
  };
@@ -1312,25 +2222,75 @@ function parseAgent(args) {
1312
2222
  const value = index < 0 ? undefined : args[index + 1];
1313
2223
  return isCommandHookAgent(value) ? value : null;
1314
2224
  }
2225
+ function nativeIntegrationId(agent) {
2226
+ return agent === "copilot-vscode"
2227
+ ? "preview/copilot-vscode"
2228
+ : `native/${agent}`;
2229
+ }
2230
+ async function beginHookInvocation(agent) {
2231
+ const config = await readRuntimeConfig();
2232
+ if (config === null || !config.agents.includes(agent)) {
2233
+ return null;
2234
+ }
2235
+ const workspaceKey = reliabilityWorkspaceKey(config);
2236
+ const home = homeDirectory();
2237
+ const attempt = createIntegrationInvocationAttempt(nativeIntegrationId(agent), { runtimeVersion: RUNTIME_VERSION });
2238
+ const recorded = await writeInvocationAttemptBounded(workspaceKey, attempt, { home, standalone: IS_STANDALONE_RUNTIME });
2239
+ return recorded ? { workspaceKey, home, attempt } : null;
2240
+ }
2241
+ async function finishHookInvocation(tracker, outcome) {
2242
+ if (tracker === null) {
2243
+ return;
2244
+ }
2245
+ await writeInvocationCompletionBounded(tracker.workspaceKey, tracker.attempt, outcome, { home: tracker.home, standalone: IS_STANDALONE_RUNTIME }).catch(() => false);
2246
+ }
1315
2247
  export async function runHook(args = process.argv.slice(2)) {
1316
2248
  const agent = parseAgent(args);
1317
2249
  if (agent === null) {
1318
2250
  return;
1319
2251
  }
2252
+ const tracker = await beginHookInvocation(agent).catch(() => null);
2253
+ let input;
1320
2254
  try {
1321
- const result = await handleHookEvent(await readStdin(), agent);
2255
+ input = await readStdin();
2256
+ }
2257
+ catch {
2258
+ await finishHookInvocation(tracker, {
2259
+ success: false,
2260
+ failureCode: "invalid_input",
2261
+ });
2262
+ return;
2263
+ }
2264
+ try {
2265
+ const result = await handleHookEvent(input, agent);
1322
2266
  if (result !== undefined) {
1323
2267
  process.stdout.write(`${JSON.stringify(result)}\n`);
1324
2268
  }
1325
- }
1326
- catch {
2269
+ await finishHookInvocation(tracker, { success: true });
2270
+ }
2271
+ catch (error) {
2272
+ await finishHookInvocation(tracker, {
2273
+ success: false,
2274
+ failureCode: error instanceof InvalidHookInputError
2275
+ ? "invalid_input"
2276
+ : "runtime_error",
2277
+ });
1327
2278
  // Native hooks must never block or add error noise to an agent session.
1328
2279
  }
1329
2280
  }
1330
2281
  const entryPath = process.argv[1];
2282
+ const isRuntimeEntrypoint = entryPath !== undefined &&
2283
+ (() => {
2284
+ try {
2285
+ return (realpathSync(resolve(entryPath)) ===
2286
+ realpathSync(fileURLToPath(import.meta.url)));
2287
+ }
2288
+ catch {
2289
+ return import.meta.url === pathToFileURL(resolve(entryPath)).href;
2290
+ }
2291
+ })();
1331
2292
  if (!IS_STANDALONE_RUNTIME &&
1332
- entryPath !== undefined &&
1333
- import.meta.url === pathToFileURL(resolve(entryPath)).href) {
2293
+ isRuntimeEntrypoint) {
1334
2294
  void runHook();
1335
2295
  }
1336
2296
  //# sourceMappingURL=runtime.js.map