@possumtech/rummy 2.1.0 → 2.2.1

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 (140) hide show
  1. package/.env.example +40 -15
  2. package/.xai.key +1 -0
  3. package/PLUGINS.md +169 -53
  4. package/README.md +38 -32
  5. package/SPEC.md +366 -179
  6. package/bin/digest.js +1097 -0
  7. package/biome/no-fallbacks.grit +2 -2
  8. package/gemini.key +1 -0
  9. package/lang/en.json +10 -1
  10. package/migrations/001_initial_schema.sql +9 -2
  11. package/package.json +19 -8
  12. package/service.js +1 -0
  13. package/src/agent/AgentLoop.js +76 -26
  14. package/src/agent/ContextAssembler.js +2 -0
  15. package/src/agent/Entries.js +238 -60
  16. package/src/agent/ProjectAgent.js +44 -0
  17. package/src/agent/TurnExecutor.js +99 -30
  18. package/src/agent/XmlParser.js +206 -111
  19. package/src/agent/errors.js +35 -0
  20. package/src/agent/known_queries.sql +1 -1
  21. package/src/agent/known_store.sql +3 -42
  22. package/src/agent/materializeContext.js +30 -1
  23. package/src/agent/runs.sql +8 -18
  24. package/src/agent/tokens.js +0 -1
  25. package/src/agent/turns.sql +1 -0
  26. package/src/hooks/Hooks.js +26 -0
  27. package/src/hooks/RummyContext.js +12 -1
  28. package/src/lib/hedberg/README.md +60 -0
  29. package/src/lib/hedberg/hedberg.js +60 -0
  30. package/src/lib/hedberg/marker.js +158 -0
  31. package/src/{plugins → lib}/hedberg/matcher.js +1 -2
  32. package/src/llm/LlmProvider.js +41 -3
  33. package/src/llm/openaiStream.js +17 -0
  34. package/src/plugins/ask_user/ask_user.js +12 -2
  35. package/src/plugins/ask_user/ask_userDoc.md +1 -5
  36. package/src/plugins/budget/README.md +29 -24
  37. package/src/plugins/budget/budget.js +166 -110
  38. package/src/plugins/cli/README.md +3 -4
  39. package/src/plugins/cli/cli.js +31 -5
  40. package/src/plugins/cloudflare/cloudflare.js +136 -0
  41. package/src/plugins/cp/cp.js +41 -4
  42. package/src/plugins/cp/cpDoc.md +5 -6
  43. package/src/plugins/engine/engine.sql +1 -1
  44. package/src/plugins/env/README.md +5 -4
  45. package/src/plugins/env/env.js +7 -4
  46. package/src/plugins/env/envDoc.md +7 -8
  47. package/src/plugins/error/error.js +56 -15
  48. package/src/plugins/file/README.md +12 -3
  49. package/src/plugins/file/file.js +2 -2
  50. package/src/plugins/get/get.js +59 -36
  51. package/src/plugins/get/getDoc.md +10 -34
  52. package/src/plugins/google/google.js +115 -0
  53. package/src/plugins/hedberg/hedberg.js +13 -56
  54. package/src/plugins/helpers.js +66 -12
  55. package/src/plugins/index.js +1 -2
  56. package/src/plugins/instructions/README.md +44 -47
  57. package/src/plugins/instructions/instructions-system.md +44 -0
  58. package/src/plugins/instructions/instructions-user.md +53 -0
  59. package/src/plugins/instructions/instructions.js +58 -189
  60. package/src/plugins/known/README.md +6 -7
  61. package/src/plugins/known/known.js +24 -30
  62. package/src/plugins/log/log.js +41 -32
  63. package/src/plugins/mv/mv.js +40 -1
  64. package/src/plugins/mv/mvDoc.md +1 -8
  65. package/src/plugins/ollama/ollama.js +4 -3
  66. package/src/plugins/openai/openai.js +4 -3
  67. package/src/plugins/openrouter/openrouter.js +14 -4
  68. package/src/plugins/persona/README.md +11 -13
  69. package/src/plugins/persona/default.md +29 -0
  70. package/src/plugins/persona/persona.js +10 -66
  71. package/src/plugins/policy/policy.js +23 -22
  72. package/src/plugins/prompt/README.md +37 -27
  73. package/src/plugins/prompt/prompt.js +13 -19
  74. package/src/plugins/rm/rm.js +18 -0
  75. package/src/plugins/rm/rmDoc.md +5 -6
  76. package/src/plugins/rpc/rpc.js +3 -3
  77. package/src/plugins/set/set.js +205 -323
  78. package/src/plugins/set/setDoc.md +47 -17
  79. package/src/plugins/sh/README.md +6 -5
  80. package/src/plugins/sh/sh.js +8 -5
  81. package/src/plugins/sh/shDoc.md +7 -8
  82. package/src/plugins/skill/README.md +37 -14
  83. package/src/plugins/skill/skill.js +200 -101
  84. package/src/plugins/skill/skillDoc.js +3 -0
  85. package/src/plugins/skill/skillDoc.md +9 -0
  86. package/src/plugins/stream/README.md +7 -6
  87. package/src/plugins/stream/finalize.js +100 -0
  88. package/src/plugins/stream/stream.js +13 -45
  89. package/src/plugins/telemetry/telemetry.js +27 -4
  90. package/src/plugins/think/think.js +2 -3
  91. package/src/plugins/think/thinkDoc.md +2 -4
  92. package/src/plugins/unknown/README.md +1 -1
  93. package/src/plugins/unknown/unknown.js +17 -19
  94. package/src/plugins/update/update.js +4 -51
  95. package/src/plugins/update/updateDoc.md +21 -6
  96. package/src/plugins/xai/xai.js +68 -102
  97. package/src/plugins/yolo/yolo.js +102 -75
  98. package/src/sql/functions/hedmatch.js +1 -1
  99. package/src/sql/functions/hedreplace.js +1 -1
  100. package/src/sql/functions/hedsearch.js +1 -1
  101. package/src/sql/functions/slugify.js +16 -2
  102. package/BENCH_ENVIRONMENT.md +0 -230
  103. package/CLIENT_INTERFACE.md +0 -396
  104. package/last_run.txt +0 -5617
  105. package/scriptify/ask_run.js +0 -77
  106. package/scriptify/cache_probe.js +0 -66
  107. package/scriptify/cache_probe_grok.js +0 -74
  108. package/src/agent/budget.js +0 -33
  109. package/src/agent/config.js +0 -38
  110. package/src/plugins/hedberg/README.md +0 -71
  111. package/src/plugins/hedberg/docs.md +0 -0
  112. package/src/plugins/hedberg/edits.js +0 -55
  113. package/src/plugins/hedberg/normalize.js +0 -17
  114. package/src/plugins/hedberg/sed.js +0 -49
  115. package/src/plugins/instructions/instructions.md +0 -34
  116. package/src/plugins/instructions/instructions_104.md +0 -8
  117. package/src/plugins/instructions/instructions_105.md +0 -39
  118. package/src/plugins/instructions/instructions_106.md +0 -22
  119. package/src/plugins/instructions/instructions_107.md +0 -17
  120. package/src/plugins/instructions/instructions_108.md +0 -0
  121. package/src/plugins/known/knownDoc.js +0 -3
  122. package/src/plugins/known/knownDoc.md +0 -8
  123. package/src/plugins/unknown/unknownDoc.js +0 -3
  124. package/src/plugins/unknown/unknownDoc.md +0 -11
  125. package/turns/cli_1777462658211/turn_001.txt +0 -772
  126. package/turns/cli_1777462658211/turn_002.txt +0 -606
  127. package/turns/cli_1777462658211/turn_003.txt +0 -667
  128. package/turns/cli_1777462658211/turn_004.txt +0 -297
  129. package/turns/cli_1777462658211/turn_005.txt +0 -301
  130. package/turns/cli_1777462658211/turn_006.txt +0 -262
  131. package/turns/cli_1777465095132/turn_001.txt +0 -715
  132. package/turns/cli_1777465095132/turn_002.txt +0 -236
  133. package/turns/cli_1777465095132/turn_003.txt +0 -287
  134. package/turns/cli_1777465095132/turn_004.txt +0 -694
  135. package/turns/cli_1777465095132/turn_005.txt +0 -422
  136. package/turns/cli_1777465095132/turn_006.txt +0 -365
  137. package/turns/cli_1777465095132/turn_007.txt +0 -885
  138. package/turns/cli_1777465095132/turn_008.txt +0 -1277
  139. package/turns/cli_1777465095132/turn_009.txt +0 -736
  140. /package/src/{plugins → lib}/hedberg/patterns.js +0 -0
@@ -1,18 +1,85 @@
1
1
  import slugify from "../sql/functions/slugify.js";
2
- import { PermissionError } from "./errors.js";
2
+ import { EntryOverflowError, PermissionError } from "./errors.js";
3
3
  import encodeSegment from "./pathEncode.js";
4
4
 
5
+ // Update entry bodies are promised ≤ 80 chars to clients (run summary
6
+ // payload, model-facing <log> rendering). Mirror of SUMMARY_MAX_CHARS:
7
+ // the boundary chops + emits a soft error so the violation is visible
8
+ // without crashing the run. Lives here because Entries.update is the
9
+ // canonical persistence boundary all callers fund-route through.
10
+ const UPDATE_BODY_MAX = 80;
11
+
12
+ // SQLite surfaces the CHECK as either err.code === "SQLITE_CONSTRAINT_CHECK"
13
+ // or an Error whose message names the failing column. Both forms appear in
14
+ // the wild depending on the driver build, so we match defensively.
15
+ // Caller-side contract: only invoked from a SQL try/catch, so err is always
16
+ // an Error instance — err.message is a string (possibly empty), not undefined.
17
+ function isBodyOverflow(err) {
18
+ if (!err) return false;
19
+ if (err.code === "SQLITE_CONSTRAINT_CHECK") return true;
20
+ return err.message.includes("CHECK") && err.message.includes("length(body)");
21
+ }
22
+
23
+ function translateBodyOverflow(err, path, body) {
24
+ if (!isBodyOverflow(err)) return err;
25
+ const size = body == null ? 0 : body.length;
26
+ return new EntryOverflowError(path, size);
27
+ }
28
+
29
+ // Already-an-error path: log://turn_N/error/<slug>. The auto-failure
30
+ // hook below skips these to break the recursion (error.log.emit's
31
+ // handler ALSO writes state=failed when materializing its own entry).
32
+ const ERROR_PATH_RE = /^log:\/\/turn_\d+\/error\//;
33
+
34
+ // Streaming data channels for env/sh actions (env://turn_N/cmd_K,
35
+ // sh://turn_N/cmd_K). Their failure is already captured by the parent
36
+ // log://turn_N/<scheme>/<slug> action entry's auto-emit; emitting again
37
+ // for each channel produces redundant duplicates with empty-body
38
+ // fallback messages.
39
+ const CHANNEL_PATH_RE = /^(env|sh):\/\/turn_\d+\//;
40
+
5
41
  export default class Entries {
6
42
  #db;
7
43
  #onChanged;
44
+ #onError;
45
+ #onFailed;
46
+ #onSoftError;
8
47
  #schemes = new Map();
9
48
  #schemesLoaded = null;
10
49
  #seq = 0;
11
50
  #pendingResolutions = new Map();
12
51
 
13
- constructor(db, { onChanged = null } = {}) {
52
+ // onError is the centralized site for storage-layer rejections that
53
+ // should surface to the model as strikes rather than crash the run.
54
+ // Today: EntryOverflowError (RUMMY_ENTRY_SIZE_MAX CHECK violations).
55
+ // When onError is supplied, set() catches the typed error, dispatches
56
+ // it to the callback (which emits hooks.error.log → 413 strike), and
57
+ // returns silently — callers don't need to handle storage-layer
58
+ // rejections at every write site. When onError is null (e.g. unit
59
+ // tests with a bare Entries), the error propagates as before.
60
+ //
61
+ // onFailed is the universal failure-rendering enforcer: every
62
+ // transition to state="failed" on a non-error path fires this
63
+ // callback so a SEPARATE log://turn_N/error/<slug> entry is created
64
+ // alongside the action entry. Without this, plugins that record
65
+ // failure via entries.set({state: "failed", ...}) leave nothing for
66
+ // the model to recognize as an error — failure encodes only as tiny
67
+ // JSON metadata indistinguishable from a successful entry. The
68
+ // callback wires to hooks.error.log.emit (see ProjectAgent).
69
+ constructor(
70
+ db,
71
+ {
72
+ onChanged = null,
73
+ onError = null,
74
+ onFailed = null,
75
+ onSoftError = null,
76
+ } = {},
77
+ ) {
14
78
  this.#db = db;
15
79
  this.#onChanged = onChanged;
80
+ this.#onError = onError;
81
+ this.#onFailed = onFailed;
82
+ this.#onSoftError = onSoftError;
16
83
  }
17
84
 
18
85
  // Populate the scheme cache; idempotent, lazy on first need.
@@ -42,7 +109,16 @@ export default class Entries {
42
109
  }
43
110
 
44
111
  static normalizePath(path) {
45
- if (!path?.includes("://")) return path;
112
+ if (!path) return path;
113
+ if (!path.includes("://")) {
114
+ // Bare file path: strip a single leading `./` for canonical
115
+ // form. `./main.go` and `main.go` must resolve to the same
116
+ // entry — otherwise SEARCH/REPLACE edits on `./main.go`
117
+ // land in a phantom entry while reads of `main.go` see the
118
+ // original, and the model can't reconcile.
119
+ if (path.startsWith("./")) return path.slice(2);
120
+ return path;
121
+ }
46
122
  const sep = path.indexOf("://");
47
123
  const scheme = path.slice(0, sep).toLowerCase();
48
124
  const rest = path.slice(sep + 3);
@@ -72,28 +148,29 @@ export default class Entries {
72
148
  return `${candidate}_${++this.#seq}`;
73
149
  }
74
150
 
75
- // Single namespace log://turn_N/action/slug; target URL-encoded for round-trip safety.
151
+ // Single namespace log://turn_N/action/slug. slug is built via slugify
152
+ // (80-char cap + integer tie-breaker on collision) — same contract as
153
+ // slugPath. Plugins (including externals) can trust that any target
154
+ // they pass will produce a bounded, unique log path, regardless of
155
+ // the target's length or character composition. Full payload always
156
+ // belongs in the entry body, not the slug.
76
157
  async logPath(runId, turn, action, target) {
77
- // Cap target before encoding: the schema's CHECK(length(path) <= 2048)
78
- // otherwise blows up when callers pass long error messages or other
79
- // arbitrary text. encodeURIComponent expands ~3x for ASCII, more for
80
- // Unicode; 150 raw chars stays comfortably under 2048 even after
81
- // worst-case expansion. The full message belongs in body, not path.
82
- const safeTarget = String(target).slice(0, 150);
83
- const encodedTarget = encodeSegment(safeTarget);
84
- const candidate = `log://turn_${turn}/${action}/${encodedTarget}`;
158
+ const slug = target == null ? "" : slugify(String(target));
159
+ const base = slug
160
+ ? `log://turn_${turn}/${action}/${slug}`
161
+ : `log://turn_${turn}/${action}/_`;
85
162
  const existing = await this.#db.get_entry_body.get({
86
163
  run_id: runId,
87
- path: candidate,
164
+ path: base,
88
165
  });
89
- if (!existing) return candidate;
90
- return `${candidate}_${++this.#seq}`;
166
+ if (!existing) return base;
167
+ return `${base}_${++this.#seq}`;
91
168
  }
92
169
 
93
- async slugPath(runId, scheme, content, summary) {
94
- // summary > content > empty; slugify("") yields "" and we sequence-only.
170
+ async slugPath(runId, scheme, content, tags) {
171
+ // tags > content > empty; slugify("") yields "" and we sequence-only.
95
172
  let source = "";
96
- if (summary) source = summary;
173
+ if (tags) source = tags;
97
174
  else if (content) source = content;
98
175
  const base = slugify(source);
99
176
  const prefix = `${scheme}://`;
@@ -149,7 +226,35 @@ export default class Entries {
149
226
  }
150
227
 
151
228
  // set — create or update an entry; see PLUGINS.md primitives.
152
- async set({
229
+ async set(args) {
230
+ if (!args.runId) throw new Error("set: runId is required");
231
+ if (!args.path) throw new Error("set: path is required");
232
+ try {
233
+ return await this.#setImpl(args);
234
+ } catch (err) {
235
+ // EntryOverflowError: storage-layer CHECK fired. When the host
236
+ // supplies onError (the production wiring), route the strike
237
+ // to error.log and return silently — every set() caller in
238
+ // the codebase becomes overflow-safe without per-site catches.
239
+ // Without onError (raw unit tests), propagate as before.
240
+ if (err instanceof EntryOverflowError && this.#onError) {
241
+ // Destructure with the same defaults as #setImpl so the
242
+ // callback sees the same loopId/turn shape callers wrote
243
+ // against — no `??` fallback shim, just contract alignment.
244
+ const { runId, loopId = null, turn = 0 } = args;
245
+ await this.#onError({
246
+ runId,
247
+ loopId,
248
+ turn,
249
+ error: err,
250
+ });
251
+ return;
252
+ }
253
+ throw err;
254
+ }
255
+ }
256
+
257
+ async #setImpl({
153
258
  runId,
154
259
  projectId = null,
155
260
  turn = 0,
@@ -166,20 +271,21 @@ export default class Entries {
166
271
  loopId = null,
167
272
  writer = "plugin",
168
273
  }) {
169
- if (!runId) throw new Error("set: runId is required");
170
- if (!path) throw new Error("set: path is required");
171
-
172
274
  // Pattern mode is explicit; never inferred from `*` in path.
173
275
  const isPattern = pattern === true || bodyFilter !== null;
174
276
 
175
277
  if (isPattern) {
176
278
  if (body != null && !append) {
177
- await this.#db.update_body_by_pattern.run({
178
- run_id: runId,
179
- path,
180
- body: bodyFilter,
181
- new_body: body,
182
- });
279
+ try {
280
+ await this.#db.update_body_by_pattern.run({
281
+ run_id: runId,
282
+ path,
283
+ body: bodyFilter,
284
+ new_body: body,
285
+ });
286
+ } catch (err) {
287
+ throw translateBodyOverflow(err, path, body);
288
+ }
183
289
  await this.#db.bump_write_count_by_pattern.run({
184
290
  run_id: runId,
185
291
  path,
@@ -212,11 +318,15 @@ export default class Entries {
212
318
  // Append mode: streaming body growth on an existing entry.
213
319
  if (append) {
214
320
  if (body == null) throw new Error("set: append requires body");
215
- await this.#db.append_entry_body.run({
216
- run_id: runId,
217
- path: normalized,
218
- chunk: body,
219
- });
321
+ try {
322
+ await this.#db.append_entry_body.run({
323
+ run_id: runId,
324
+ path: normalized,
325
+ chunk: body,
326
+ });
327
+ } catch (err) {
328
+ throw translateBodyOverflow(err, normalized, body);
329
+ }
220
330
  this.#emitChanged(runId, normalized, "append");
221
331
  return;
222
332
  }
@@ -232,6 +342,15 @@ export default class Entries {
232
342
  });
233
343
  this.#emitChanged(runId, normalized, "resolve");
234
344
  this.#drainPendingResolution(runId, normalized);
345
+ if (state === "failed") {
346
+ await this.#fireFailed({
347
+ runId,
348
+ turn,
349
+ loopId,
350
+ path: normalized,
351
+ outcome,
352
+ });
353
+ }
235
354
  }
236
355
  if (visibility != null) {
237
356
  await this.#db.set_visibility.run({
@@ -264,20 +383,37 @@ export default class Entries {
264
383
  const m = normalized.match(/^log:\/\/turn_\d+\/([^/]+)\//);
265
384
  if (m) effectiveAttributes.action = m[1];
266
385
  }
267
- const entry = await this.#db.upsert_entry.get({
268
- scope,
269
- path: normalized,
270
- body,
271
- attributes: effectiveAttributes
272
- ? JSON.stringify(effectiveAttributes)
273
- : null,
274
- hash,
275
- });
386
+ let entry;
387
+ try {
388
+ entry = await this.#db.upsert_entry.get({
389
+ scope,
390
+ path: normalized,
391
+ body,
392
+ attributes: effectiveAttributes
393
+ ? JSON.stringify(effectiveAttributes)
394
+ : null,
395
+ hash,
396
+ });
397
+ } catch (err) {
398
+ throw translateBodyOverflow(err, normalized, body);
399
+ }
276
400
  const effectiveState = state === undefined ? "resolved" : state;
277
- const effectiveVisibility =
278
- visibility === undefined
279
- ? this.#defaultVisibility(scheme, category)
280
- : visibility;
401
+ // Visibility resolution: explicit > preserve-existing > scheme-default.
402
+ // A body update without visibility= must NOT silently reset visibility
403
+ // to the scheme default — that would hide content the model just
404
+ // promoted (e.g. a model <get>'d file then <set> SEARCH/REPLACE
405
+ // would lose its visible status). Preserve what's there.
406
+ let effectiveVisibility;
407
+ if (visibility !== undefined) {
408
+ effectiveVisibility = visibility;
409
+ } else {
410
+ const existing = await this.getState(runId, normalized);
411
+ if (existing?.visibility) {
412
+ effectiveVisibility = existing.visibility;
413
+ } else {
414
+ effectiveVisibility = this.#defaultVisibility(scheme, category);
415
+ }
416
+ }
281
417
  await this.#db.upsert_run_view.run({
282
418
  run_id: runId,
283
419
  entry_id: entry.id,
@@ -291,6 +427,42 @@ export default class Entries {
291
427
  if (effectiveState !== "proposed") {
292
428
  this.#drainPendingResolution(runId, normalized);
293
429
  }
430
+ if (effectiveState === "failed") {
431
+ await this.#fireFailed({
432
+ runId,
433
+ turn,
434
+ loopId,
435
+ path: normalized,
436
+ body,
437
+ outcome,
438
+ });
439
+ }
440
+ }
441
+
442
+ // Fire onFailed for any state→failed transition on a non-error path.
443
+ // The auto-emit creates a sibling log://turn_N/error/<slug> entry so
444
+ // the failure appears in the model's <log> as a category-distinct
445
+ // item, not just metadata buried in the action's own log entry.
446
+ async #fireFailed({ runId, turn, loopId, path, body, outcome }) {
447
+ if (!this.#onFailed) return;
448
+ if (ERROR_PATH_RE.test(path)) return;
449
+ if (CHANNEL_PATH_RE.test(path)) return;
450
+ // Body-less state changes don't carry a message; fall back to the
451
+ // outcome string (or the path itself) so the error entry has a
452
+ // recognizable slug instead of an empty one.
453
+ let message = body;
454
+ if (!message) {
455
+ if (outcome) message = `failed: ${outcome}`;
456
+ else message = `failed: ${path}`;
457
+ }
458
+ await this.#onFailed({
459
+ runId,
460
+ turn,
461
+ loopId,
462
+ sourcePath: path,
463
+ body: message,
464
+ outcome,
465
+ });
294
466
  }
295
467
 
296
468
  // get — promote entry(ies); see PLUGINS.md primitives.
@@ -399,6 +571,9 @@ export default class Entries {
399
571
  }
400
572
 
401
573
  // update — once-per-turn lifecycle signal; see PLUGINS.md.
574
+ // Body chopped to UPDATE_BODY_MAX with a soft error fire so clients
575
+ // always receive ≤ 80 chars and the violation is visible to the model
576
+ // next turn. Applies to ALL callers — system, plugin, model.
402
577
  async update({
403
578
  runId,
404
579
  turn = 0,
@@ -410,12 +585,24 @@ export default class Entries {
410
585
  }) {
411
586
  if (!runId) throw new Error("update: runId is required");
412
587
  if (body == null) throw new Error("update: body is required");
413
- const path = await this.logPath(runId, turn, "update", body);
588
+ let storedBody = body;
589
+ if (body.length > UPDATE_BODY_MAX) {
590
+ storedBody = body.slice(0, UPDATE_BODY_MAX);
591
+ if (this.#onSoftError) {
592
+ await this.#onSoftError({
593
+ runId,
594
+ turn,
595
+ loopId,
596
+ message: "error: YOU MUST keep the update body to <= 80 characters",
597
+ });
598
+ }
599
+ }
600
+ const path = await this.logPath(runId, turn, "update", storedBody);
414
601
  await this.set({
415
602
  runId,
416
603
  turn,
417
604
  path,
418
- body,
605
+ body: storedBody,
419
606
  state: "resolved",
420
607
  loopId,
421
608
  writer,
@@ -527,10 +714,10 @@ export default class Entries {
527
714
  });
528
715
  }
529
716
 
530
- async archivePriorPromptArtifacts(runId, currentTurn) {
531
- await this.#db.archive_prior_prompt_artifacts.run({
717
+ async setNextTurn(runId, nextTurn) {
718
+ await this.#db.set_next_turn.run({
532
719
  run_id: runId,
533
- current_turn: currentTurn,
720
+ next_turn: nextTurn,
534
721
  });
535
722
  }
536
723
 
@@ -544,15 +731,6 @@ export default class Entries {
544
731
  return targets;
545
732
  }
546
733
 
547
- // Budget postDispatch fallback: demote every visible entry in the run.
548
- async demoteRunVisibleEntries(runId) {
549
- const targets = await this.#db.get_run_visible_targets.all({
550
- run_id: runId,
551
- });
552
- await this.#db.demote_run_visible.run({ run_id: runId });
553
- return targets;
554
- }
555
-
556
734
  // Plugin-facing run lookup; avoids reaching into core.db.
557
735
  async getRun(runId) {
558
736
  return this.#db.get_run_by_id.get({ id: runId });
@@ -1,6 +1,7 @@
1
1
  import LlmProvider from "../llm/LlmProvider.js";
2
2
  import AgentLoop from "./AgentLoop.js";
3
3
  import Entries from "./Entries.js";
4
+ import { SOFT_FAILURE_OUTCOMES } from "./errors.js";
4
5
  import TurnExecutor from "./TurnExecutor.js";
5
6
 
6
7
  export default class ProjectAgent {
@@ -16,6 +17,49 @@ export default class ProjectAgent {
16
17
  this.#llm = new LlmProvider(db, hooks);
17
18
  this.#entries = new Entries(db, {
18
19
  onChanged: (event) => hooks.entry.changed.emit(event),
20
+ onError: ({ runId, loopId, turn, error }) =>
21
+ hooks.error.log.emit({
22
+ store: this.#entries,
23
+ runId,
24
+ turn,
25
+ loopId,
26
+ message: error.message,
27
+ status: 413,
28
+ attributes: { path: error.path, size: error.size },
29
+ }),
30
+ // Universal failure-rendering: every state→failed transition on
31
+ // a non-error path fires error.log.emit so a sibling
32
+ // log://turn_N/error/<slug> entry is created. The error plugin's
33
+ // own #onErrorLog handler also writes state=failed on the error
34
+ // entry; Entries.#fireFailed skips when path matches
35
+ // log://turn_*/error/* so no recursion.
36
+ //
37
+ // soft=true when the outcome is in SOFT_FAILURE_OUTCOMES
38
+ // (not_found, conflict): the error entry still renders so the
39
+ // model can read the finding, but error.log skips turnErrors++
40
+ // so the strike accumulator doesn't penalize legitimate
41
+ // state-discovery via the auto-emit path. Without this, soft
42
+ // outcomes count as strikes on the turnErrors path even though
43
+ // recordedFailed correctly excludes them.
44
+ onFailed: ({ runId, loopId, turn, sourcePath, body, outcome }) =>
45
+ hooks.error.log.emit({
46
+ store: this.#entries,
47
+ runId,
48
+ turn,
49
+ loopId,
50
+ message: body,
51
+ attributes: { sourcePath, outcome },
52
+ soft: SOFT_FAILURE_OUTCOMES.has(outcome),
53
+ }),
54
+ onSoftError: ({ runId, loopId, turn, message }) =>
55
+ hooks.error.log.emit({
56
+ store: this.#entries,
57
+ runId,
58
+ turn,
59
+ loopId,
60
+ message,
61
+ soft: true,
62
+ }),
19
63
  });
20
64
  this.#entries.loadSchemes(db);
21
65