opencode-agent-skill 14.4.0 → 15.1.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 (53) hide show
  1. package/README.md +34 -16
  2. package/docs/PI-COMPAT.md +151 -2
  3. package/docs/V15-MANAGED-RUNTIME.md +128 -0
  4. package/global-config/agents/architect.md +63 -1
  5. package/global-config/agents/executor.md +7 -0
  6. package/global-config/agents/plan-checker.md +8 -1
  7. package/global-config/agents/verifier.md +3 -3
  8. package/global-config/skills/database-engineering/references/workflow.md +15 -0
  9. package/global-config/skills/engineering-orchestrator/references/long-horizon.md +21 -11
  10. package/lib/activity-deadline.mjs +70 -0
  11. package/lib/adaptive-context-budget.mjs +25 -5
  12. package/lib/affected-tests.mjs +38 -5
  13. package/lib/capability-fabric.mjs +17 -0
  14. package/lib/code-intelligence/index.mjs +3 -2
  15. package/lib/code-intelligence/lsp-provider.mjs +326 -52
  16. package/lib/compaction-resume-guard.mjs +255 -0
  17. package/lib/completion-auditor.mjs +25 -5
  18. package/lib/context-engine-v11.mjs +20 -7
  19. package/lib/evidence-store.mjs +92 -8
  20. package/lib/execution-contract.mjs +592 -0
  21. package/lib/leaf-runtime-optimizer.mjs +76 -0
  22. package/lib/memory-engine.mjs +189 -16
  23. package/lib/performance-fabric.mjs +262 -107
  24. package/lib/pi-child-invocation.mjs +65 -0
  25. package/lib/pi-rpc-pool.mjs +61 -9
  26. package/lib/plan-salvage.mjs +124 -0
  27. package/lib/planning-speed-policy.mjs +131 -0
  28. package/lib/repo-graph.mjs +1 -1
  29. package/lib/repo-inspect.mjs +1 -0
  30. package/lib/runtime-artifacts.mjs +42 -0
  31. package/lib/runtime-events.mjs +33 -1
  32. package/lib/safety.mjs +1 -0
  33. package/lib/semantic-index.mjs +1 -1
  34. package/lib/service-manager.mjs +526 -0
  35. package/lib/session-display.mjs +49 -0
  36. package/lib/subagent-artifacts.mjs +182 -0
  37. package/lib/task-graph.mjs +87 -0
  38. package/lib/task-policy.mjs +150 -4
  39. package/lib/trajectory.mjs +111 -1
  40. package/lib/turbo-fast-path.mjs +44 -0
  41. package/lib/verification-broker.mjs +32 -27
  42. package/lib/workspace-fingerprint.mjs +45 -19
  43. package/lib/workspace-hygiene.mjs +481 -0
  44. package/lib/workspace-root.mjs +49 -0
  45. package/lib/worktree-sandbox.mjs +234 -11
  46. package/package.json +8 -5
  47. package/pi/extensions/ues-child-runtime.ts +170 -4
  48. package/pi/extensions/ues.ts +2063 -188
  49. package/scripts/check-release-consistency.mjs +25 -2
  50. package/scripts/check-source-integrity.mjs +471 -4
  51. package/scripts/eval-pi.mjs +76 -36
  52. package/scripts/smoke-pi-extension.mjs +1 -1
  53. package/pi/prompts/ues-run.md +0 -17
@@ -0,0 +1,255 @@
1
+ import { createHash } from "node:crypto"
2
+ import { existsSync } from "node:fs"
3
+ import { readdir, readFile } from "node:fs/promises"
4
+ import path from "node:path"
5
+ import { checkpointWork } from "./task-engine.mjs"
6
+
7
+ const WORK_DIR = ".ues-work"
8
+ const MAX_WORKSPACES = 3
9
+ const MAX_TASKS = 28
10
+ const MAX_RECEIPTS = 20
11
+
12
+ function digest(value) {
13
+ return createHash("sha256").update(String(value || "")).digest("hex").slice(0, 20)
14
+ }
15
+
16
+ async function readJson(file, fallback = null) {
17
+ try {
18
+ return JSON.parse(await readFile(file, "utf8"))
19
+ } catch (error) {
20
+ if (error?.code === "ENOENT") return fallback
21
+ return fallback
22
+ }
23
+ }
24
+
25
+ function inside(base, target) {
26
+ const root = path.resolve(base)
27
+ const resolved = path.resolve(target)
28
+ return resolved === root || resolved.startsWith(root + path.sep)
29
+ }
30
+
31
+ function normalizePhasePath(value) {
32
+ return String(value || "").replaceAll("\\", "/").replace(/^\.\/+/, "")
33
+ }
34
+
35
+ async function activeWorkspaces(root, max = MAX_WORKSPACES) {
36
+ const base = path.join(path.resolve(root), WORK_DIR)
37
+ if (!existsSync(base)) return []
38
+ const entries = await readdir(base, { withFileTypes: true }).catch(() => [])
39
+ const rows = []
40
+ for (const entry of entries) {
41
+ if (!entry.isDirectory()) continue
42
+ const dir = path.join(base, entry.name)
43
+ const state = await readJson(path.join(dir, "STATE.json"), null)
44
+ if (!state || String(state.status || "").toLowerCase() === "completed") continue
45
+ rows.push({
46
+ slug: entry.name,
47
+ dir,
48
+ state,
49
+ updatedAt: Date.parse(state.updatedAt || state.createdAt || "") || 0,
50
+ })
51
+ }
52
+ return rows
53
+ .sort((a, b) => b.updatedAt - a.updatedAt || a.slug.localeCompare(b.slug))
54
+ .slice(0, Math.max(1, Math.min(10, Number(max || MAX_WORKSPACES))))
55
+ }
56
+
57
+ function taskRows(plan, state) {
58
+ const tasks = Array.isArray(plan?.tasks) ? plan.tasks : []
59
+ const rows = tasks.map((task) => {
60
+ const runtime = state?.tasks?.[task.id] || {}
61
+ return {
62
+ id: task.id,
63
+ title: String(task.title || "").slice(0, 240),
64
+ status: runtime.status || "unknown",
65
+ attempts: Number(runtime.attempts || 0),
66
+ runId: runtime.runId || null,
67
+ dependsOn: Array.isArray(task.dependsOn) ? task.dependsOn.slice(0, 24) : [],
68
+ evidenceStrength: runtime.evidenceStrength || null,
69
+ report: runtime.report || null,
70
+ lastError: runtime.lastError ? String(runtime.lastError).slice(0, 800) : null,
71
+ }
72
+ })
73
+ rows.sort((a, b) => {
74
+ const rank = (row) => row.status === "running" ? 0 : row.status === "completed" ? 2 : 1
75
+ return rank(a) - rank(b) || String(a.id).localeCompare(String(b.id))
76
+ })
77
+ return rows.slice(0, MAX_TASKS)
78
+ }
79
+
80
+ function receiptRows(evidence) {
81
+ return (Array.isArray(evidence?.receipts) ? evidence.receipts : [])
82
+ .slice(-MAX_RECEIPTS)
83
+ .map((item) => ({
84
+ id: item.id || null,
85
+ kind: item.kind || "verification-receipt",
86
+ task: item.task || null,
87
+ runId: item.runId || null,
88
+ passed: item.passed === true,
89
+ verdict: item.verdict || (item.passed === true ? "PASS" : null),
90
+ command: item.command || null,
91
+ workspaceAfter: item.workspaceAfter || null,
92
+ recordedAt: item.recordedAt || item.finishedAt || null,
93
+ }))
94
+ }
95
+
96
+ function gateRows(evidence) {
97
+ return (Array.isArray(evidence?.gateReceipts) ? evidence.gateReceipts : [])
98
+ .slice(-12)
99
+ .map((item) => ({
100
+ id: item.id || null,
101
+ kind: item.kind || null,
102
+ verdict: item.verdict || null,
103
+ verifier: item.verifier || null,
104
+ planHash: item.planHash || null,
105
+ workspaceFingerprint: item.workspaceFingerprint || null,
106
+ recordedAt: item.recordedAt || null,
107
+ }))
108
+ }
109
+
110
+ async function phaseRows(dir, manifest) {
111
+ const artifacts = Array.isArray(manifest?.artifacts) ? manifest.artifacts : []
112
+ const rows = []
113
+ for (const relative of artifacts.slice(0, 32)) {
114
+ const normalized = normalizePhasePath(relative)
115
+ const target = path.resolve(dir, normalized)
116
+ if (!normalized || !inside(dir, target)) continue
117
+ const phase = await readJson(target, null)
118
+ if (!phase) continue
119
+ rows.push({
120
+ artifact: normalized,
121
+ phase: phase.phase ?? null,
122
+ title: String(phase.title || "").slice(0, 240),
123
+ status: phase.status || "UNKNOWN",
124
+ sourceBodyHash: phase.sourceBodyHash || null,
125
+ evidenceCount: Array.isArray(phase.evidence) ? phase.evidence.length : 0,
126
+ finalVerdicts: phase.finalVerdicts || null,
127
+ })
128
+ }
129
+ return rows
130
+ }
131
+
132
+ function stateSummary(state) {
133
+ return {
134
+ status: state?.status || null,
135
+ goal: state?.goal ? String(state.goal).slice(0, 800) : null,
136
+ updatedAt: state?.updatedAt || null,
137
+ planHash: state?.planHash || null,
138
+ planApproved: state?.planApproved === true,
139
+ nextAction: state?.nextAction || null,
140
+ checkpoint: state?.checkpoint ? {
141
+ id: state.checkpoint.id || null,
142
+ reason: state.checkpoint.reason || null,
143
+ createdAt: state.checkpoint.createdAt || null,
144
+ currentTaskId: state.checkpoint.currentTaskId || null,
145
+ runId: state.checkpoint.runId || null,
146
+ planHash: state.checkpoint.planHash || null,
147
+ workspaceFingerprint: state.checkpoint.workspaceFingerprint || null,
148
+ nextAction: state.checkpoint.nextAction || null,
149
+ evidencePointers: Array.isArray(state.checkpoint.evidencePointers)
150
+ ? state.checkpoint.evidencePointers.slice(-20)
151
+ : [],
152
+ } : null,
153
+ }
154
+ }
155
+
156
+ export async function checkpointDurableWorkBeforeCompaction(root, options = {}) {
157
+ const reason = String(options.reason || "unknown")
158
+ const rows = await activeWorkspaces(root, options.maxWorkspaces || MAX_WORKSPACES)
159
+ const results = []
160
+ for (const row of rows) {
161
+ try {
162
+ const checkpoint = await checkpointWork(root, row.slug, {
163
+ reason: `pi-compaction:${reason}`,
164
+ })
165
+ results.push({ slug: row.slug, checkpoint, ok: true })
166
+ } catch (error) {
167
+ results.push({
168
+ slug: row.slug,
169
+ checkpoint: null,
170
+ ok: false,
171
+ error: error instanceof Error ? error.message : String(error),
172
+ })
173
+ }
174
+ }
175
+ return results
176
+ }
177
+
178
+ export async function buildCompactionResumeGuard(root, options = {}) {
179
+ const rows = await activeWorkspaces(root, options.maxWorkspaces || MAX_WORKSPACES)
180
+ const workspaces = []
181
+ for (const row of rows) {
182
+ const [plan, evidence, contract, manifest] = await Promise.all([
183
+ readJson(path.join(row.dir, "PLAN.json"), null),
184
+ readJson(path.join(row.dir, "EVIDENCE.json"), {}),
185
+ readJson(path.join(row.dir, "EXECUTION_CONTRACT.json"), null),
186
+ readJson(path.join(row.dir, "phases", "MANIFEST.json"), null),
187
+ ])
188
+ const phases = await phaseRows(row.dir, manifest)
189
+ const sources = {
190
+ state: path.relative(root, path.join(row.dir, "STATE.json")).replaceAll("\\", "/"),
191
+ plan: path.relative(root, path.join(row.dir, "PLAN.json")).replaceAll("\\", "/"),
192
+ evidence: path.relative(root, path.join(row.dir, "EVIDENCE.json")).replaceAll("\\", "/"),
193
+ executionContract: contract
194
+ ? path.relative(root, path.join(row.dir, "EXECUTION_CONTRACT.json")).replaceAll("\\", "/")
195
+ : null,
196
+ phaseManifest: manifest
197
+ ? path.relative(root, path.join(row.dir, "phases", "MANIFEST.json")).replaceAll("\\", "/")
198
+ : null,
199
+ }
200
+ workspaces.push({
201
+ slug: row.slug,
202
+ sources,
203
+ sourceHashes: {
204
+ state: digest(JSON.stringify(row.state)),
205
+ plan: digest(JSON.stringify(plan || {})),
206
+ evidence: digest(JSON.stringify(evidence || {})),
207
+ executionContract: contract ? digest(JSON.stringify(contract)) : null,
208
+ phaseManifest: manifest ? digest(JSON.stringify(manifest)) : null,
209
+ },
210
+ state: stateSummary(row.state),
211
+ tasks: taskRows(plan, row.state),
212
+ receipts: receiptRows(evidence),
213
+ gateReceipts: gateRows(evidence),
214
+ executionContract: contract ? {
215
+ schemaVersion: contract.schemaVersion || null,
216
+ taskHash: contract.taskHash || null,
217
+ gates: contract.gates || null,
218
+ phaseCount: Array.isArray(contract.phases) ? contract.phases.length : 0,
219
+ approvedInheritedDirtyPaths: Array.isArray(contract.approvedInheritedDirtyPaths)
220
+ ? contract.approvedInheritedDirtyPaths.slice(0, 40)
221
+ : [],
222
+ } : null,
223
+ phases,
224
+ })
225
+ }
226
+
227
+ return {
228
+ schemaVersion: 1,
229
+ kind: "ues-durable-compaction-resume-guard",
230
+ generatedAt: new Date().toISOString(),
231
+ compactionReason: options.reason || null,
232
+ source: "deterministic-filesystem-artifacts",
233
+ modelSummaryTrustedForDurableState: false,
234
+ workspaceCount: workspaces.length,
235
+ workspaces,
236
+ }
237
+ }
238
+
239
+ export function renderCompactionResumeGuard(packet, options = {}) {
240
+ if (!packet?.workspaces?.length) return ""
241
+ const maxChars = Math.max(8000, Math.min(48000, Number(options.maxChars || 28000)))
242
+ const preface = [
243
+ "## UES Durable Compaction Resume Guard",
244
+ "Authoritative durable state was rebuilt from filesystem artifacts after Pi compaction.",
245
+ "The model-generated compaction summary is advisory only for durable execution state.",
246
+ "If the summary conflicts with STATE.json, PLAN.json, EXECUTION_CONTRACT.json, phase artifacts, or evidence receipts below, the deterministic artifacts win.",
247
+ "Do not mark a task/phase complete unless the rebuilt state and fresh receipts support it.",
248
+ "Follow checkpoint.nextAction/state.nextAction and re-read the referenced artifact when more detail is required.",
249
+ "",
250
+ ].join("\n")
251
+ const encoded = JSON.stringify(packet, null, 2)
252
+ if (preface.length + encoded.length <= maxChars) return preface + encoded
253
+ return preface + encoded.slice(0, Math.max(0, maxChars - preface.length - 120)) +
254
+ "\n...[resume packet bounded; re-read referenced .ues-work artifacts for exact detail]"
255
+ }
@@ -11,13 +11,30 @@ function passRun(run) {
11
11
  return Boolean(run && Number(run.exitCode) === 0 && run.verdict === "PASS")
12
12
  }
13
13
 
14
- function benignEmptySection(value) {
15
- const normalized = text(value)
14
+ function normalizeSection(value) {
15
+ return text(value)
16
16
  .toLowerCase()
17
17
  .replace(/[`*_#]/g, "")
18
18
  .replace(/[.!:;]+$/g, "")
19
+ .replace(/\s+/g, " ")
19
20
  .trim()
20
- return !normalized || /^(?:none(?: observed| found| remaining)?|n\/a|na|nil|0|no failures?(?: observed| found| remaining)?|no unresolved gaps?(?: observed| found| remaining)?|no gaps?(?: observed| found| remaining)?|not applicable)$/.test(normalized)
21
+ }
22
+
23
+ function benignEmptySection(value) {
24
+ const normalized = normalizeSection(value)
25
+ return !normalized || /^(?:none(?: observed| found| remaining)?|n\/a|na|nil|0|no failures?(?: observed| found| remaining)?|no unresolved gaps?(?: observed| found| remaining)?|no gaps?(?: observed| found| remaining)?|not applicable|không có|không có gì|không có lỗi|không phát hiện lỗi|không còn khoảng trống|không có khoảng trống)$/.test(normalized)
26
+ }
27
+
28
+ function benignFailureSection(value) {
29
+ const normalized = normalizeSection(value)
30
+ return benignEmptySection(normalized) ||
31
+ /^(?:không có|không phát hiện) .{0,120}(?:lỗi|thất bại)(?: nào)?$/.test(normalized)
32
+ }
33
+
34
+ function outOfScopeGapOnly(value) {
35
+ const normalized = normalizeSection(value)
36
+ if (benignEmptySection(normalized)) return true
37
+ return /(?:not requested|outside (?:the )?scope|out of scope|not required|không có yêu cầu|không được yêu cầu|ngoài phạm vi|không thuộc phạm vi)/.test(normalized)
21
38
  }
22
39
 
23
40
  function receiptPassed(row = {}) {
@@ -43,8 +60,11 @@ export function auditCompletion(input = {}) {
43
60
  for (const key of ["checks-run", "acceptance-criteria-proven", "completion-evidence"]) {
44
61
  if (!text(sections[key])) failures.push(`missing-report-section:${key}`)
45
62
  }
46
- if (Object.hasOwn(sections, "failures") && !benignEmptySection(sections.failures)) failures.push("verification-reports-failures")
47
- if (Object.hasOwn(sections, "unresolved-gaps") && !benignEmptySection(sections["unresolved-gaps"])) failures.push("verification-reports-unresolved-gaps")
63
+ if (Object.hasOwn(sections, "failures") && !benignFailureSection(sections.failures)) failures.push("verification-reports-failures")
64
+ if (Object.hasOwn(sections, "unresolved-gaps") && !benignEmptySection(sections["unresolved-gaps"])) {
65
+ if (outOfScopeGapOnly(sections["unresolved-gaps"])) warnings.push("verification-reports-out-of-scope-gap")
66
+ else failures.push("verification-reports-unresolved-gaps")
67
+ }
48
68
  if (Object.hasOwn(sections, "checks-not-run") && !benignEmptySection(sections["checks-not-run"])) warnings.push("verification-reports-checks-not-run")
49
69
 
50
70
  const snapshot = input.workspaceSnapshot || null
@@ -101,6 +101,16 @@ export async function buildAdaptiveTaskContext(root, task, options = {}) {
101
101
  const policy = options.policy || {}
102
102
  const capabilities = options.capabilities || inferTaskCapabilities(taskText(task), options.facts || {})
103
103
  const evidenceBudget = options.evidenceBudget || planEvidenceBudget(policy, task, capabilities.required)
104
+ const contextBuildStartedAt = Date.now()
105
+ const memoryPromise = retrieveMemories(root, taskText(task), {
106
+ files: taskFilesForMemory(task),
107
+ limit: options.memoryLimit ?? 6,
108
+ taskClass: options.taskClass || task.type || policy.executionProfile || policy.profile?.name || null,
109
+ touch: options.touchMemories !== false,
110
+ }).catch(() => ({ schemaVersion: 1, query: taskText(task), eligible: 0, results: [] }))
111
+ const fabricPromise = capabilityFabricStatus(root).catch(() => null)
112
+
113
+ const manifestStartedAt = Date.now()
104
114
  const manifest = await buildContextManifest(root, task, {
105
115
  budget: evidenceBudget.total,
106
116
  evidenceBudget,
@@ -110,21 +120,18 @@ export async function buildAdaptiveTaskContext(root, task, options = {}) {
110
120
  changedFiles: options.changedFiles,
111
121
  workspaceFingerprint: options.workspaceFingerprint,
112
122
  })
123
+ const manifestMs = Date.now() - manifestStartedAt
113
124
 
125
+ const externalizeStartedAt = Date.now()
114
126
  const externalized = await externalizeContextExcerpts(root, manifest, {
115
127
  task,
116
128
  threshold: options.externalizeThreshold,
117
129
  inlineChars: options.inlineChars,
118
130
  })
131
+ const externalizeMs = Date.now() - externalizeStartedAt
119
132
 
120
- const memoryRetrieval = await retrieveMemories(root, taskText(task), {
121
- files: taskFilesForMemory(task),
122
- limit: options.memoryLimit ?? 6,
123
- taskClass: options.taskClass || task.type || policy.executionProfile || policy.profile?.name || null,
124
- touch: options.touchMemories !== false,
125
- }).catch(() => ({ schemaVersion: 1, query: taskText(task), eligible: 0, results: [] }))
133
+ const [memoryRetrieval, fabric] = await Promise.all([memoryPromise, fabricPromise])
126
134
  const memories = memoryRetrieval.results || []
127
- const fabric = await capabilityFabricStatus(root).catch(() => null)
128
135
  const providerHints = (fabric?.rows || [])
129
136
  .filter((row) => row.selected)
130
137
  .slice(0, 12)
@@ -202,6 +209,12 @@ export async function buildAdaptiveTaskContext(root, task, options = {}) {
202
209
  entries: externalized.externalized,
203
210
  },
204
211
  promptEnvelope,
212
+ performance: {
213
+ parallelContextPreparation: true,
214
+ manifestMs,
215
+ externalizeMs,
216
+ totalMs: Date.now() - contextBuildStartedAt,
217
+ },
205
218
  promptCache: {
206
219
  stablePrefixHash: promptEnvelope.stablePrefixHash,
207
220
  dynamicHash: promptEnvelope.dynamicHash,
@@ -5,6 +5,41 @@ import path from "node:path"
5
5
 
6
6
  const STORE_VERSION = 1
7
7
  const REF_PREFIX = "evidence:sha256:"
8
+ const DEFAULT_MAX_STORE_BYTES = 192 * 1024 * 1024
9
+ const DEFAULT_MAX_STORE_ENTRIES = 1200
10
+ const DEFAULT_MAX_STORE_AGE_DAYS = 14
11
+ const AUTO_GC_EVERY_WRITES = 8
12
+ const AUTO_GC_LARGE_ITEM_BYTES = 16 * 1024 * 1024
13
+ const PUT_COUNTS = new Map()
14
+
15
+ function boundedNumber(value, fallback, min, max) {
16
+ const parsed = Number(value)
17
+ if (!Number.isFinite(parsed)) return fallback
18
+ return Math.max(min, Math.min(max, Math.trunc(parsed)))
19
+ }
20
+
21
+ function defaultStoreLimits() {
22
+ return {
23
+ maxBytes: boundedNumber(
24
+ process.env.UES_EVIDENCE_MAX_BYTES,
25
+ DEFAULT_MAX_STORE_BYTES,
26
+ 16 * 1024 * 1024,
27
+ 2 * 1024 * 1024 * 1024,
28
+ ),
29
+ maxEntries: boundedNumber(
30
+ process.env.UES_EVIDENCE_MAX_ENTRIES,
31
+ DEFAULT_MAX_STORE_ENTRIES,
32
+ 100,
33
+ 20_000,
34
+ ),
35
+ maxAgeDays: boundedNumber(
36
+ process.env.UES_EVIDENCE_MAX_AGE_DAYS,
37
+ DEFAULT_MAX_STORE_AGE_DAYS,
38
+ 1,
39
+ 365,
40
+ ),
41
+ }
42
+ }
8
43
 
9
44
  function stableValue(value) {
10
45
  if (Array.isArray(value)) return value.map(stableValue)
@@ -21,6 +56,24 @@ function toBytes(value, options = {}) {
21
56
  return { bytes: Buffer.from(text, "utf8"), encoding: "utf8", mediaType: options.mediaType || "application/json" }
22
57
  }
23
58
 
59
+ async function protectedEvidenceHashes(root) {
60
+ const file = path.join(path.resolve(root), ".ues-memory", "MEMORY.json")
61
+ const parsed = await readFile(file, "utf8")
62
+ .then((raw) => JSON.parse(raw))
63
+ .catch(() => null)
64
+ const protectedHashes = new Set()
65
+ const now = Date.now()
66
+ for (const memory of parsed?.memories || []) {
67
+ if (memory?.status !== "verified" || memory?.supersededBy) continue
68
+ const expiresAt = Date.parse(String(memory?.expiresAt || ""))
69
+ if (expiresAt && expiresAt <= now) continue
70
+ for (const ref of memory?.evidenceRefs || []) {
71
+ try { protectedHashes.add(normalizeHash(ref)) } catch {}
72
+ }
73
+ }
74
+ return protectedHashes
75
+ }
76
+
24
77
  function normalizeHash(ref) {
25
78
  const value = String(ref || "").trim()
26
79
  const hash = value.startsWith(REF_PREFIX) ? value.slice(REF_PREFIX.length) : value.replace(/^sha256:/, "")
@@ -79,6 +132,13 @@ export async function putEvidence(root, value, options = {}) {
79
132
  preview,
80
133
  }
81
134
  await writeFile(target.meta, JSON.stringify(metadata, null, 2) + "\n", "utf8")
135
+
136
+ const writes = (PUT_COUNTS.get(root) || 0) + 1
137
+ PUT_COUNTS.set(root, writes)
138
+ if (writes % AUTO_GC_EVERY_WRITES === 0 || encoded.bytes.length >= AUTO_GC_LARGE_ITEM_BYTES) {
139
+ await gcEvidenceStore(root, defaultStoreLimits()).catch(() => null)
140
+ }
141
+
82
142
  return metadata
83
143
  }
84
144
 
@@ -216,25 +276,44 @@ export async function evidenceStoreStatus(root = process.cwd()) {
216
276
  }
217
277
 
218
278
  export async function gcEvidenceStore(root = process.cwd(), options = {}) {
279
+ root = path.resolve(root)
219
280
  const files = await metadataFiles(root)
220
- const maxEntries = Math.max(10, Number(options.maxEntries || 2_000))
221
- const maxAgeMs = Math.max(0, Number(options.maxAgeDays ?? 30)) * 86_400_000
281
+ const defaults = defaultStoreLimits()
282
+ const maxEntries = Math.max(10, Number(options.maxEntries ?? defaults.maxEntries))
283
+ const maxBytes = Math.max(1024 * 1024, Number(options.maxBytes ?? defaults.maxBytes))
284
+ const maxAgeMs = Math.max(0, Number(options.maxAgeDays ?? defaults.maxAgeDays)) * 86_400_000
222
285
  const now = Date.now()
286
+ const protectedHashes = options.protectMemoryRefs === false
287
+ ? new Set()
288
+ : await protectedEvidenceHashes(root)
223
289
  const rows = []
224
290
  for (const metaFile of files) {
225
291
  const meta = JSON.parse(await readFile(metaFile, "utf8").catch(() => "{}"))
226
292
  const time = Date.parse(meta.lastSeenAt || meta.createdAt || 0) || 0
227
- rows.push({ metaFile, meta, time })
293
+ rows.push({ metaFile, meta, time, bytes: Math.max(0, Number(meta.bytes || 0)) })
228
294
  }
229
295
  rows.sort((a, b) => b.time - a.time)
230
296
 
231
297
  const removed = []
232
- for (let index = 0; index < rows.length; index += 1) {
233
- const row = rows[index]
234
- const tooMany = index >= maxEntries
298
+ let retainedEntries = 0
299
+ let retainedBytes = 0
300
+ let protectedEntries = 0
301
+ let protectedBytes = 0
302
+ for (const row of rows) {
303
+ const hash = normalizeHash(row.meta.sha256 || path.basename(row.metaFile, ".json"))
304
+ const protectedByMemory = protectedHashes.has(hash)
305
+ const tooMany = retainedEntries >= maxEntries
235
306
  const tooOld = maxAgeMs > 0 && row.time > 0 && now - row.time > maxAgeMs
236
- if (!tooMany && !tooOld) continue
237
- const hash = row.meta.sha256 || path.basename(row.metaFile, ".json")
307
+ const tooLarge = retainedBytes + row.bytes > maxBytes
308
+ if (protectedByMemory || (!tooMany && !tooOld && !tooLarge)) {
309
+ retainedEntries += 1
310
+ retainedBytes += row.bytes
311
+ if (protectedByMemory) {
312
+ protectedEntries += 1
313
+ protectedBytes += row.bytes
314
+ }
315
+ continue
316
+ }
238
317
  const target = evidencePaths(root, hash)
239
318
  await rm(target.meta, { force: true })
240
319
  await rm(target.data, { force: true })
@@ -244,6 +323,11 @@ export async function gcEvidenceStore(root = process.cwd(), options = {}) {
244
323
  return {
245
324
  removed,
246
325
  removedCount: removed.length,
326
+ retainedEntries,
327
+ retainedBytes,
328
+ protectedEntries,
329
+ protectedBytes,
330
+ limits: { maxEntries, maxBytes, maxAgeMs },
247
331
  status: await evidenceStoreStatus(root),
248
332
  }
249
333
  }