@avocadostudio-ai/orchestrator-core 0.3.2 → 0.4.0

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 (88) hide show
  1. package/dist/chat/anthropic-planner.d.ts +8 -0
  2. package/dist/chat/anthropic-planner.js +166 -12
  3. package/dist/chat/chat-pipeline-translation.d.ts +13 -0
  4. package/dist/chat/chat-pipeline-translation.js +109 -45
  5. package/dist/chat/chat-pipeline.d.ts +1 -1
  6. package/dist/chat/chat-pipeline.js +312 -54
  7. package/dist/chat/gemini-planner.d.ts +2 -0
  8. package/dist/chat/gemini-planner.js +2 -1
  9. package/dist/chat/planner-types.d.ts +15 -0
  10. package/dist/chat/planner-types.js +2 -2
  11. package/dist/chat/planner.d.ts +12 -0
  12. package/dist/chat/planner.js +16 -2
  13. package/dist/chat/prompts.d.ts +5 -0
  14. package/dist/chat/prompts.js +92 -9
  15. package/dist/chat/translation-chunking.d.ts +124 -0
  16. package/dist/chat/translation-chunking.js +371 -0
  17. package/dist/checks/field-walk.d.ts +42 -0
  18. package/dist/checks/field-walk.js +198 -0
  19. package/dist/checks/index.d.ts +5 -0
  20. package/dist/checks/index.js +4 -0
  21. package/dist/checks/page-weight.d.ts +22 -0
  22. package/dist/checks/page-weight.js +200 -0
  23. package/dist/checks/rules-draft.d.ts +2 -0
  24. package/dist/checks/rules-draft.js +439 -0
  25. package/dist/checks/run-checks.d.ts +42 -0
  26. package/dist/checks/run-checks.js +159 -0
  27. package/dist/checks/session-runner.d.ts +19 -0
  28. package/dist/checks/session-runner.js +99 -0
  29. package/dist/checks/types.d.ts +109 -0
  30. package/dist/checks/types.js +1 -0
  31. package/dist/cms/adapter.d.ts +74 -1
  32. package/dist/cms/adapter.js +1 -0
  33. package/dist/cms/index.d.ts +1 -1
  34. package/dist/cms/index.js +1 -1
  35. package/dist/cms/media-sources.d.ts +29 -1
  36. package/dist/cms/media-sources.js +188 -7
  37. package/dist/durable/durable-store-singleton.d.ts +37 -0
  38. package/dist/durable/durable-store-singleton.js +179 -0
  39. package/dist/durable/finding-impact.d.ts +30 -0
  40. package/dist/durable/finding-impact.js +53 -0
  41. package/dist/durable/in-memory-durable-store.d.ts +203 -0
  42. package/dist/durable/in-memory-durable-store.js +363 -0
  43. package/dist/durable/index.d.ts +5 -0
  44. package/dist/durable/index.js +4 -0
  45. package/dist/durable/pending-plan-store.d.ts +28 -0
  46. package/dist/durable/pending-plan-store.js +156 -0
  47. package/dist/durable/sqlite-durable-store.d.ts +71 -0
  48. package/dist/durable/sqlite-durable-store.js +631 -0
  49. package/dist/durable/types.d.ts +265 -0
  50. package/dist/durable/types.js +1 -0
  51. package/dist/handler/create-orchestrator.d.ts +4 -0
  52. package/dist/handler/create-orchestrator.js +283 -32
  53. package/dist/http/audio-actions.d.ts +1 -1
  54. package/dist/http/checks-actions.d.ts +39 -0
  55. package/dist/http/checks-actions.js +122 -0
  56. package/dist/http/history-actions.d.ts +44 -1
  57. package/dist/http/history-actions.js +122 -0
  58. package/dist/http/image-generate-actions.d.ts +2 -2
  59. package/dist/http/ops-actions.d.ts +2 -2
  60. package/dist/http/publish-actions.d.ts +15 -4
  61. package/dist/http/publish-actions.js +3 -3
  62. package/dist/http/restore-actions.d.ts +3 -3
  63. package/dist/http/screenshot-actions.d.ts +2 -2
  64. package/dist/http/session-actions.d.ts +1 -1
  65. package/dist/http/telemetry-feedback-actions.d.ts +2 -2
  66. package/dist/http/unsplash-actions.d.ts +2 -2
  67. package/dist/http/variations-actions.d.ts +2 -2
  68. package/dist/index.d.ts +9 -2
  69. package/dist/index.js +28 -1
  70. package/dist/nlp/deterministic-planner-context.d.ts +16 -0
  71. package/dist/nlp/deterministic-planner-context.js +33 -7
  72. package/dist/nlp/intent-detection.d.ts +16 -0
  73. package/dist/nlp/intent-detection.js +15 -1
  74. package/dist/nlp/plan-normalizer.js +66 -32
  75. package/dist/ops/destructive-action-gate.js +7 -2
  76. package/dist/ops/ops-engine.d.ts +12 -1
  77. package/dist/ops/ops-engine.js +41 -14
  78. package/dist/publish/publish-helpers.d.ts +12 -2
  79. package/dist/publish/publish-helpers.js +10 -3
  80. package/dist/publish/publish-selection.d.ts +84 -0
  81. package/dist/publish/publish-selection.js +113 -0
  82. package/dist/publish/publish-target-registry.js +1 -1
  83. package/dist/publish/publish-target.d.ts +1 -1
  84. package/dist/publish/targets/git.js +2 -2
  85. package/dist/state/session-state.js +8 -1
  86. package/dist/state/site-assets.d.ts +41 -0
  87. package/dist/state/site-assets.js +40 -0
  88. package/package.json +3 -3
@@ -0,0 +1,203 @@
1
+ import type { CheckRunInput, CheckRunPatch, CorrectionInput, CorrectionQuery, DurableStore, FindingInput, FindingQuery, FindingSeverity, FindingStatus, MemoryInput, MemoryQuery, MemoryStatus, ProposalInput, ProposalQuery, ProposalStatus } from "./types.ts";
2
+ export type InMemoryDurableStoreOptions = {
3
+ now?: () => number;
4
+ };
5
+ export declare class InMemoryDurableStore implements DurableStore {
6
+ private readonly findings;
7
+ /** (scopeKey, fingerprint) → finding id. Mirrors the SQLite unique index. */
8
+ private readonly byFingerprint;
9
+ private readonly runs;
10
+ private readonly memory;
11
+ private readonly corrections;
12
+ private readonly proposals;
13
+ private readonly now;
14
+ constructor(options?: InMemoryDurableStoreOptions);
15
+ /** Keep the newest `cap` rows per scope, matching the SQLite store's trim. */
16
+ private trim;
17
+ recordFindings(runId: string, findings: FindingInput[]): Promise<{
18
+ opened: number;
19
+ updated: number;
20
+ }>;
21
+ reconcileFindings(args: {
22
+ runId: string;
23
+ scopeKey: string;
24
+ slugs: string[];
25
+ agent?: string;
26
+ at?: number;
27
+ }): Promise<{
28
+ closed: number;
29
+ }>;
30
+ listFindings(query: FindingQuery): Promise<{
31
+ fingerprint: string;
32
+ scopeKey: string;
33
+ slug: string;
34
+ ruleId: string;
35
+ agent: string;
36
+ severity: FindingSeverity;
37
+ impact: number;
38
+ title: string;
39
+ detail?: string;
40
+ evidence?: import("./types.ts").FindingEvidence;
41
+ proposedOps?: import("@avocadostudio-ai/shared").Operation[];
42
+ id: string;
43
+ status: FindingStatus;
44
+ lastRunId: string;
45
+ firstSeenAt: number;
46
+ lastSeenAt: number;
47
+ resolvedAt?: number;
48
+ snoozedUntil?: number;
49
+ }[]>;
50
+ getFinding(id: string): Promise<{
51
+ fingerprint: string;
52
+ scopeKey: string;
53
+ slug: string;
54
+ ruleId: string;
55
+ agent: string;
56
+ severity: FindingSeverity;
57
+ impact: number;
58
+ title: string;
59
+ detail?: string;
60
+ evidence?: import("./types.ts").FindingEvidence;
61
+ proposedOps?: import("@avocadostudio-ai/shared").Operation[];
62
+ id: string;
63
+ status: FindingStatus;
64
+ lastRunId: string;
65
+ firstSeenAt: number;
66
+ lastSeenAt: number;
67
+ resolvedAt?: number;
68
+ snoozedUntil?: number;
69
+ } | null>;
70
+ setFindingStatus(id: string, status: FindingStatus, at?: number): Promise<void>;
71
+ wakeSnoozedFindings(scopeKey: string, now?: number): Promise<{
72
+ woken: number;
73
+ }>;
74
+ startCheckRun(run: CheckRunInput): Promise<{
75
+ id: string;
76
+ scopeKey: string;
77
+ agent: string;
78
+ trigger: import("./types.ts").CheckRunTrigger;
79
+ startedAt: number;
80
+ finishedAt?: number;
81
+ pagesScanned: number;
82
+ findingsOpened: number;
83
+ findingsClosed: number;
84
+ costUsd: number;
85
+ error?: string;
86
+ }>;
87
+ finishCheckRun(id: string, patch: CheckRunPatch): Promise<void>;
88
+ listCheckRuns(scopeKey: string, limit?: number): Promise<{
89
+ id: string;
90
+ scopeKey: string;
91
+ agent: string;
92
+ trigger: import("./types.ts").CheckRunTrigger;
93
+ startedAt: number;
94
+ finishedAt?: number;
95
+ pagesScanned: number;
96
+ findingsOpened: number;
97
+ findingsClosed: number;
98
+ costUsd: number;
99
+ error?: string;
100
+ }[]>;
101
+ putMemory(input: MemoryInput, at?: number): Promise<{
102
+ confidence: number;
103
+ scope: import("./types.ts").MemoryScope;
104
+ scopeKey: string;
105
+ kind: import("./types.ts").MemoryKind;
106
+ key: string;
107
+ value: string;
108
+ source: import("./types.ts").MemorySource;
109
+ sourceRef?: string;
110
+ id: string;
111
+ status: MemoryStatus;
112
+ supersedesId?: string;
113
+ createdAt: number;
114
+ lastUsedAt?: number;
115
+ useCount: number;
116
+ }>;
117
+ listMemory(query?: MemoryQuery): Promise<{
118
+ confidence: number;
119
+ scope: import("./types.ts").MemoryScope;
120
+ scopeKey: string;
121
+ kind: import("./types.ts").MemoryKind;
122
+ key: string;
123
+ value: string;
124
+ source: import("./types.ts").MemorySource;
125
+ sourceRef?: string;
126
+ id: string;
127
+ status: MemoryStatus;
128
+ supersedesId?: string;
129
+ createdAt: number;
130
+ lastUsedAt?: number;
131
+ useCount: number;
132
+ }[]>;
133
+ touchMemory(ids: string[], at?: number): Promise<void>;
134
+ setMemoryStatus(id: string, status: MemoryStatus): Promise<void>;
135
+ recordCorrection(input: CorrectionInput, at?: number): Promise<{
136
+ traceId: string;
137
+ scopeKey: string;
138
+ slug?: string;
139
+ request: string;
140
+ proposed: import("@avocadostudio-ai/shared").Operation[];
141
+ outcome: import("./types.ts").CorrectionOutcome;
142
+ applied?: import("@avocadostudio-ai/shared").Operation[];
143
+ id: string;
144
+ at: number;
145
+ }>;
146
+ listCorrections(query?: CorrectionQuery): Promise<{
147
+ traceId: string;
148
+ scopeKey: string;
149
+ slug?: string;
150
+ request: string;
151
+ proposed: import("@avocadostudio-ai/shared").Operation[];
152
+ outcome: import("./types.ts").CorrectionOutcome;
153
+ applied?: import("@avocadostudio-ai/shared").Operation[];
154
+ id: string;
155
+ at: number;
156
+ }[]>;
157
+ putProposal(input: ProposalInput): Promise<{
158
+ id: string;
159
+ scopeKey: string;
160
+ origin: string;
161
+ summary: string;
162
+ ops: import("@avocadostudio-ai/shared").Operation[];
163
+ slugs: string[];
164
+ findingId?: string;
165
+ payload?: Record<string, unknown>;
166
+ createdAt: number;
167
+ expiresAt?: number;
168
+ status: ProposalStatus;
169
+ resolvedAt?: number;
170
+ }>;
171
+ getProposal(id: string): Promise<{
172
+ id: string;
173
+ scopeKey: string;
174
+ origin: string;
175
+ summary: string;
176
+ ops: import("@avocadostudio-ai/shared").Operation[];
177
+ slugs: string[];
178
+ findingId?: string;
179
+ payload?: Record<string, unknown>;
180
+ createdAt: number;
181
+ expiresAt?: number;
182
+ status: ProposalStatus;
183
+ resolvedAt?: number;
184
+ } | null>;
185
+ listProposals(query: ProposalQuery): Promise<{
186
+ id: string;
187
+ scopeKey: string;
188
+ origin: string;
189
+ summary: string;
190
+ ops: import("@avocadostudio-ai/shared").Operation[];
191
+ slugs: string[];
192
+ findingId?: string;
193
+ payload?: Record<string, unknown>;
194
+ createdAt: number;
195
+ expiresAt?: number;
196
+ status: ProposalStatus;
197
+ resolvedAt?: number;
198
+ }[]>;
199
+ setProposalStatus(id: string, status: ProposalStatus, at?: number): Promise<void>;
200
+ expireProposals(now?: number): Promise<{
201
+ expired: number;
202
+ }>;
203
+ }
@@ -0,0 +1,363 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { CHECK_RUN_CAP, CORRECTION_CAP, PROPOSAL_CAP, SNOOZE_WINDOW_MS } from "./sqlite-durable-store.js";
3
+ import { fallbackImpact } from "./finding-impact.js";
4
+ /*
5
+ * In-memory `DurableStore`. Two jobs, both real.
6
+ *
7
+ * 1. It is the reference semantics. `durable-store.test.ts` runs one conformance
8
+ * suite against this and the SQLite store, so a behaviour that drifts between
9
+ * them fails rather than being discovered by whoever swaps implementations.
10
+ *
11
+ * 2. It is the honest fallback. `createOrchestrator()` already degrades to
12
+ * memory-only when `better-sqlite3` cannot be loaded, and reports it through
13
+ * `persistence: { ok: false, reason }`. Findings and memory should degrade
14
+ * the same way and report the same thing — not throw, and not pretend.
15
+ *
16
+ * Nothing here is durable. The name describes the interface, not this class.
17
+ */
18
+ const SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
19
+ /** A fingerprint is unique per scope, never globally. See the SQLite index. */
20
+ function fingerprintKey(scopeKey, fingerprint) {
21
+ return `${scopeKey}\u0000${fingerprint}`;
22
+ }
23
+ export class InMemoryDurableStore {
24
+ findings = new Map();
25
+ /** (scopeKey, fingerprint) → finding id. Mirrors the SQLite unique index. */
26
+ byFingerprint = new Map();
27
+ runs = new Map();
28
+ memory = new Map();
29
+ corrections = [];
30
+ proposals = new Map();
31
+ now;
32
+ constructor(options = {}) {
33
+ this.now = options.now ?? Date.now;
34
+ }
35
+ /** Keep the newest `cap` rows per scope, matching the SQLite store's trim. */
36
+ trim(map, scopeKey, cap, order) {
37
+ const forScope = [...map.values()].filter((row) => row.scopeKey === scopeKey);
38
+ if (forScope.length <= cap)
39
+ return;
40
+ forScope
41
+ .sort((a, b) => order(a) - order(b))
42
+ .slice(0, forScope.length - cap)
43
+ .forEach((row) => map.delete(row.id));
44
+ }
45
+ // Findings ---------------------------------------------------------------
46
+ async recordFindings(runId, findings) {
47
+ const at = this.now();
48
+ let opened = 0;
49
+ let updated = 0;
50
+ for (const f of findings) {
51
+ const fpKey = fingerprintKey(f.scopeKey, f.fingerprint);
52
+ const existingId = this.byFingerprint.get(fpKey);
53
+ const existing = existingId ? this.findings.get(existingId) : undefined;
54
+ if (!existing) {
55
+ const id = randomUUID();
56
+ this.findings.set(id, {
57
+ ...f,
58
+ id,
59
+ impact: f.impact ?? fallbackImpact(f.severity),
60
+ status: "open",
61
+ lastRunId: runId,
62
+ firstSeenAt: at,
63
+ lastSeenAt: at
64
+ });
65
+ this.byFingerprint.set(fpKey, id);
66
+ opened += 1;
67
+ continue;
68
+ }
69
+ // `status` and `firstSeenAt` deliberately survive — see the SQLite store.
70
+ const next = {
71
+ ...existing,
72
+ slug: f.slug,
73
+ ruleId: f.ruleId,
74
+ agent: f.agent,
75
+ severity: f.severity,
76
+ // Recomputed every run — the page's weight moves even when the finding
77
+ // does not. See the SQLite store.
78
+ impact: f.impact ?? fallbackImpact(f.severity),
79
+ title: f.title,
80
+ lastRunId: runId,
81
+ lastSeenAt: at
82
+ };
83
+ if (f.detail === undefined)
84
+ delete next.detail;
85
+ else
86
+ next.detail = f.detail;
87
+ if (f.evidence === undefined)
88
+ delete next.evidence;
89
+ else
90
+ next.evidence = f.evidence;
91
+ if (f.proposedOps === undefined)
92
+ delete next.proposedOps;
93
+ else
94
+ next.proposedOps = f.proposedOps;
95
+ if (existing.status === "fixed") {
96
+ next.status = "open";
97
+ delete next.resolvedAt;
98
+ opened += 1;
99
+ }
100
+ else {
101
+ updated += 1;
102
+ }
103
+ this.findings.set(existing.id, next);
104
+ }
105
+ return { opened, updated };
106
+ }
107
+ async reconcileFindings(args) {
108
+ if (args.slugs.length === 0)
109
+ return { closed: 0 };
110
+ const at = args.at ?? this.now();
111
+ const inScope = new Set(args.slugs);
112
+ let closed = 0;
113
+ for (const record of this.findings.values()) {
114
+ if (record.scopeKey !== args.scopeKey)
115
+ continue;
116
+ // `snoozed` closes too — see the SQLite store. A snooze defers the
117
+ // report, not the problem.
118
+ if (record.status !== "open" && record.status !== "snoozed")
119
+ continue;
120
+ if (record.lastRunId === args.runId)
121
+ continue;
122
+ if (!inScope.has(record.slug))
123
+ continue;
124
+ if (args.agent && record.agent !== args.agent)
125
+ continue;
126
+ const next = { ...record, status: "fixed", resolvedAt: at };
127
+ delete next.snoozedUntil;
128
+ this.findings.set(record.id, next);
129
+ closed += 1;
130
+ }
131
+ return { closed };
132
+ }
133
+ async listFindings(query) {
134
+ const statuses = query.status
135
+ ? new Set(Array.isArray(query.status) ? query.status : [query.status])
136
+ : null;
137
+ if (statuses && statuses.size === 0)
138
+ return [];
139
+ const limit = Math.max(1, Math.min(query.limit ?? 500, 5000));
140
+ return [...this.findings.values()]
141
+ .filter((f) => f.scopeKey === query.scopeKey)
142
+ .filter((f) => (query.slug ? f.slug === query.slug : true))
143
+ .filter((f) => (query.agent ? f.agent === query.agent : true))
144
+ .filter((f) => (query.severity ? f.severity === query.severity : true))
145
+ .filter((f) => (statuses ? statuses.has(f.status) : true))
146
+ // Impact leads, severity breaks its ties — mirroring the SQLite ORDER BY.
147
+ .sort((a, b) => b.impact - a.impact ||
148
+ SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] ||
149
+ b.lastSeenAt - a.lastSeenAt)
150
+ .slice(0, limit)
151
+ .map((f) => ({ ...f }));
152
+ }
153
+ async getFinding(id) {
154
+ const record = this.findings.get(id);
155
+ return record ? { ...record } : null;
156
+ }
157
+ async setFindingStatus(id, status, at) {
158
+ const record = this.findings.get(id);
159
+ if (!record)
160
+ return;
161
+ const when = at ?? this.now();
162
+ const next = { ...record, status };
163
+ // Deferred is not resolved, so `snoozed` clears `resolvedAt` like `open`.
164
+ if (status === "open" || status === "snoozed")
165
+ delete next.resolvedAt;
166
+ else
167
+ next.resolvedAt = when;
168
+ if (status === "snoozed")
169
+ next.snoozedUntil = when + SNOOZE_WINDOW_MS;
170
+ else
171
+ delete next.snoozedUntil;
172
+ this.findings.set(id, next);
173
+ }
174
+ async wakeSnoozedFindings(scopeKey, now) {
175
+ const at = now ?? this.now();
176
+ let woken = 0;
177
+ for (const record of this.findings.values()) {
178
+ if (record.scopeKey !== scopeKey || record.status !== "snoozed")
179
+ continue;
180
+ if (record.snoozedUntil == null || record.snoozedUntil > at)
181
+ continue;
182
+ const next = { ...record, status: "open" };
183
+ delete next.snoozedUntil;
184
+ delete next.resolvedAt;
185
+ this.findings.set(record.id, next);
186
+ woken += 1;
187
+ }
188
+ return { woken };
189
+ }
190
+ // Check runs -------------------------------------------------------------
191
+ async startCheckRun(run) {
192
+ const record = {
193
+ ...run,
194
+ pagesScanned: 0,
195
+ findingsOpened: 0,
196
+ findingsClosed: 0,
197
+ costUsd: 0
198
+ };
199
+ this.runs.set(run.id, record);
200
+ this.trim(this.runs, run.scopeKey, CHECK_RUN_CAP, (r) => r.startedAt);
201
+ return { ...record };
202
+ }
203
+ async finishCheckRun(id, patch) {
204
+ const record = this.runs.get(id);
205
+ if (!record)
206
+ return;
207
+ this.runs.set(id, { ...record, ...patch });
208
+ }
209
+ async listCheckRuns(scopeKey, limit = 50) {
210
+ return [...this.runs.values()]
211
+ .filter((r) => r.scopeKey === scopeKey)
212
+ .sort((a, b) => b.startedAt - a.startedAt)
213
+ .slice(0, Math.max(1, Math.min(limit, 1000)))
214
+ .map((r) => ({ ...r }));
215
+ }
216
+ // Memory -----------------------------------------------------------------
217
+ async putMemory(input, at) {
218
+ const createdAt = at ?? this.now();
219
+ const previous = [...this.memory.values()].find((m) => m.status === "active" &&
220
+ m.scope === input.scope &&
221
+ m.scopeKey === input.scopeKey &&
222
+ m.kind === input.kind &&
223
+ m.key === input.key);
224
+ if (previous)
225
+ this.memory.set(previous.id, { ...previous, status: "superseded" });
226
+ const record = {
227
+ ...input,
228
+ id: randomUUID(),
229
+ confidence: input.confidence ?? 1,
230
+ status: "active",
231
+ ...(previous ? { supersedesId: previous.id } : {}),
232
+ createdAt,
233
+ useCount: 0
234
+ };
235
+ this.memory.set(record.id, record);
236
+ return { ...record };
237
+ }
238
+ async listMemory(query = {}) {
239
+ const kinds = query.kind
240
+ ? new Set(Array.isArray(query.kind) ? query.kind : [query.kind])
241
+ : null;
242
+ if (kinds && kinds.size === 0)
243
+ return [];
244
+ const status = query.status ?? "active";
245
+ const limit = Math.max(1, Math.min(query.limit ?? 500, 5000));
246
+ return [...this.memory.values()]
247
+ .filter((m) => (query.scopeKey ? m.scopeKey === query.scopeKey : true))
248
+ .filter((m) => (query.scope ? m.scope === query.scope : true))
249
+ .filter((m) => (kinds ? kinds.has(m.kind) : true))
250
+ .filter((m) => m.status === status)
251
+ .sort((a, b) => b.createdAt - a.createdAt)
252
+ .slice(0, limit)
253
+ .map((m) => ({ ...m }));
254
+ }
255
+ async touchMemory(ids, at) {
256
+ const when = at ?? this.now();
257
+ for (const id of ids) {
258
+ const record = this.memory.get(id);
259
+ if (!record)
260
+ continue;
261
+ this.memory.set(id, { ...record, lastUsedAt: when, useCount: record.useCount + 1 });
262
+ }
263
+ }
264
+ async setMemoryStatus(id, status) {
265
+ const record = this.memory.get(id);
266
+ if (!record)
267
+ return;
268
+ // Reactivating demotes whatever is active for that key — otherwise this
269
+ // store would hold two active records for one key and hand a planner two
270
+ // contradicting facts, while SQLite refused the same write outright.
271
+ if (status === "active") {
272
+ for (const other of this.memory.values()) {
273
+ if (other.id === id)
274
+ continue;
275
+ if (other.status !== "active")
276
+ continue;
277
+ if (other.scope !== record.scope ||
278
+ other.scopeKey !== record.scopeKey ||
279
+ other.kind !== record.kind ||
280
+ other.key !== record.key) {
281
+ continue;
282
+ }
283
+ this.memory.set(other.id, { ...other, status: "superseded" });
284
+ }
285
+ }
286
+ this.memory.set(id, { ...record, status });
287
+ }
288
+ // Corrections ------------------------------------------------------------
289
+ async recordCorrection(input, at) {
290
+ const record = { ...input, id: randomUUID(), at: at ?? this.now() };
291
+ this.corrections.push(record);
292
+ const forScope = this.corrections.filter((c) => c.scopeKey === record.scopeKey);
293
+ if (forScope.length > CORRECTION_CAP) {
294
+ const drop = new Set(forScope.sort((a, b) => a.at - b.at).slice(0, forScope.length - CORRECTION_CAP).map((c) => c.id));
295
+ for (let i = this.corrections.length - 1; i >= 0; i -= 1) {
296
+ if (drop.has(this.corrections[i].id))
297
+ this.corrections.splice(i, 1);
298
+ }
299
+ }
300
+ return { ...record };
301
+ }
302
+ async listCorrections(query = {}) {
303
+ const limit = Math.max(1, Math.min(query.limit ?? 200, 5000));
304
+ return this.corrections
305
+ .filter((c) => (query.scopeKey ? c.scopeKey === query.scopeKey : true))
306
+ .filter((c) => (query.outcome ? c.outcome === query.outcome : true))
307
+ .filter((c) => (query.since != null ? c.at >= query.since : true))
308
+ .slice()
309
+ .sort((a, b) => b.at - a.at)
310
+ .slice(0, limit)
311
+ .map((c) => ({ ...c }));
312
+ }
313
+ // Proposals --------------------------------------------------------------
314
+ async putProposal(input) {
315
+ const existing = this.proposals.get(input.id);
316
+ const record = {
317
+ ...input,
318
+ status: existing?.status ?? "pending",
319
+ ...(existing?.resolvedAt != null ? { resolvedAt: existing.resolvedAt } : {}),
320
+ createdAt: existing?.createdAt ?? input.createdAt
321
+ };
322
+ this.proposals.set(record.id, record);
323
+ this.trim(this.proposals, record.scopeKey, PROPOSAL_CAP, (p) => p.createdAt);
324
+ return { ...record };
325
+ }
326
+ async getProposal(id) {
327
+ const record = this.proposals.get(id);
328
+ return record ? { ...record } : null;
329
+ }
330
+ async listProposals(query) {
331
+ const limit = Math.max(1, Math.min(query.limit ?? 100, 1000));
332
+ return [...this.proposals.values()]
333
+ .filter((p) => p.scopeKey === query.scopeKey)
334
+ .filter((p) => (query.status ? p.status === query.status : true))
335
+ .sort((a, b) => b.createdAt - a.createdAt)
336
+ .slice(0, limit)
337
+ .map((p) => ({ ...p }));
338
+ }
339
+ async setProposalStatus(id, status, at) {
340
+ const record = this.proposals.get(id);
341
+ if (!record)
342
+ return;
343
+ const next = { ...record, status };
344
+ if (status === "pending")
345
+ delete next.resolvedAt;
346
+ else
347
+ next.resolvedAt = at ?? this.now();
348
+ this.proposals.set(id, next);
349
+ }
350
+ async expireProposals(now) {
351
+ const at = now ?? this.now();
352
+ let expired = 0;
353
+ for (const record of this.proposals.values()) {
354
+ if (record.status !== "pending")
355
+ continue;
356
+ if (record.expiresAt == null || record.expiresAt > at)
357
+ continue;
358
+ this.proposals.set(record.id, { ...record, status: "expired", resolvedAt: at });
359
+ expired += 1;
360
+ }
361
+ return { expired };
362
+ }
363
+ }
@@ -0,0 +1,5 @@
1
+ export { getDurableStore, resetDurableStore, setDurableStore, durableStoreIsEphemeral, discardPendingProposalsForSession, sweepExpiredProposals } from "./durable-store-singleton.ts";
2
+ export { loadPendingPlan, savePendingPlan, clearPendingPlan, peekPendingPlan } from "./pending-plan-store.ts";
3
+ export { SqliteDurableStore, type SqliteDurableStoreOptions } from "./sqlite-durable-store.ts";
4
+ export { InMemoryDurableStore, type InMemoryDurableStoreOptions } from "./in-memory-durable-store.ts";
5
+ export type { DurableStore, FindingInput, FindingRecord, FindingQuery, FindingSeverity, FindingStatus, FindingEvidence, CheckRunInput, CheckRunRecord, CheckRunPatch, CheckRunTrigger, MemoryInput, MemoryRecord, MemoryQuery, MemoryScope, MemoryKind, MemorySource, MemoryStatus, CorrectionInput, CorrectionRecord, CorrectionQuery, CorrectionOutcome, ProposalInput, ProposalRecord, ProposalQuery, ProposalStatus } from "./types.ts";
@@ -0,0 +1,4 @@
1
+ export { getDurableStore, resetDurableStore, setDurableStore, durableStoreIsEphemeral, discardPendingProposalsForSession, sweepExpiredProposals } from "./durable-store-singleton.js";
2
+ export { loadPendingPlan, savePendingPlan, clearPendingPlan, peekPendingPlan } from "./pending-plan-store.js";
3
+ export { SqliteDurableStore } from "./sqlite-durable-store.js";
4
+ export { InMemoryDurableStore } from "./in-memory-durable-store.js";
@@ -0,0 +1,28 @@
1
+ import { type PendingApprovalPlan } from "../state/session-state.ts";
2
+ /**
3
+ * Map-only read. For call sites that only need to know whether a plan is live
4
+ * in this process and must not pay for a query.
5
+ */
6
+ export declare function peekPendingPlan(session: string): PendingApprovalPlan | undefined;
7
+ /**
8
+ * Read the pending plan, rehydrating from the durable store on a Map miss.
9
+ *
10
+ * An expired row is treated as absent rather than deleted: eviction is the
11
+ * sweeper's job, and a read is not the place to discover that a plan the user
12
+ * is looking at right now has just aged out mid-request.
13
+ */
14
+ export declare function loadPendingPlan(session: string): Promise<PendingApprovalPlan | undefined>;
15
+ /** Set the plan in memory and mirror it to the durable store. */
16
+ export declare function savePendingPlan(session: string, plan: PendingApprovalPlan): Promise<void>;
17
+ /**
18
+ * Drop the plan from memory and resolve its durable row.
19
+ *
20
+ * `outcome` is recorded rather than deleted, so "how often does a human discard
21
+ * what we proposed" stays answerable — which is the same signal the corrections
22
+ * table is for, and the reason `expectedId` matters. A turn that applies
23
+ * something unrelated must not record a held plan as `approved`: it was never
24
+ * reviewed, and writing that down poisons the one number this is here to make
25
+ * available. Pass the id of the plan actually acted on; omit it only where the
26
+ * intent really is "whatever chat plan is pending, it is over".
27
+ */
28
+ export declare function clearPendingPlan(session: string, outcome: "approved" | "discarded", expectedId?: string): Promise<void>;