@lore-co/opencode 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.
package/dist/index.js CHANGED
@@ -1,11 +1,19 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { homedir } from "node:os";
2
3
  import { isAbsolute, relative } from "node:path";
3
- import { GenericAgentAdapter, injectSharedMemory, } from "@lore-co/adapter-generic";
4
- import { redactSensitiveText, stripLoreInjectedContext, } from "@lore-co/core";
4
+ import { GenericAgentAdapter, normalizeHostScope, toHostTask, injectSharedMemory, } from "@lore-co/adapter-generic";
5
+ import { canonicalJson, formatExplanation, redactUnknown, redactSensitiveText, stripLoreInjectedContext, } from "@lore-co/core";
6
+ import { ReliabilityStore, classifyRetryFailure, createIntegrationInvocationAttempt, recordLocalGuardMetric, recordLocalRetrievalMetric, } from "@lore-co/cli/reliability-store";
7
+ import { writeInvocationAttemptBounded, writeInvocationCompletionBounded, } from "@lore-co/cli/invocation-health-writer";
8
+ import { SignedSnapshotError, verifySignedSnapshot, } from "@lore-co/cli/signed-snapshot";
9
+ import { CONTEXT_CACHE_MAX_STALE_MS, isContextTransportFailure, selectCachedContext, } from "@lore-co/cli/context-fallback";
5
10
  import { boundedTimeout, createBoundedFetch, resolveLoreCredentials, withTimeout, } from "./config.js";
6
11
  import { canonicalRepositoryScope, repositoryScopeFromWorktree, } from "./repository.js";
12
+ import { OPENCODE_PLUGIN_VERSION } from "./version.js";
7
13
  export { DEFAULT_TIMEOUT_MS, MAX_CONFIG_BYTES, MAX_RESPONSE_BYTES, MAX_TIMEOUT_MS, MIN_TIMEOUT_MS, boundedTimeout, createBoundedFetch, resolveLoreCredentials, } from "./config.js";
8
14
  export { canonicalRepositoryScope, repositoryScopeFromWorktree, } from "./repository.js";
15
+ export { OPENCODE_PLUGIN_VERSION } from "./version.js";
16
+ export { OPENCODE_RELIABILITY_INTEGRATION, } from "@lore-co/core";
9
17
  export const MAX_CONTEXT_BYTES = 12_000;
10
18
  export const MAX_MESSAGE_BYTES = 100_000;
11
19
  export const MAX_SESSIONS = 256;
@@ -16,6 +24,9 @@ const MAX_METADATA_BYTES = 500;
16
24
  const MAX_PARTS_PER_MESSAGE = 200;
17
25
  const MAX_MESSAGE_SCAN = 20;
18
26
  const CONNECTOR = "lore-opencode-plugin";
27
+ const TRUST_KEY_REFRESH_TTL_MS = 60 * 60_000;
28
+ const TRUST_KEY_REFRESH_STATE_KEY = "snapshot-trust-keys";
29
+ const CONTEXT_NOTICE_COOLDOWN_MS = 15 * 60_000;
19
30
  function utf8Length(value) {
20
31
  return Buffer.byteLength(value, "utf8");
21
32
  }
@@ -101,6 +112,83 @@ function contextText(value) {
101
112
  }
102
113
  return truncateUtf8(redactSensitiveText(value).text.trim(), MAX_CONTEXT_BYTES);
103
114
  }
115
+ function objectValue(value) {
116
+ return typeof value === "object" &&
117
+ value !== null &&
118
+ !Array.isArray(value)
119
+ ? value
120
+ : null;
121
+ }
122
+ function contextRetrievalMetadata(value, payload) {
123
+ const metadata = objectValue(value);
124
+ const freshness = objectValue(metadata?.freshness);
125
+ const policy = objectValue(metadata?.policy);
126
+ const reasons = Array.isArray(metadata?.reasons)
127
+ ? metadata.reasons.filter((reason) => typeof reason === "string")
128
+ : [];
129
+ const source = metadata?.source;
130
+ const fallback = metadata?.fallback;
131
+ const live = source === "live" &&
132
+ freshness?.state === "fresh" &&
133
+ policy?.source === "live" &&
134
+ policy.loadedAt === payload.issuedAt &&
135
+ Date.now() < Date.parse(payload.validUntil);
136
+ const cached = source === "cache" &&
137
+ fallback === "cached_context" &&
138
+ metadata?.status === "degraded" &&
139
+ (freshness?.state === "fresh" || freshness?.state === "stale") &&
140
+ policy?.source === "cache" &&
141
+ policy.loadedAt === payload.asOf &&
142
+ reasons.includes("cached_context");
143
+ if (metadata?.contractVersion !== "reliability-v1" ||
144
+ metadata.operation !== "retrieval" ||
145
+ metadata.requestId !== payload.requestId ||
146
+ freshness?.asOf !== payload.asOf ||
147
+ freshness.validUntil !== payload.validUntil ||
148
+ policy?.version !== payload.policyVersion ||
149
+ policy.validUntil !== payload.validUntil ||
150
+ (!live && !cached)) {
151
+ return { valid: false };
152
+ }
153
+ if (cached) {
154
+ const ageMs = Math.max(0, Date.now() - Date.parse(payload.asOf));
155
+ const state = Date.now() < Date.parse(payload.validUntil) ? "fresh" : "stale";
156
+ return {
157
+ valid: true,
158
+ notice: `Lore used verified ${state} cached context (${ageLabel(ageMs)} old) because fresh context was unavailable.`,
159
+ metricOutcome: "cached_fallback",
160
+ cacheAgeMs: ageMs,
161
+ };
162
+ }
163
+ if (fallback === "live_lexical" &&
164
+ metadata?.status === "degraded" &&
165
+ reasons.includes("lexical_fallback")) {
166
+ return {
167
+ valid: true,
168
+ notice: "Lore used live lexical retrieval because semantic retrieval was unavailable.",
169
+ metricOutcome: "live_lexical_fallback",
170
+ };
171
+ }
172
+ const valid = fallback === "none" &&
173
+ metadata?.status === "ok" &&
174
+ reasons.length > 0;
175
+ return {
176
+ valid,
177
+ ...(valid ? { metricOutcome: "live_primary" } : {}),
178
+ };
179
+ }
180
+ function ageLabel(ageMs) {
181
+ if (ageMs < 60_000) {
182
+ return "less than a minute";
183
+ }
184
+ if (ageMs < 60 * 60_000) {
185
+ return `${Math.floor(ageMs / 60_000)}m`;
186
+ }
187
+ if (ageMs < 24 * 60 * 60_000) {
188
+ return `${Math.floor(ageMs / (60 * 60_000))}h`;
189
+ }
190
+ return `${Math.floor(ageMs / (24 * 60 * 60_000))}d`;
191
+ }
104
192
  function scopeFromContext(context, repositoryScope) {
105
193
  const repo = boundedScopeValue(repositoryScope, MAX_SCOPE_BYTES);
106
194
  const relativePath = relative(context.worktree, context.directory);
@@ -179,7 +267,12 @@ export function formatGuardBlockMessage(verdict) {
179
267
  const shown = required.length > 0 ? required : verdict.items;
180
268
  const lines = [
181
269
  "Lore Guard: this action conflicts with a required rule.",
182
- ...shown.map((item) => `- ${item.content} (${guardItemSource(item)})`),
270
+ ...shown.flatMap((item) => [
271
+ `- ${item.content}`,
272
+ ` ${item.explanation === undefined
273
+ ? `Source: ${guardItemSource(item)}.`
274
+ : formatExplanation(item.explanation)}`,
275
+ ]),
183
276
  verdict.confirm === undefined
184
277
  ? "Ask the user to confirm before retrying."
185
278
  : `Ask the user to confirm; they can approve with: ${verdict.confirm.command}`,
@@ -193,10 +286,9 @@ export function formatGuardAssistContext(verdict) {
193
286
  const lines = ["Relevant Lore context:"];
194
287
  for (const item of verdict.items) {
195
288
  lines.push(`- ${item.content}`);
196
- }
197
- lines.push("", "Sources:");
198
- for (const item of verdict.items) {
199
- lines.push(`- ${guardItemSource(item)}`);
289
+ lines.push(` ${item.explanation === undefined
290
+ ? `Source: ${guardItemSource(item)}.`
291
+ : formatExplanation(item.explanation)}`);
200
292
  }
201
293
  if (verdict.conflicts.length > 0) {
202
294
  lines.push("", "Conflicting Lore context (do not silently pick a winner):");
@@ -227,14 +319,18 @@ function pluginTimeout(options, env) {
227
319
  class LoreOpenCodeRuntime {
228
320
  #context;
229
321
  #adapter;
322
+ #store;
323
+ #workspaceId;
230
324
  #repositoryScope;
231
325
  #timeoutMs;
232
326
  #sessions = new Map();
233
- constructor(context, adapter, timeoutMs, repositoryScope) {
327
+ constructor(context, adapter, timeoutMs, repositoryScope, store, workspaceId) {
234
328
  this.#context = context;
235
329
  this.#adapter = adapter;
236
330
  this.#timeoutMs = timeoutMs;
237
331
  this.#repositoryScope = repositoryScope;
332
+ this.#store = store;
333
+ this.#workspaceId = workspaceId;
238
334
  }
239
335
  hooks() {
240
336
  return {
@@ -260,7 +356,11 @@ class LoreOpenCodeRuntime {
260
356
  blockMessage = await this.#guardBeforeTool(input, output);
261
357
  }
262
358
  catch {
263
- // Guard checks fail open; only a deliberate verdict may block.
359
+ const state = this.#session(input.sessionID);
360
+ if (state !== undefined) {
361
+ state.guardContext =
362
+ "Lore Guard is unavailable; this action is proceeding without a live policy decision.";
363
+ }
264
364
  return;
265
365
  }
266
366
  if (blockMessage !== undefined) {
@@ -276,6 +376,7 @@ class LoreOpenCodeRuntime {
276
376
  }
277
377
  else if (event.type === "session.deleted") {
278
378
  this.#deleteSession(event.properties.info.id);
379
+ await this.#clearPendingAssistant(event.properties.info.id);
279
380
  }
280
381
  }
281
382
  catch {
@@ -326,6 +427,447 @@ class LoreOpenCodeRuntime {
326
427
  return undefined;
327
428
  }
328
429
  }
430
+ #contextNotice(state, notice) {
431
+ if (notice === undefined) {
432
+ return "";
433
+ }
434
+ if (notice.includes("cached context")) {
435
+ return notice;
436
+ }
437
+ const now = Date.now();
438
+ if (state.contextNotice?.text === notice &&
439
+ now - state.contextNotice.at < CONTEXT_NOTICE_COOLDOWN_MS) {
440
+ return "";
441
+ }
442
+ state.contextNotice = { text: notice, at: now };
443
+ return notice;
444
+ }
445
+ #pendingStateKey(sessionId) {
446
+ return `opencode:pending:${sessionId}`;
447
+ }
448
+ async #loadPendingAssistant(sessionId) {
449
+ const stored = await this.#store?.readState(this.#pendingStateKey(sessionId));
450
+ const value = objectValue(stored);
451
+ if (value === null ||
452
+ typeof value.id !== "string" ||
453
+ typeof value.content !== "string" ||
454
+ typeof value.timestamp !== "string" ||
455
+ typeof value.completedAt !== "number" ||
456
+ typeof value.fingerprint !== "string") {
457
+ return undefined;
458
+ }
459
+ return {
460
+ id: value.id,
461
+ content: value.content,
462
+ timestamp: value.timestamp,
463
+ completedAt: value.completedAt,
464
+ fingerprint: value.fingerprint,
465
+ };
466
+ }
467
+ async #savePendingAssistant(sessionId, pending) {
468
+ await this.#store?.writeState(this.#pendingStateKey(sessionId), pending);
469
+ }
470
+ async #clearPendingAssistant(sessionId) {
471
+ await this.#store?.deleteState(this.#pendingStateKey(sessionId));
472
+ }
473
+ async #snapshotKeys(refresh = false) {
474
+ const store = this.#store;
475
+ const workspaceId = this.#workspaceId;
476
+ const adapter = this.#adapter;
477
+ if (store === undefined || workspaceId === undefined) {
478
+ throw new Error("Lore connector has no workspace identity; reconnect Lore");
479
+ }
480
+ if (!refresh) {
481
+ const cached = await store.readPublicTrustKeys();
482
+ const refreshState = objectValue(await store.readState(TRUST_KEY_REFRESH_STATE_KEY));
483
+ const refreshedAt = typeof refreshState?.refreshedAt === "string"
484
+ ? Date.parse(refreshState.refreshedAt)
485
+ : Number.NaN;
486
+ if (cached.length > 0 &&
487
+ Number.isFinite(refreshedAt) &&
488
+ Date.now() - refreshedAt < TRUST_KEY_REFRESH_TTL_MS &&
489
+ refreshedAt <= Date.now() + 5 * 60_000) {
490
+ return { workspaceId, keys: cached };
491
+ }
492
+ }
493
+ if (adapter?.getSnapshotPublicKeys === undefined) {
494
+ throw new Error("Lore adapter does not support snapshot keys");
495
+ }
496
+ const keySet = await adapter.getSnapshotPublicKeys();
497
+ if (keySet.workspaceId !== workspaceId) {
498
+ throw new Error("Lore snapshot keys belong to a different workspace");
499
+ }
500
+ const keys = keySet.keys.map((key) => ({
501
+ ...key,
502
+ workspaceId,
503
+ }));
504
+ await store.writePublicTrustKeys(keys);
505
+ await store.writeState(TRUST_KEY_REFRESH_STATE_KEY, {
506
+ refreshedAt: new Date().toISOString(),
507
+ });
508
+ return { workspaceId, keys };
509
+ }
510
+ async #cachedSnapshotKeys() {
511
+ const store = this.#store;
512
+ const workspaceId = this.#workspaceId;
513
+ if (store === undefined || workspaceId === undefined) {
514
+ return null;
515
+ }
516
+ const [keys, refreshState] = await Promise.all([
517
+ store.readPublicTrustKeys(),
518
+ store.readState(TRUST_KEY_REFRESH_STATE_KEY),
519
+ ]);
520
+ const refreshedAt = typeof objectValue(refreshState)?.refreshedAt === "string"
521
+ ? Date.parse(String(objectValue(refreshState)?.refreshedAt))
522
+ : Number.NaN;
523
+ if (keys.length === 0 ||
524
+ !Number.isFinite(refreshedAt) ||
525
+ Date.now() - refreshedAt >= TRUST_KEY_REFRESH_TTL_MS ||
526
+ refreshedAt > Date.now() + 5 * 60_000) {
527
+ return null;
528
+ }
529
+ return { workspaceId, keys };
530
+ }
531
+ async #cachedContext(delivery) {
532
+ const store = this.#store;
533
+ const workspaceId = this.#workspaceId;
534
+ const keys = await this.#cachedSnapshotKeys();
535
+ if (store === undefined || workspaceId === undefined || keys === null) {
536
+ return null;
537
+ }
538
+ const candidates = [];
539
+ for (const record of (await store.listContextSnapshots()).records) {
540
+ const value = objectValue(record.value);
541
+ if (value === null || typeof value.compactJws !== "string") {
542
+ continue;
543
+ }
544
+ try {
545
+ candidates.push({
546
+ cacheKey: record.cacheKey,
547
+ compactJws: value.compactJws,
548
+ payload: verifySignedSnapshot(value.compactJws, {
549
+ workspaceId,
550
+ kind: "context",
551
+ keys,
552
+ allowExpired: true,
553
+ }).payload,
554
+ });
555
+ }
556
+ catch {
557
+ // One invalid cache entry must not hide another verified candidate.
558
+ }
559
+ }
560
+ const task = redactUnknown(toHostTask(delivery, "opencode")).value;
561
+ const taskValue = objectValue(task);
562
+ if (taskValue === null ||
563
+ typeof taskValue.task !== "string" ||
564
+ !objectValue(taskValue.scope)) {
565
+ return null;
566
+ }
567
+ const fallbackTask = {
568
+ workspaceId,
569
+ task: taskValue.task,
570
+ scope: taskValue.scope,
571
+ ...(Array.isArray(taskValue.files)
572
+ ? { files: taskValue.files.filter((item) => typeof item === "string") }
573
+ : {}),
574
+ ...(Array.isArray(taskValue.components)
575
+ ? {
576
+ components: taskValue.components.filter((item) => typeof item === "string"),
577
+ }
578
+ : {}),
579
+ ...(Array.isArray(taskValue.symbols)
580
+ ? {
581
+ symbols: taskValue.symbols.filter((item) => typeof item === "string"),
582
+ }
583
+ : {}),
584
+ };
585
+ const selected = selectCachedContext(candidates, fallbackTask);
586
+ if (selected === null) {
587
+ return null;
588
+ }
589
+ return {
590
+ context: contextText(selected.context),
591
+ notice: `Lore used verified ${selected.freshness.state} cached context (${ageLabel(selected.freshness.ageMs)} old) because live retrieval was unavailable.`,
592
+ metricOutcome: "cached_fallback",
593
+ cacheAgeMs: selected.freshness.ageMs,
594
+ };
595
+ }
596
+ async #verifySnapshot(compactJws, kind, allowExpired = false) {
597
+ const workspaceId = this.#workspaceId;
598
+ if (workspaceId === undefined) {
599
+ throw new Error("Lore connector has no workspace identity; reconnect Lore");
600
+ }
601
+ const verifyWith = (keys) => verifySignedSnapshot(compactJws, {
602
+ workspaceId,
603
+ kind,
604
+ keys,
605
+ allowExpired,
606
+ }).payload;
607
+ try {
608
+ return verifyWith(await this.#snapshotKeys());
609
+ }
610
+ catch (error) {
611
+ if (!(error instanceof SignedSnapshotError) ||
612
+ error.code !== "UNTRUSTED_KEY") {
613
+ throw error;
614
+ }
615
+ return verifyWith(await this.#snapshotKeys(true));
616
+ }
617
+ }
618
+ #captureCommitted(response, externalEventId, serverEventId) {
619
+ const acknowledgement = response.capture.acknowledgement;
620
+ return (acknowledgement?.state === "committed_server" &&
621
+ acknowledgement.durability === "workspace_database" &&
622
+ acknowledgement.replayPending === false &&
623
+ acknowledgement.idempotencyKey === externalEventId &&
624
+ acknowledgement.eventId === serverEventId);
625
+ }
626
+ async #persistContextSnapshot(compactJws, cacheKey, expectedRequest, eventId, receiptId) {
627
+ const payload = await this.#verifySnapshot(compactJws, "context", true);
628
+ const request = objectValue(payload.request);
629
+ if (request === null ||
630
+ canonicalJson(request) !== canonicalJson(expectedRequest) ||
631
+ payload.eventId !== eventId ||
632
+ payload.receiptId !== receiptId ||
633
+ typeof payload.context !== "string" ||
634
+ !Array.isArray(payload.memories) ||
635
+ !Array.isArray(payload.hits) ||
636
+ objectValue(payload.packing) === null) {
637
+ throw new Error("Lore context snapshot does not bind its request and delivery");
638
+ }
639
+ if (Date.now() - Date.parse(payload.validUntil) >
640
+ CONTEXT_CACHE_MAX_STALE_MS) {
641
+ return null;
642
+ }
643
+ await this.#store
644
+ ?.writeContextSnapshot(cacheKey, { compactJws })
645
+ .catch(() => undefined);
646
+ return payload;
647
+ }
648
+ async #enqueueCapture(kind, eventId, payload) {
649
+ if (this.#store === undefined) {
650
+ return null;
651
+ }
652
+ return (await this.#store.enqueue({
653
+ kind,
654
+ idempotencyKey: eventId,
655
+ payload,
656
+ })).entry;
657
+ }
658
+ async #flushCapture() {
659
+ const store = this.#store;
660
+ const adapter = this.#adapter;
661
+ if (store === undefined || adapter === undefined) {
662
+ return null;
663
+ }
664
+ const claimed = await store.claimNext({
665
+ workerId: `opencode:${process.pid}`,
666
+ kinds: ["opencode-turn", "opencode-observation"],
667
+ });
668
+ if (claimed === null || claimed.claim === undefined) {
669
+ return null;
670
+ }
671
+ try {
672
+ const payload = objectValue(claimed.payload);
673
+ if (payload === null) {
674
+ throw Object.assign(new Error("Invalid OpenCode outbox payload"), {
675
+ incompatible: true,
676
+ });
677
+ }
678
+ let turn;
679
+ let signedContext;
680
+ let retrievalNotice;
681
+ if (claimed.kind === "opencode-turn") {
682
+ if (adapter.processTurnReliable === undefined) {
683
+ throw Object.assign(new Error("Lore adapter has no reliable turn operation"), { incompatible: true });
684
+ }
685
+ const turnInput = payload;
686
+ turn = await adapter.processTurnReliable(turnInput, claimed.idempotencyKey);
687
+ if (!this.#captureCommitted(turn, claimed.idempotencyKey, turn.turn.event.id)) {
688
+ throw Object.assign(new Error("Lore turn has no matching durable acknowledgement"), { incompatible: true });
689
+ }
690
+ const contextSnapshot = await this.#persistContextSnapshot(turn.contextSnapshot, claimed.idempotencyKey, objectValue(redactUnknown({
691
+ connector: turnInput.connector,
692
+ eventId: turnInput.eventId,
693
+ sessionId: turnInput.sessionId,
694
+ task: {
695
+ agent: "opencode",
696
+ scope: normalizeHostScope(turnInput),
697
+ task: typeof turnInput.currentUser === "string"
698
+ ? turnInput.currentUser
699
+ : (turnInput.currentUser?.content ??
700
+ turnInput.currentUserPrompt ??
701
+ ""),
702
+ },
703
+ }).value) ?? {}, turn.turn.event.id, turn.turn.receipt.id);
704
+ if (contextSnapshot === null) {
705
+ retrievalNotice =
706
+ "Lore context cache is too old to use; continuing without injected context.";
707
+ }
708
+ else {
709
+ const metadata = contextRetrievalMetadata(turn.retrieval, contextSnapshot);
710
+ if (metadata.valid) {
711
+ signedContext = contextText(contextSnapshot.context);
712
+ retrievalNotice = metadata.notice;
713
+ }
714
+ else {
715
+ retrievalNotice =
716
+ "Lore context could not be trusted; continuing without injected context.";
717
+ }
718
+ }
719
+ }
720
+ else {
721
+ if (adapter.observeEventReliable === undefined) {
722
+ throw Object.assign(new Error("Lore adapter has no reliable observation operation"), { incompatible: true });
723
+ }
724
+ const observed = await adapter.observeEventReliable(payload);
725
+ if (!this.#captureCommitted(observed, claimed.idempotencyKey, observed.observation.event.id)) {
726
+ throw Object.assign(new Error("Lore observation has no matching durable acknowledgement"), { incompatible: true });
727
+ }
728
+ }
729
+ await store.acknowledgeClaim(claimed.claim.id);
730
+ return {
731
+ idempotencyKey: claimed.idempotencyKey,
732
+ ...(turn === undefined ? {} : { turn }),
733
+ ...(signedContext === undefined ? {} : { signedContext }),
734
+ ...(retrievalNotice === undefined ? {} : { retrievalNotice }),
735
+ };
736
+ }
737
+ catch (error) {
738
+ const classified = classifyRetryFailure(error);
739
+ const failure = objectValue(error);
740
+ const httpStatus = typeof failure?.httpStatus === "number"
741
+ ? failure.httpStatus
742
+ : typeof failure?.status === "number"
743
+ ? failure.status
744
+ : undefined;
745
+ await store.failClaim({
746
+ claimId: claimed.claim.id,
747
+ classification: error instanceof SignedSnapshotError
748
+ ? "incompatible"
749
+ : classified === "retryable" &&
750
+ httpStatus !== 409 &&
751
+ !isContextTransportFailure(error)
752
+ ? "incompatible"
753
+ : classified,
754
+ message: error instanceof Error ? error.message : "Lore upload failed",
755
+ });
756
+ return null;
757
+ }
758
+ }
759
+ async #prepareContextDelivery(delivery) {
760
+ const adapter = this.#adapter;
761
+ if (adapter === undefined) {
762
+ return undefined;
763
+ }
764
+ if (this.#store !== undefined && this.#workspaceId === undefined) {
765
+ return {
766
+ context: "",
767
+ notice: "Lore context is temporarily unavailable; continuing without injected context.",
768
+ metricOutcome: "failed",
769
+ };
770
+ }
771
+ if (this.#store !== undefined &&
772
+ this.#workspaceId !== undefined &&
773
+ adapter.prepareDeliveryReliable !== undefined) {
774
+ try {
775
+ const response = await adapter.prepareDeliveryReliable(delivery);
776
+ const expectedRequest = redactUnknown({
777
+ connector: delivery.connector,
778
+ eventId: delivery.eventId,
779
+ sessionId: delivery.sessionId,
780
+ task: toHostTask(delivery, "opencode"),
781
+ }).value;
782
+ const contextSnapshot = await this.#persistContextSnapshot(response.snapshot, delivery.eventId, objectValue(expectedRequest) ?? {}, response.delivery.event.id, response.delivery.receipt.id);
783
+ if (contextSnapshot === null) {
784
+ return {
785
+ context: "",
786
+ notice: "Lore context cache is too old to use; continuing without injected context.",
787
+ metricOutcome: "failed",
788
+ };
789
+ }
790
+ const metadata = contextRetrievalMetadata(response.reliability, contextSnapshot);
791
+ if (!metadata.valid) {
792
+ return {
793
+ context: "",
794
+ notice: "Lore context could not be trusted; continuing without injected context.",
795
+ metricOutcome: "failed",
796
+ };
797
+ }
798
+ return {
799
+ context: contextText(contextSnapshot.context),
800
+ ...(metadata.notice === undefined
801
+ ? {}
802
+ : { notice: metadata.notice }),
803
+ ...(metadata.metricOutcome === undefined
804
+ ? {}
805
+ : { metricOutcome: metadata.metricOutcome }),
806
+ ...(metadata.cacheAgeMs === undefined
807
+ ? {}
808
+ : { cacheAgeMs: metadata.cacheAgeMs }),
809
+ };
810
+ }
811
+ catch (error) {
812
+ if (isContextTransportFailure(error)) {
813
+ const cached = await this.#cachedContext(delivery).catch(() => null);
814
+ if (cached !== null) {
815
+ return cached;
816
+ }
817
+ return {
818
+ context: "",
819
+ notice: "Lore context is temporarily unavailable; continuing without injected context.",
820
+ metricOutcome: "failed",
821
+ };
822
+ }
823
+ return {
824
+ context: "",
825
+ notice: "Lore context could not be trusted; continuing without injected context.",
826
+ metricOutcome: "failed",
827
+ };
828
+ }
829
+ }
830
+ try {
831
+ const response = await adapter.prepareDelivery(delivery);
832
+ return { context: contextText(response.context) };
833
+ }
834
+ catch {
835
+ return {
836
+ context: "",
837
+ notice: "Lore context is temporarily unavailable; continuing without injected context.",
838
+ };
839
+ }
840
+ }
841
+ async #recordPreparedContextMetric(prepared) {
842
+ if (this.#store === undefined ||
843
+ prepared?.metricOutcome === undefined) {
844
+ return;
845
+ }
846
+ await recordLocalRetrievalMetric(this.#store, {
847
+ outcome: prepared.metricOutcome,
848
+ ...(prepared.cacheAgeMs === undefined
849
+ ? {}
850
+ : { cacheAgeMs: prepared.cacheAgeMs }),
851
+ }).catch(() => undefined);
852
+ }
853
+ async #checkGuard(input) {
854
+ const adapter = this.#adapter;
855
+ if (adapter === undefined) {
856
+ return undefined;
857
+ }
858
+ if (this.#store !== undefined &&
859
+ this.#workspaceId !== undefined &&
860
+ adapter.guardCheckReliable !== undefined) {
861
+ const response = await adapter.guardCheckReliable(input);
862
+ const payload = await this.#verifySnapshot(response.policySnapshot, "guard_policy");
863
+ if (payload.policyVersion !== response.policyVersion) {
864
+ throw new Error("Lore Guard snapshot does not bind the returned policy");
865
+ }
866
+ await this.#store.writePolicySnapshot("current", response.policySnapshot);
867
+ return response.check;
868
+ }
869
+ return adapter.guardCheck?.(input);
870
+ }
329
871
  async #captureAndDeliver(input, output) {
330
872
  if (this.#adapter === undefined) {
331
873
  return;
@@ -359,7 +901,8 @@ class LoreOpenCodeRuntime {
359
901
  }
360
902
  }
361
903
  async #processChatMessage(state, sessionId, messageId, content, timestamp, fingerprint, input) {
362
- if (!state.active) {
904
+ const adapter = this.#adapter;
905
+ if (!state.active || adapter === undefined) {
363
906
  return;
364
907
  }
365
908
  if (state.lastChat?.fingerprint === fingerprint) {
@@ -375,10 +918,16 @@ class LoreOpenCodeRuntime {
375
918
  state.context = "";
376
919
  const scope = scopeFromContext(this.#context, this.#repositoryScope);
377
920
  const metadata = metadataFromChat(input, this.#context);
378
- const pendingAssistant = state.pendingAssistant;
921
+ await this.#flushCapture();
922
+ const pendingAssistant = state.pendingAssistant ??
923
+ (await this.#loadPendingAssistant(sessionId));
924
+ if (state.pendingAssistant === undefined &&
925
+ pendingAssistant !== undefined) {
926
+ state.pendingAssistant = pendingAssistant;
927
+ }
379
928
  if (pendingAssistant !== undefined) {
380
929
  const eventId = stableEventId("turn", sessionId, pendingAssistant.fingerprint, fingerprint);
381
- const processed = await this.#attempt(() => this.#adapter.processTurn({
930
+ const turnInput = {
382
931
  connector: CONNECTOR,
383
932
  eventId,
384
933
  sessionId,
@@ -402,12 +951,69 @@ class LoreOpenCodeRuntime {
402
951
  previousAssistantMessageId: pendingAssistant.id,
403
952
  currentUserMessageId: messageId,
404
953
  },
405
- }, eventId));
406
- if (processed === undefined || !state.active) {
954
+ };
955
+ let context = "";
956
+ let pendingDurable = false;
957
+ if (this.#store !== undefined &&
958
+ adapter.processTurnReliable !== undefined) {
959
+ try {
960
+ await this.#enqueueCapture("opencode-turn", eventId, turnInput);
961
+ pendingDurable = true;
962
+ await this.#clearPendingAssistant(sessionId);
963
+ const flushed = await this.#flushCapture();
964
+ if (flushed?.idempotencyKey === eventId &&
965
+ flushed.turn !== undefined) {
966
+ await recordLocalRetrievalMetric(this.#store, {
967
+ outcome: flushed.retrievalNotice?.includes("lexical")
968
+ ? "live_lexical_fallback"
969
+ : "live_primary",
970
+ }).catch(() => undefined);
971
+ context = [
972
+ this.#contextNotice(state, flushed.retrievalNotice),
973
+ flushed.signedContext,
974
+ ]
975
+ .filter((value) => value !== undefined && value !== "")
976
+ .join("\n\n");
977
+ }
978
+ else {
979
+ const prepared = await this.#prepareContextDelivery({
980
+ connector: CONNECTOR,
981
+ eventId: stableEventId("delivery", sessionId, eventId, content),
982
+ sessionId,
983
+ scope,
984
+ task: content,
985
+ limit: 10,
986
+ });
987
+ await this.#recordPreparedContextMetric(prepared);
988
+ context = [
989
+ "Lore saved this capture locally; server upload is pending.",
990
+ this.#contextNotice(state, prepared?.notice),
991
+ prepared?.context,
992
+ ]
993
+ .filter((value) => value !== undefined && value !== "")
994
+ .join("\n\n");
995
+ }
996
+ }
997
+ catch {
998
+ context =
999
+ "Lore could not acknowledge this capture locally. Run lore doctor.";
1000
+ }
1001
+ }
1002
+ else {
1003
+ const processed = await this.#attempt(() => adapter.processTurn(turnInput, eventId));
1004
+ if (processed === undefined || !state.active) {
1005
+ return;
1006
+ }
1007
+ pendingDurable = true;
1008
+ context = contextText(processed.context.text);
1009
+ }
1010
+ if (!state.active) {
407
1011
  return;
408
1012
  }
409
- const context = contextText(processed.context.text);
410
1013
  state.context = context;
1014
+ if (!pendingDurable) {
1015
+ return;
1016
+ }
411
1017
  state.lastChat = { fingerprint, context };
412
1018
  state.lastConsumedAssistant = {
413
1019
  completedAt: pendingAssistant.completedAt,
@@ -438,16 +1044,52 @@ class LoreOpenCodeRuntime {
438
1044
  task: content,
439
1045
  limit: 10,
440
1046
  };
441
- const [observed, prepared] = await Promise.all([
442
- this.#attempt(() => this.#adapter.observeEvent(observation)),
443
- this.#attempt(() => this.#adapter.prepareDelivery(delivery)),
444
- ]);
1047
+ let observed;
1048
+ let preparedContext;
1049
+ let captureNotice = "";
1050
+ if (this.#store !== undefined &&
1051
+ adapter.observeEventReliable !== undefined) {
1052
+ try {
1053
+ await this.#enqueueCapture("opencode-observation", observationId, observation);
1054
+ }
1055
+ catch {
1056
+ captureNotice =
1057
+ "Lore could not acknowledge this capture locally. Run lore doctor.";
1058
+ }
1059
+ const [flushed, prepared] = await Promise.all([
1060
+ this.#flushCapture(),
1061
+ this.#prepareContextDelivery(delivery),
1062
+ ]);
1063
+ await this.#recordPreparedContextMetric(prepared);
1064
+ observed =
1065
+ flushed?.idempotencyKey === observationId ? flushed : undefined;
1066
+ preparedContext = [
1067
+ this.#contextNotice(state, prepared?.notice),
1068
+ prepared?.context,
1069
+ ]
1070
+ .filter((value) => value !== undefined && value !== "")
1071
+ .join("\n\n");
1072
+ if (observed === undefined && captureNotice === "") {
1073
+ captureNotice =
1074
+ "Lore saved this capture locally; server upload is pending.";
1075
+ }
1076
+ }
1077
+ else {
1078
+ const [legacyObserved, prepared] = await Promise.all([
1079
+ this.#attempt(() => adapter.observeEvent(observation)),
1080
+ this.#attempt(() => adapter.prepareDelivery(delivery)),
1081
+ ]);
1082
+ observed = legacyObserved;
1083
+ preparedContext = contextText(prepared?.context);
1084
+ }
445
1085
  if (!state.active) {
446
1086
  return;
447
1087
  }
448
- const context = contextText(prepared?.context);
1088
+ const context = [captureNotice, preparedContext]
1089
+ .filter((value) => value !== undefined && value !== "")
1090
+ .join("\n\n");
449
1091
  state.context = context;
450
- if (observed !== undefined && prepared !== undefined) {
1092
+ if (observed !== undefined && preparedContext !== undefined) {
451
1093
  state.lastChat = { fingerprint, context };
452
1094
  }
453
1095
  }
@@ -479,7 +1121,8 @@ class LoreOpenCodeRuntime {
479
1121
  }
480
1122
  async #guardBeforeTool(input, output) {
481
1123
  const adapter = this.#adapter;
482
- if (adapter?.guardCheck === undefined) {
1124
+ if (adapter?.guardCheck === undefined &&
1125
+ adapter?.guardCheckReliable === undefined) {
483
1126
  return undefined;
484
1127
  }
485
1128
  const sessionId = boundedIdentifier(input.sessionID);
@@ -500,11 +1143,21 @@ class LoreOpenCodeRuntime {
500
1143
  const now = Date.now();
501
1144
  if (state.guardMode?.mode === "off" &&
502
1145
  now - state.guardMode.checkedAt < GUARD_MODE_TTL_MS) {
1146
+ if (this.#store !== undefined) {
1147
+ await recordLocalGuardMetric(this.#store, {
1148
+ outcome: "reused_decision",
1149
+ }).catch(() => undefined);
1150
+ }
503
1151
  return undefined;
504
1152
  }
505
1153
  const key = guardKey(trigger);
506
1154
  const lastSeen = state.guardKeys?.get(key);
507
1155
  if (lastSeen !== undefined && now - lastSeen < GUARD_KEY_COOLDOWN_MS) {
1156
+ if (this.#store !== undefined) {
1157
+ await recordLocalGuardMetric(this.#store, {
1158
+ outcome: "reused_decision",
1159
+ }).catch(() => undefined);
1160
+ }
508
1161
  return undefined;
509
1162
  }
510
1163
  const scope = scopeFromContext(this.#context, this.#repositoryScope);
@@ -517,18 +1170,36 @@ class LoreOpenCodeRuntime {
517
1170
  return relativePath.startsWith("..") ? file : relativePath;
518
1171
  })
519
1172
  .map((file) => truncateUtf8(file, MAX_PATH_BYTES));
520
- const verdict = await this.#attempt(() => adapter.guardCheck({
521
- connector: CONNECTOR,
522
- sessionId,
523
- action: trigger.action,
524
- tool: input.tool,
525
- ...(scope.repo === undefined ? {} : { repo: scope.repo }),
526
- ...(scope.path === undefined ? {} : { path: scope.path }),
527
- ...(files === undefined ? {} : { files }),
528
- ...(trigger.command === undefined
529
- ? {}
530
- : { command: truncateUtf8(trigger.command, MAX_PATH_BYTES) }),
531
- }));
1173
+ const startedAt = Date.now();
1174
+ let verdict;
1175
+ try {
1176
+ verdict = await withTimeout(this.#timeoutMs, () => this.#checkGuard({
1177
+ connector: CONNECTOR,
1178
+ sessionId,
1179
+ action: trigger.action,
1180
+ tool: input.tool,
1181
+ ...(scope.repo === undefined ? {} : { repo: scope.repo }),
1182
+ ...(scope.path === undefined ? {} : { path: scope.path }),
1183
+ ...(files === undefined ? {} : { files }),
1184
+ ...(trigger.command === undefined
1185
+ ? {}
1186
+ : { command: truncateUtf8(trigger.command, MAX_PATH_BYTES) }),
1187
+ }));
1188
+ }
1189
+ catch (error) {
1190
+ if (this.#store !== undefined) {
1191
+ await recordLocalGuardMetric(this.#store, {
1192
+ outcome: "failed",
1193
+ }).catch(() => undefined);
1194
+ }
1195
+ throw error;
1196
+ }
1197
+ if (this.#store !== undefined) {
1198
+ await recordLocalGuardMetric(this.#store, {
1199
+ outcome: verdict === undefined ? "failed" : "live_check",
1200
+ durationMs: Math.max(0, Date.now() - startedAt),
1201
+ }).catch(() => undefined);
1202
+ }
532
1203
  if (verdict === undefined || !state.active) {
533
1204
  return undefined;
534
1205
  }
@@ -644,13 +1315,15 @@ class LoreOpenCodeRuntime {
644
1315
  fingerprint === state.pendingAssistant.fingerprint))) {
645
1316
  return;
646
1317
  }
647
- state.pendingAssistant = {
1318
+ const pendingAssistant = {
648
1319
  id: assistantId,
649
1320
  content: assistantContent,
650
1321
  timestamp: isoTimestamp(completedAt),
651
1322
  completedAt,
652
1323
  fingerprint,
653
1324
  };
1325
+ await this.#savePendingAssistant(sessionId, pendingAssistant);
1326
+ state.pendingAssistant = pendingAssistant;
654
1327
  }
655
1328
  #deleteSession(sessionId) {
656
1329
  const state = this.#sessions.get(sessionId);
@@ -666,18 +1339,40 @@ class LoreOpenCodeRuntime {
666
1339
  this.#sessions.delete(sessionId);
667
1340
  }
668
1341
  }
1342
+ function effectiveHome(dependencies) {
1343
+ return (dependencies.home ??
1344
+ (dependencies.env ?? process.env).HOME ??
1345
+ homedir());
1346
+ }
669
1347
  async function createAdapter(dependencies, timeoutMs) {
670
1348
  if (dependencies.adapter !== undefined) {
671
- return dependencies.adapter;
1349
+ if (dependencies.workspaceId === undefined) {
1350
+ return {
1351
+ adapter: dependencies.adapter,
1352
+ store: undefined,
1353
+ invocationWorkspaceKey: undefined,
1354
+ workspaceId: undefined,
1355
+ };
1356
+ }
1357
+ const store = new ReliabilityStore(dependencies.workspaceId, {
1358
+ home: effectiveHome(dependencies),
1359
+ });
1360
+ await store.initialize();
1361
+ return {
1362
+ adapter: dependencies.adapter,
1363
+ store,
1364
+ invocationWorkspaceKey: dependencies.workspaceId,
1365
+ workspaceId: dependencies.workspaceId,
1366
+ };
672
1367
  }
673
1368
  try {
674
1369
  const env = dependencies.env ?? process.env;
675
1370
  const credentials = await resolveLoreCredentials({
676
1371
  env,
677
- ...(dependencies.home === undefined ? {} : { home: dependencies.home }),
1372
+ home: effectiveHome(dependencies),
678
1373
  });
679
1374
  const fetchImplementation = dependencies.fetch ?? globalThis.fetch;
680
- return new GenericAgentAdapter({
1375
+ const adapter = new GenericAgentAdapter({
681
1376
  id: "opencode",
682
1377
  baseUrl: credentials.apiUrl,
683
1378
  headers: {
@@ -685,20 +1380,99 @@ async function createAdapter(dependencies, timeoutMs) {
685
1380
  },
686
1381
  fetch: createBoundedFetch(fetchImplementation, timeoutMs),
687
1382
  });
1383
+ let workspaceId = credentials.workspaceId;
1384
+ let keySet;
1385
+ let legacyServer = false;
1386
+ if (workspaceId === undefined) {
1387
+ try {
1388
+ keySet = await adapter.getSnapshotPublicKeys();
1389
+ workspaceId = keySet.workspaceId;
1390
+ }
1391
+ catch (error) {
1392
+ legacyServer =
1393
+ objectValue(error)?.status === 404;
1394
+ // Captures can still queue under a credential-scoped fallback.
1395
+ }
1396
+ }
1397
+ if (legacyServer) {
1398
+ return {
1399
+ adapter,
1400
+ store: undefined,
1401
+ invocationWorkspaceKey: undefined,
1402
+ workspaceId: undefined,
1403
+ };
1404
+ }
1405
+ const credentialStoreKey = `credential-${createHash("sha256")
1406
+ .update(`${credentials.apiUrl}\0${credentials.token}`)
1407
+ .digest("hex")
1408
+ .slice(0, 32)}`;
1409
+ const storeKey = workspaceId ?? credentialStoreKey;
1410
+ const store = new ReliabilityStore(storeKey, {
1411
+ home: effectiveHome(dependencies),
1412
+ });
1413
+ await store.initialize();
1414
+ if (workspaceId !== undefined) {
1415
+ const credentialStore = new ReliabilityStore(credentialStoreKey, {
1416
+ home: effectiveHome(dependencies),
1417
+ });
1418
+ await credentialStore.transferPendingTo(store);
1419
+ }
1420
+ if (keySet !== undefined && workspaceId !== undefined) {
1421
+ await store.writePublicTrustKeys(keySet.keys.map((key) => ({
1422
+ ...key,
1423
+ workspaceId,
1424
+ })));
1425
+ await store.writeState(TRUST_KEY_REFRESH_STATE_KEY, {
1426
+ refreshedAt: new Date().toISOString(),
1427
+ });
1428
+ }
1429
+ return {
1430
+ adapter,
1431
+ store,
1432
+ invocationWorkspaceKey: storeKey,
1433
+ workspaceId,
1434
+ };
688
1435
  }
689
1436
  catch {
690
- return undefined;
1437
+ return {
1438
+ adapter: undefined,
1439
+ store: undefined,
1440
+ invocationWorkspaceKey: undefined,
1441
+ workspaceId: undefined,
1442
+ };
691
1443
  }
692
1444
  }
693
1445
  export function createLoreOpenCodePlugin(dependencies = {}) {
694
1446
  return async (context, options) => {
695
1447
  const env = dependencies.env ?? process.env;
696
1448
  const timeoutMs = pluginTimeout(options, env);
697
- const adapter = await createAdapter(dependencies, timeoutMs);
1449
+ const runtime = await createAdapter(dependencies, timeoutMs);
1450
+ const invocation = runtime.invocationWorkspaceKey === undefined
1451
+ ? null
1452
+ : createIntegrationInvocationAttempt("plugin/opencode", { runtimeVersion: OPENCODE_PLUGIN_VERSION });
1453
+ const invocationRecorded = runtime.invocationWorkspaceKey === undefined || invocation === null
1454
+ ? false
1455
+ : await writeInvocationAttemptBounded(runtime.invocationWorkspaceKey, invocation, { home: effectiveHome(dependencies) }).catch(() => false);
698
1456
  const repositoryScope = dependencies.repositoryScope === undefined
699
1457
  ? await repositoryScopeFromWorktree(context.worktree)
700
1458
  : canonicalRepositoryScope(dependencies.repositoryScope);
701
- return new LoreOpenCodeRuntime(context, adapter, timeoutMs, repositoryScope).hooks();
1459
+ try {
1460
+ const hooks = new LoreOpenCodeRuntime(context, runtime.adapter, timeoutMs, repositoryScope, runtime.store, runtime.workspaceId).hooks();
1461
+ if (runtime.invocationWorkspaceKey !== undefined &&
1462
+ invocation !== null &&
1463
+ invocationRecorded) {
1464
+ await writeInvocationCompletionBounded(runtime.invocationWorkspaceKey, invocation, { success: true }, { home: effectiveHome(dependencies) }).catch(() => undefined);
1465
+ }
1466
+ return hooks;
1467
+ }
1468
+ catch (error) {
1469
+ if (runtime.invocationWorkspaceKey !== undefined &&
1470
+ invocation !== null &&
1471
+ invocationRecorded) {
1472
+ await writeInvocationCompletionBounded(runtime.invocationWorkspaceKey, invocation, { success: false, failureCode: "runtime_error" }, { home: effectiveHome(dependencies) }).catch(() => undefined);
1473
+ }
1474
+ throw error;
1475
+ }
702
1476
  };
703
1477
  }
704
1478
  export const LoreOpenCodePlugin = createLoreOpenCodePlugin();