@namzu/sdk 21.0.0 → 22.0.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 (103) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/dist/advisory/executor.d.ts +9 -4
  3. package/dist/advisory/executor.d.ts.map +1 -1
  4. package/dist/advisory/executor.js +16 -9
  5. package/dist/advisory/executor.js.map +1 -1
  6. package/dist/constants/limits.d.ts +13 -0
  7. package/dist/constants/limits.d.ts.map +1 -1
  8. package/dist/constants/limits.js +14 -2
  9. package/dist/constants/limits.js.map +1 -1
  10. package/dist/manager/agent/lifecycle.d.ts +2 -2
  11. package/dist/manager/run/persistence.d.ts +37 -2
  12. package/dist/manager/run/persistence.d.ts.map +1 -1
  13. package/dist/manager/run/persistence.js +48 -7
  14. package/dist/manager/run/persistence.js.map +1 -1
  15. package/dist/pricing/catalogue.generated.d.ts +15 -0
  16. package/dist/pricing/catalogue.generated.d.ts.map +1 -0
  17. package/dist/pricing/catalogue.generated.js +293 -0
  18. package/dist/pricing/catalogue.generated.js.map +1 -0
  19. package/dist/pricing/index.d.ts +46 -0
  20. package/dist/pricing/index.d.ts.map +1 -0
  21. package/dist/pricing/index.js +70 -0
  22. package/dist/pricing/index.js.map +1 -0
  23. package/dist/public-runtime.d.ts +6 -1
  24. package/dist/public-runtime.d.ts.map +1 -1
  25. package/dist/public-runtime.js +21 -1
  26. package/dist/public-runtime.js.map +1 -1
  27. package/dist/public-types.d.ts +3 -1
  28. package/dist/public-types.d.ts.map +1 -1
  29. package/dist/run/LimitChecker.d.ts +10 -0
  30. package/dist/run/LimitChecker.d.ts.map +1 -1
  31. package/dist/run/LimitChecker.js +14 -2
  32. package/dist/run/LimitChecker.js.map +1 -1
  33. package/dist/run/command-gate.d.ts +107 -0
  34. package/dist/run/command-gate.d.ts.map +1 -0
  35. package/dist/run/command-gate.js +157 -0
  36. package/dist/run/command-gate.js.map +1 -0
  37. package/dist/run/index.d.ts +6 -0
  38. package/dist/run/index.d.ts.map +1 -1
  39. package/dist/run/index.js +3 -0
  40. package/dist/run/index.js.map +1 -1
  41. package/dist/run/memory-promoter.d.ts +70 -0
  42. package/dist/run/memory-promoter.d.ts.map +1 -0
  43. package/dist/run/memory-promoter.js +117 -0
  44. package/dist/run/memory-promoter.js.map +1 -0
  45. package/dist/run/workspace-fingerprint.d.ts +105 -0
  46. package/dist/run/workspace-fingerprint.d.ts.map +1 -0
  47. package/dist/run/workspace-fingerprint.js +147 -0
  48. package/dist/run/workspace-fingerprint.js.map +1 -0
  49. package/dist/runtime/query/checkpoint.d.ts +23 -2
  50. package/dist/runtime/query/checkpoint.d.ts.map +1 -1
  51. package/dist/runtime/query/checkpoint.js +24 -3
  52. package/dist/runtime/query/checkpoint.js.map +1 -1
  53. package/dist/runtime/query/guard.d.ts.map +1 -1
  54. package/dist/runtime/query/guard.js +1 -0
  55. package/dist/runtime/query/guard.js.map +1 -1
  56. package/dist/runtime/query/index.d.ts.map +1 -1
  57. package/dist/runtime/query/index.js +42 -0
  58. package/dist/runtime/query/index.js.map +1 -1
  59. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  60. package/dist/runtime/query/iteration/index.js +15 -2
  61. package/dist/runtime/query/iteration/index.js.map +1 -1
  62. package/dist/runtime/query/iteration/phases/advisory.d.ts.map +1 -1
  63. package/dist/runtime/query/iteration/phases/advisory.js +9 -1
  64. package/dist/runtime/query/iteration/phases/advisory.js.map +1 -1
  65. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  66. package/dist/runtime/query/iteration/phases/compaction.js +11 -2
  67. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  68. package/dist/store/run/conformance.d.ts.map +1 -1
  69. package/dist/store/run/conformance.js +8 -2
  70. package/dist/store/run/conformance.js.map +1 -1
  71. package/dist/types/common/index.d.ts +38 -2
  72. package/dist/types/common/index.d.ts.map +1 -1
  73. package/dist/types/run/stop-reason.d.ts +31 -1
  74. package/dist/types/run/stop-reason.d.ts.map +1 -1
  75. package/dist/utils/cost.d.ts +60 -2
  76. package/dist/utils/cost.d.ts.map +1 -1
  77. package/dist/utils/cost.js +126 -11
  78. package/dist/utils/cost.js.map +1 -1
  79. package/package.json +1 -1
  80. package/src/advisory/executor.ts +16 -9
  81. package/src/constants/limits.ts +14 -2
  82. package/src/manager/agent/lifecycle.ts +2 -2
  83. package/src/manager/run/persistence.ts +67 -7
  84. package/src/pricing/catalogue.generated.ts +308 -0
  85. package/src/pricing/index.ts +78 -0
  86. package/src/pricing/rates.source.json +206 -0
  87. package/src/public-runtime.ts +39 -0
  88. package/src/public-types.ts +3 -1
  89. package/src/run/LimitChecker.ts +24 -2
  90. package/src/run/command-gate.ts +234 -0
  91. package/src/run/index.ts +17 -0
  92. package/src/run/memory-promoter.ts +155 -0
  93. package/src/run/workspace-fingerprint.ts +193 -0
  94. package/src/runtime/query/checkpoint.ts +24 -3
  95. package/src/runtime/query/guard.ts +1 -0
  96. package/src/runtime/query/index.ts +42 -0
  97. package/src/runtime/query/iteration/index.ts +15 -2
  98. package/src/runtime/query/iteration/phases/advisory.ts +9 -1
  99. package/src/runtime/query/iteration/phases/compaction.ts +12 -2
  100. package/src/store/run/conformance.ts +8 -2
  101. package/src/types/common/index.ts +38 -2
  102. package/src/types/run/stop-reason.ts +30 -0
  103. package/src/utils/cost.ts +186 -16
@@ -4,6 +4,16 @@ export interface LimitCheckerState {
4
4
  aborted: boolean
5
5
  totalTokens: number
6
6
  totalCost: number
7
+ /**
8
+ * Tokens accumulated at no known rate, from `CostInfo.unpricedTokens`.
9
+ *
10
+ * The cost checks below read `totalCost`, and `totalCost` omits whatever
11
+ * these cost. Without this field they cannot tell a run that has spent
12
+ * nothing from one whose spend was never computed, so a `costLimitUsd`
13
+ * silently never fires — which is what every run did, because nothing fed
14
+ * the cost calculation at all.
15
+ */
16
+ unpricedTokens: number
7
17
  currentIteration: number
8
18
  startTime: number
9
19
  }
@@ -29,8 +39,20 @@ export function checkLimitsDetailed(
29
39
  return { type: 'hard_stop', reason: 'token_budget' }
30
40
  }
31
41
 
32
- if (config.costLimitUsd && config.costLimitUsd > 0 && state.totalCost >= config.costLimitUsd) {
33
- return { type: 'hard_stop', reason: 'cost_limit' }
42
+ if (config.costLimitUsd && config.costLimitUsd > 0) {
43
+ if (state.totalCost >= config.costLimitUsd) {
44
+ return { type: 'hard_stop', reason: 'cost_limit' }
45
+ }
46
+ // Checked BEFORE the comparison can pass, not after. `totalCost` is
47
+ // only the part of the run that had a rate, so a run whose spend is
48
+ // partly unknown can sit under any limit forever while spending without
49
+ // bound. Continuing here would be the check answering "the budget is
50
+ // satisfied" to the question "can the budget be evaluated?", which is
51
+ // the strongest available wrong answer — see
52
+ // docs/conventions/an-optional-dependency-may-not-degrade-a-check.md.
53
+ if (state.unpricedTokens > 0) {
54
+ return { type: 'hard_stop', reason: 'cost_unmeasurable' }
55
+ }
34
56
  }
35
57
 
36
58
  if (state.currentIteration >= config.maxIterations) {
@@ -0,0 +1,234 @@
1
+ /**
2
+ * A {@link ReviewAnswer} that runs shell commands and hands the failure back.
3
+ *
4
+ * `reviewAnswer` was the seam for exactly this — judge the answer at the point
5
+ * the model stops calling tools, and return it with feedback instead of
6
+ * settling — and nothing shipped supplied one, so an operator who wanted
7
+ * "don't finish until the build passes" had to write TypeScript. This is the
8
+ * supplier. With it, `--gate 'pnpm test'` is the whole unattended story: the
9
+ * model works, stops, the tests run, and a failure comes back as the next user
10
+ * turn rather than as a green run somebody discovers in CI.
11
+ *
12
+ * The kernel already bounds it. The reviewer is consulted only when the model
13
+ * stopped calling tools, never on the forced-final turn, and a rejection
14
+ * budget stops the run with `answer_rejected` — a stop reason that names the
15
+ * reviewer rather than blaming a token budget. None of that is re-implemented
16
+ * here.
17
+ *
18
+ * ## The part that is not just "run a command"
19
+ *
20
+ * **Before re-running a command that already failed, the workspace is
21
+ * fingerprinted, and an identical fingerprint means the command is NOT run.**
22
+ *
23
+ * This is the difference between a bounded loop and one that spends its whole
24
+ * budget. A model that has run out of ideas answers again without editing
25
+ * anything; re-running the suite then costs a full execution — often the most
26
+ * expensive thing in the loop — to produce a failure already known character
27
+ * for character. Worse, the feedback is identical, so the model is handed the
28
+ * same prompt that just failed to help it. Saying instead "the workspace has
29
+ * not changed since that failure; edit something before trying to finish"
30
+ * is both cheaper and a different instruction.
31
+ *
32
+ * The attempt still advances. Skipping the command is a saving, not a pardon:
33
+ * an answer that changed nothing has been rejected, and the run's budget must
34
+ * see that or a stuck model loops forever for free.
35
+ *
36
+ * And it fails open on the cheap side. No fingerprint — a git invocation that
37
+ * errored, a timeout, output past the cap, a tree with no commits — means the
38
+ * command runs. See {@link fingerprintWorkspace}: the cost of re-running
39
+ * unnecessarily is one execution; the cost of wrongly skipping is a
40
+ * verification that silently did not happen.
41
+ */
42
+
43
+ import { LocalExecutionContext } from '../execution/local.js'
44
+ import type { CommandOptions, CommandResult } from '../types/execution/index.js'
45
+ import type { AnswerReview, ReviewAnswer } from '../types/run/answer-review.js'
46
+ import { fingerprintWorkspace } from './workspace-fingerprint.js'
47
+
48
+ /** How the gate runs a command. Injected so a test needs no shell. */
49
+ export type GateExec = (
50
+ command: string,
51
+ args: string[],
52
+ options?: CommandOptions,
53
+ ) => Promise<CommandResult>
54
+
55
+ /** Default per-command deadline. A test suite is allowed to be slow. */
56
+ export const DEFAULT_GATE_TIMEOUT_MS = 600_000
57
+
58
+ /** How many attempts the gate will EXECUTE its commands for, by default. */
59
+ export const DEFAULT_GATE_MAX_RETRIES = 3
60
+
61
+ /**
62
+ * Model-visible characters of a failing command's output.
63
+ *
64
+ * Head and tail, not head alone: a compiler names the file at the top and a
65
+ * test runner names the failure at the bottom, and a gate that only ever kept
66
+ * one end would be useless for one of them.
67
+ */
68
+ export const DEFAULT_GATE_OUTPUT_CHARS = 4_000
69
+
70
+ export interface CommandGateOptions {
71
+ /**
72
+ * Shell command lines, run in order, stopping at the first failure.
73
+ *
74
+ * In order and short-circuiting because that is what a person means by
75
+ * "typecheck then test": a type error makes the test output noise about
76
+ * the same cause, and handing the model both invites it to fix the
77
+ * symptom.
78
+ */
79
+ readonly commands: readonly string[]
80
+ /** Directory the commands run in, and the tree that is fingerprinted. */
81
+ readonly cwd: string
82
+ /**
83
+ * How many attempts will actually EXECUTE the commands.
84
+ *
85
+ * Past it the gate rejects without running anything, naming the
86
+ * exhaustion. It does not accept: an answer that never passed the gate
87
+ * has not passed the gate, and a reviewer that gave up by accepting would
88
+ * hand back a green run over a red build — the exact outcome the gate
89
+ * exists to prevent. What ENDS the run is the kernel's rejection budget,
90
+ * so set that to the same number (the CLI does).
91
+ */
92
+ readonly maxRetries?: number
93
+ /** Per-command deadline. See {@link DEFAULT_GATE_TIMEOUT_MS}. */
94
+ readonly timeoutMs?: number
95
+ /** Override the executor. Defaults to a local shell in `cwd`. */
96
+ readonly exec?: GateExec
97
+ /** See {@link DEFAULT_GATE_OUTPUT_CHARS}. */
98
+ readonly maxOutputChars?: number
99
+ /**
100
+ * Override the change detector. Defaults to
101
+ * {@link fingerprintWorkspace} over `cwd`.
102
+ *
103
+ * Returning `null` means "cannot tell", and the gate then runs its
104
+ * commands. A detector that returned a constant would silence the gate
105
+ * after its first failure, so this seam exists for tests and for a host
106
+ * whose workspace is not a git tree — not as a way to turn the check off.
107
+ */
108
+ readonly fingerprint?: () => Promise<string | null>
109
+ }
110
+
111
+ /** Head and tail of a command's output, with the middle marked as dropped. */
112
+ export function clipOutput(text: string, max: number): string {
113
+ const trimmed = text.trimEnd()
114
+ if (trimmed.length <= max) return trimmed
115
+ const half = Math.floor(max / 2)
116
+ const dropped = trimmed.length - half * 2
117
+ return `${trimmed.slice(0, half)}\n… ${dropped} characters omitted …\n${trimmed.slice(-half)}`
118
+ }
119
+
120
+ interface LastFailure {
121
+ readonly command: string
122
+ /** The tree as it stood when this failed. `null` = could not be taken. */
123
+ readonly fingerprint: string | null
124
+ }
125
+
126
+ function failureFeedback(
127
+ command: string,
128
+ attempt: number,
129
+ result: CommandResult,
130
+ maxOutputChars: number,
131
+ ): string {
132
+ const output = clipOutput(`${result.stdout}\n${result.stderr}`, maxOutputChars)
133
+ return [
134
+ `The answer was not accepted: \`${command}\` failed (attempt ${attempt}, exit ${result.exitCode}).`,
135
+ '',
136
+ 'Output:',
137
+ '```',
138
+ output || '(no output)',
139
+ '```',
140
+ '',
141
+ 'Fix the cause and then finish. Do not restate the failure back to me; change the code so the command passes.',
142
+ ].join('\n')
143
+ }
144
+
145
+ function unchangedFeedback(command: string, attempt: number): string {
146
+ return [
147
+ `The answer was not accepted, and \`${command}\` was NOT re-run (attempt ${attempt}).`,
148
+ '',
149
+ 'The workspace is byte-for-byte identical to what it was when that command last failed — no file was created, edited or deleted since. Running it again would produce the failure you have already been shown.',
150
+ '',
151
+ 'Edit something before trying to finish again. If you believe the change you described was made, verify it by reading the file: it is not on disk.',
152
+ ].join('\n')
153
+ }
154
+
155
+ function exhaustedFeedback(command: string, maxRetries: number): string {
156
+ return [
157
+ `The answer was not accepted: \`${command}\` has failed and this gate has spent its ${maxRetries} attempts.`,
158
+ '',
159
+ 'No further command will be run.',
160
+ ].join('\n')
161
+ }
162
+
163
+ /**
164
+ * Build a reviewer that accepts an answer only when every command passes.
165
+ *
166
+ * Stateful across calls within one run, deliberately: the whole point is that
167
+ * attempt N+1 can be compared with attempt N. Build one gate per run.
168
+ */
169
+ export function createCommandGate(options: CommandGateOptions): ReviewAnswer {
170
+ const { commands, cwd } = options
171
+ const timeoutMs = options.timeoutMs ?? DEFAULT_GATE_TIMEOUT_MS
172
+ const maxRetries = options.maxRetries ?? DEFAULT_GATE_MAX_RETRIES
173
+ const maxOutputChars = options.maxOutputChars ?? DEFAULT_GATE_OUTPUT_CHARS
174
+
175
+ // Built once and reused: constructing a context per attempt would re-stat
176
+ // the directory for no gain, and the context holds nothing per-run.
177
+ const context = new LocalExecutionContext({ id: 'namzu-command-gate', cwd })
178
+ const exec: GateExec =
179
+ options.exec ?? ((command, args, opts) => context.executeCommand(command, args, opts))
180
+ const fingerprint =
181
+ options.fingerprint ?? (() => fingerprintWorkspace({ cwd, exec, timeoutMs: 20_000 }))
182
+
183
+ let attempt = 0
184
+ let last: LastFailure | undefined
185
+ let executions = 0
186
+
187
+ return async (): Promise<AnswerReview> => {
188
+ attempt += 1
189
+
190
+ // A gate that already failed, over a tree nothing has touched since.
191
+ // The command is skipped, and the attempt still counts.
192
+ //
193
+ // The comparison is a bare `===` rather than `now !== null && now ===
194
+ // …`. The guard above already establishes that the recorded
195
+ // fingerprint is non-null, so a `null` from this call cannot match it,
196
+ // and the extra clause was a branch nothing could reach — a mutation
197
+ // that deleted it killed no test, which is what a dead condition looks
198
+ // like from the outside.
199
+ if (last && last.fingerprint !== null) {
200
+ const now = await fingerprint()
201
+ if (now === last.fingerprint) {
202
+ return { accept: false, feedback: unchangedFeedback(last.command, attempt) }
203
+ }
204
+ }
205
+
206
+ if (last && executions >= maxRetries) {
207
+ return { accept: false, feedback: exhaustedFeedback(last.command, maxRetries) }
208
+ }
209
+
210
+ executions += 1
211
+ for (const command of commands) {
212
+ // `shell: true` because the operator handed over a command LINE —
213
+ // `pnpm test -- --run`, with its flags and its quoting — and taking
214
+ // that as an executable name plus literal arguments would fail on
215
+ // every gate anyone would actually write. Explicit, per the note on
216
+ // `LocalExecutionContext.executeCommand`: shell interpretation is
217
+ // opt-in, and this is the opt-in.
218
+ const result = await exec(command, [], { cwd, timeoutMs, shell: true })
219
+ if (result.exitCode === 0) continue
220
+
221
+ // Taken AFTER the failure, not before the run: the comparison next
222
+ // time is against the tree this verdict was formed over. A snapshot
223
+ // from before the command would miss anything the command itself
224
+ // wrote — a formatter, a snapshot updater, a lockfile.
225
+ last = { command, fingerprint: await fingerprint() }
226
+ return { accept: false, feedback: failureFeedback(command, attempt, result, maxOutputChars) }
227
+ }
228
+
229
+ // Cleared, so a later rejection by a DIFFERENT command is not compared
230
+ // against a tree this one failed over.
231
+ last = undefined
232
+ return { accept: true }
233
+ }
234
+ }
package/src/run/index.ts CHANGED
@@ -7,6 +7,23 @@ export type { RunReporter } from './reporter.js'
7
7
 
8
8
  export { DEFAULT_DRAIN_PAGE_SIZE, drainRuns } from './drain.js'
9
9
  export type { DrainFailure, DrainRun, DrainRunsParams, DrainRunsResult } from './drain.js'
10
+ export {
11
+ DEFAULT_GATE_MAX_RETRIES,
12
+ DEFAULT_GATE_OUTPUT_CHARS,
13
+ DEFAULT_GATE_TIMEOUT_MS,
14
+ clipOutput,
15
+ createCommandGate,
16
+ } from './command-gate.js'
17
+ export type { CommandGateOptions, GateExec } from './command-gate.js'
18
+ export {
19
+ FINGERPRINT_MAX_BYTES,
20
+ FINGERPRINT_TIMEOUT_MS,
21
+ fingerprintWorkspace,
22
+ } from './workspace-fingerprint.js'
23
+ export type { FingerprintExec, WorkspaceFingerprintOptions } from './workspace-fingerprint.js'
10
24
 
11
25
  export { checkLimitsDetailed, buildLimitConfig } from './LimitChecker.js'
12
26
  export type { LimitCheckerState, LimitCheckResult } from './LimitChecker.js'
27
+
28
+ export { RUN_MEMORY_TAG, createMemoryPromoter } from './memory-promoter.js'
29
+ export type { MemoryPromoterOptions } from './memory-promoter.js'
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The default {@link PromoteMemory}: write what a run learned into a
3
+ * {@link MemoryStore}, or write nothing at all.
4
+ *
5
+ * `promoteMemory` is called once at settle with the compaction extractor's
6
+ * already-structured output — decisions, discoveries, user requirements,
7
+ * failures, environment facts — and **nothing shipped supplied the hook**.
8
+ * So the structure the compaction pass had spent tokens producing was
9
+ * serialized into one system message and dropped on the floor when the run
10
+ * ended, exactly as its own module comment says. This is the supplier, and
11
+ * it is mostly a filter: the hard part — extracting facts from a transcript
12
+ * — already happened.
13
+ *
14
+ * ## The filter, which is the only decision here
15
+ *
16
+ * **A run that learned nothing must leave nothing.** Not an empty record,
17
+ * not a record whose body says "no decisions" — nothing. A promoter that
18
+ * wrote a row per run would fill the store with the runs least worth
19
+ * remembering, and `search_memory` would then return them: the model reads
20
+ * that store on later runs, so noise here is not merely wasted disk, it is
21
+ * context spent on a run that did nothing.
22
+ *
23
+ * What counts as having learned something is the five KNOWLEDGE categories —
24
+ * decisions, discoveries, user requirements, failures, environment. Not
25
+ * `task`, which every run has because it is the prompt restated. Not
26
+ * `files`, which every run that opened anything has, and which says what was
27
+ * touched rather than what was learned. A run whose only trace is "it read
28
+ * six files" is the exact record this filter exists to refuse.
29
+ *
30
+ * ## What it does NOT do
31
+ *
32
+ * Deduplicate against what is already stored, merge with a previous run's
33
+ * record, or expire anything. Each is a policy with real trade-offs and a
34
+ * host that wants one owns it — `promoteMemory` is a callback precisely so
35
+ * that the runtime does not decide this. This is the obvious default, not
36
+ * the only possible one.
37
+ */
38
+
39
+ import type { MemoryStore } from '../types/memory/index.js'
40
+ import type { PromoteMemory, RunMemoryCandidate } from '../types/run/memory-promotion.js'
41
+
42
+ /**
43
+ * The categories that make a run worth remembering.
44
+ *
45
+ * Ordered as they are rendered. `userRequirements` first because it is the
46
+ * most durable of the five — a constraint the user stated outlives the run
47
+ * that heard it, whereas a discovery about a codebase expires when the
48
+ * codebase moves.
49
+ */
50
+ const KNOWLEDGE = [
51
+ ['userRequirements', 'What the user requires'],
52
+ ['decisions', 'Decisions'],
53
+ ['discoveries', 'Discoveries'],
54
+ ['failures', 'What did not work'],
55
+ ['environment', 'Environment'],
56
+ ] as const satisfies readonly (readonly [keyof RunMemoryCandidate, string])[]
57
+
58
+ /** Tag every record this promoter writes, so a host can find or prune them. */
59
+ export const RUN_MEMORY_TAG = 'run-memory'
60
+
61
+ export interface MemoryPromoterOptions {
62
+ /** Where records go. The same store `save_memory` writes through. */
63
+ readonly store: MemoryStore
64
+ /**
65
+ * Extra tags on every record, beyond {@link RUN_MEMORY_TAG}.
66
+ *
67
+ * A host running several agents against one store uses this to tell whose
68
+ * memory is whose; without it a later search cannot.
69
+ */
70
+ readonly tags?: readonly string[]
71
+ /**
72
+ * Cap on entries rendered per category. Defaults to 20.
73
+ *
74
+ * The extractor already caps its lists, and this is the second cap for
75
+ * the same reason the first exists: a record nobody will read is a record
76
+ * that costs context every time it is retrieved.
77
+ */
78
+ readonly maxPerCategory?: number
79
+ }
80
+
81
+ /** Everything the candidate knows, as `[heading, items]`, empties dropped. */
82
+ function knowledge(
83
+ candidate: RunMemoryCandidate,
84
+ cap: number,
85
+ ): readonly (readonly [string, readonly string[]])[] {
86
+ const out: (readonly [string, readonly string[]])[] = []
87
+ for (const [key, heading] of KNOWLEDGE) {
88
+ const items = candidate[key] as readonly string[]
89
+ if (items.length > 0) out.push([heading, items.slice(0, cap)])
90
+ }
91
+ return out
92
+ }
93
+
94
+ /** A one-line summary naming what kind of knowledge the record holds. */
95
+ function summarize(sections: readonly (readonly [string, readonly string[]])[]): string {
96
+ return sections.map(([heading, items]) => `${heading.toLowerCase()} (${items.length})`).join(', ')
97
+ }
98
+
99
+ function render(
100
+ candidate: RunMemoryCandidate,
101
+ sections: readonly (readonly [string, readonly string[]])[],
102
+ ): string {
103
+ const body = sections.map(
104
+ ([heading, items]) => `## ${heading}\n\n${items.map((i) => `- ${i}`).join('\n')}`,
105
+ )
106
+ // The eviction counts, when there are any. Carried rather than hidden for
107
+ // the reason the candidate carries them: somebody reading this record
108
+ // should know they are reading a truncated account of the run, not a
109
+ // complete one.
110
+ const evicted = Object.entries(candidate.evicted).filter(([, n]) => n > 0)
111
+ if (evicted.length > 0) {
112
+ body.push(
113
+ `## Dropped during the run\n\n${evicted
114
+ .map(([category, n]) => `- ${category}: ${n} entr${n === 1 ? 'y' : 'ies'} evicted`)
115
+ .join('\n')}`,
116
+ )
117
+ }
118
+ if (candidate.files.length > 0) {
119
+ body.push(`## Files touched\n\n${candidate.files.map((f) => `- ${f}`).join('\n')}`)
120
+ }
121
+ return `# ${candidate.task}\n\n${body.join('\n\n')}\n`
122
+ }
123
+
124
+ /**
125
+ * Build a promoter that writes one record per run that learned something.
126
+ *
127
+ * Never throws out to the runtime — but it does not swallow either: the
128
+ * runtime already catches and logs a promoter's failure at settle, and
129
+ * catching here as well would hide a broken store from the one place that
130
+ * reports it.
131
+ */
132
+ export function createMemoryPromoter(options: MemoryPromoterOptions): PromoteMemory {
133
+ const cap = options.maxPerCategory ?? 20
134
+ const tags = [RUN_MEMORY_TAG, ...(options.tags ?? [])]
135
+
136
+ return async (candidate: RunMemoryCandidate): Promise<void> => {
137
+ const sections = knowledge(candidate, cap)
138
+ // Nothing learned, nothing written. Not an empty record: a store full
139
+ // of rows describing runs that discovered nothing is a store whose
140
+ // search results are mostly noise, and the model reads that store.
141
+ if (sections.length === 0) return
142
+
143
+ await options.store.create({
144
+ title: candidate.task.trim() || `Run ${candidate.runId}`,
145
+ summary: summarize(sections),
146
+ content: render(candidate, sections),
147
+ tags,
148
+ format: 'markdown',
149
+ // The run id, so a record can be traced back to the run that formed
150
+ // it. Evidence rather than decoration: without it a surprising
151
+ // memory cannot be checked against what actually happened.
152
+ metadata: { runId: candidate.runId, source: RUN_MEMORY_TAG },
153
+ })
154
+ }
155
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * A hash of everything a run could have changed in its working tree.
3
+ *
4
+ * It exists to answer one question, asked between two attempts at the same
5
+ * verification: **did anything happen since it last failed?** A verify-then-fix
6
+ * loop that re-runs the build after a turn which edited nothing spends a full
7
+ * command execution to learn what a comparison already knew, and does it once
8
+ * per remaining attempt — so a model that has stopped making progress burns
9
+ * the entire budget confirming the same failure.
10
+ *
11
+ * ## What is hashed, and why each part
12
+ *
13
+ * Three sources, because no one of them is complete:
14
+ *
15
+ * 1. **`git status --porcelain`** — which paths differ from the index at all.
16
+ * Cheap, and it catches additions, deletions and mode changes. On its own
17
+ * it is not enough: editing a tracked file that was ALREADY modified
18
+ * leaves the status output byte-identical.
19
+ * 2. **`git diff --binary HEAD`** — the content of every tracked change.
20
+ * `--binary` so an edit to a file git treats as binary is a real diff
21
+ * rather than the constant line `Binary files … differ`, which would make
22
+ * every edit to such a file invisible.
23
+ * 3. **Untracked file contents**, which no `git diff` covers. A new file is
24
+ * named by `status` but its CONTENT is not, so successive edits to a
25
+ * brand-new file would otherwise look like no change at all.
26
+ *
27
+ * ### Symlinks are recorded as their target, not read through
28
+ *
29
+ * Reading a link follows it, so a link repointed from one file to another
30
+ * with identical contents hashes the same — while the thing the workspace
31
+ * actually resolves has changed. The link's target path is the fact that
32
+ * moved, so that is what goes in.
33
+ *
34
+ * ## Failing open, on the cheap side
35
+ *
36
+ * Every uncertainty returns `null`, meaning *no fingerprint*, and a caller
37
+ * that cannot fingerprint re-runs its command. That is the correct direction:
38
+ * the cost of a wrong `null` is one command execution, and the cost of a
39
+ * wrong MATCH is a verification silently skipped — the loop would report
40
+ * "nothing changed" about a workspace that did change, and the model would be
41
+ * told to edit something it had already edited.
42
+ *
43
+ * So: a non-zero exit from any git invocation, a repository with no commits,
44
+ * a timeout, or output past the size cap all produce `null` rather than a
45
+ * partial hash. A truncated diff that hashed successfully would be the worst
46
+ * outcome available here, because two different workspaces truncated at the
47
+ * same point collide.
48
+ */
49
+
50
+ import { createHash } from 'node:crypto'
51
+ import { lstat, readFile, readlink } from 'node:fs/promises'
52
+ import { join } from 'node:path'
53
+
54
+ import type { CommandOptions, CommandResult } from '../types/execution/index.js'
55
+
56
+ /** How a fingerprint runs git. Injected so a test needs no repository. */
57
+ export type FingerprintExec = (
58
+ command: string,
59
+ args: string[],
60
+ options?: CommandOptions,
61
+ ) => Promise<CommandResult>
62
+
63
+ /**
64
+ * The three filesystem reads an untracked entry needs.
65
+ *
66
+ * Injectable for one specific reason, written down because a seam that
67
+ * exists only for tests is usually a smell: **creating a symlink requires a
68
+ * privilege that is not granted by default on Windows**, so the symlink rule
69
+ * below — the one that says a repointed link changes the fingerprint even
70
+ * when the bytes behind it do not — cannot be exercised on a developer
71
+ * machine without it. A rule that can only be checked on some machines is a
72
+ * rule nobody checks.
73
+ *
74
+ * The default is `node:fs/promises` and every other test uses it against a
75
+ * real repository, so this is not a fixture standing in for production; it is
76
+ * one branch of one function reached without a privilege.
77
+ */
78
+ export interface FingerprintFs {
79
+ lstat(path: string): Promise<{ isSymbolicLink(): boolean; isFile(): boolean }>
80
+ readlink(path: string): Promise<string>
81
+ readFile(path: string): Promise<Buffer>
82
+ }
83
+
84
+ const NODE_FS: FingerprintFs = { lstat, readlink, readFile }
85
+
86
+ /**
87
+ * Cap on the bytes any single git invocation may produce.
88
+ *
89
+ * Past it the fingerprint is abandoned rather than hashed. A diff big enough
90
+ * to hit this is a diff nobody is going to iterate on anyway, and hashing a
91
+ * clipped one would let two different trees agree.
92
+ */
93
+ export const FINGERPRINT_MAX_BYTES = 4 * 1024 * 1024
94
+
95
+ /** Default deadline per git invocation. */
96
+ export const FINGERPRINT_TIMEOUT_MS = 20_000
97
+
98
+ export interface WorkspaceFingerprintOptions {
99
+ /** Repository root, or any directory inside it. */
100
+ readonly cwd: string
101
+ /** How to run git. */
102
+ readonly exec: FingerprintExec
103
+ /** Per-invocation deadline. See {@link FINGERPRINT_TIMEOUT_MS}. */
104
+ readonly timeoutMs?: number
105
+ /** See {@link FINGERPRINT_MAX_BYTES}. */
106
+ readonly maxBytes?: number
107
+ /** Filesystem reads. See {@link FingerprintFs}. */
108
+ readonly fs?: FingerprintFs
109
+ }
110
+
111
+ /** One untracked path's contribution, or `null` when it could not be read. */
112
+ async function untrackedEntry(cwd: string, rel: string, fs: FingerprintFs): Promise<string | null> {
113
+ const abs = join(cwd, rel)
114
+ try {
115
+ const stats = await fs.lstat(abs)
116
+ if (stats.isSymbolicLink()) {
117
+ // The TARGET, not what is behind it. Following the link would hash a
118
+ // repointed link to the same value whenever the new target happens
119
+ // to hold the same bytes, and a repoint is a change to the workspace
120
+ // by any reading that matters.
121
+ return `L ${rel}\0${await fs.readlink(abs)}`
122
+ }
123
+ if (!stats.isFile()) return `? ${rel}`
124
+ const body = await fs.readFile(abs)
125
+ return `F ${rel}\0${createHash('sha256').update(body).digest('hex')}`
126
+ } catch {
127
+ // Vanished between the listing and the read, or unreadable. Neither is
128
+ // a fingerprint this function may guess at.
129
+ return null
130
+ }
131
+ }
132
+
133
+ /**
134
+ * A hash of the working tree's uncommitted state, or `null` when it cannot be
135
+ * established.
136
+ *
137
+ * **`null` is never "unchanged".** It means "I cannot tell", and the caller
138
+ * must treat it as a reason to do the work rather than to skip it.
139
+ */
140
+ export async function fingerprintWorkspace(
141
+ options: WorkspaceFingerprintOptions,
142
+ ): Promise<string | null> {
143
+ const { cwd, exec } = options
144
+ const timeoutMs = options.timeoutMs ?? FINGERPRINT_TIMEOUT_MS
145
+ const maxBytes = options.maxBytes ?? FINGERPRINT_MAX_BYTES
146
+ const fs = options.fs ?? NODE_FS
147
+
148
+ const git = async (args: string[]): Promise<string | null> => {
149
+ let result: CommandResult
150
+ try {
151
+ result = await exec('git', args, { cwd, timeoutMs })
152
+ } catch {
153
+ return null
154
+ }
155
+ // A timeout surfaces here as a non-zero exit, and so does "not a
156
+ // repository" and "no commits yet". All three mean the same thing to
157
+ // this function: it has no basis for a comparison.
158
+ if (result.exitCode !== 0) return null
159
+ if (Buffer.byteLength(result.stdout, 'utf8') > maxBytes) return null
160
+ return result.stdout
161
+ }
162
+
163
+ const status = await git(['status', '--porcelain'])
164
+ if (status === null) return null
165
+
166
+ const diff = await git(['diff', '--binary', 'HEAD'])
167
+ if (diff === null) return null
168
+
169
+ const untracked = await git(['ls-files', '--others', '--exclude-standard', '-z'])
170
+ if (untracked === null) return null
171
+
172
+ const parts = [`status ${status}`, `diff ${diff}`]
173
+ // Split on NUL, which is what `-z` is for: a path may contain a newline,
174
+ // and splitting on one would turn a single strange filename into two
175
+ // ordinary-looking ones.
176
+ //
177
+ // Sorted, because `ls-files` order is not part of any contract and a
178
+ // fingerprint that moved when the listing order did would report a change
179
+ // nobody made.
180
+ for (const rel of untracked.split('\0').filter(Boolean).sort()) {
181
+ const entry = await untrackedEntry(cwd, rel, fs)
182
+ if (entry === null) return null
183
+ parts.push(entry)
184
+ }
185
+
186
+ // Length-prefixed rather than delimiter-joined. A diff can contain any
187
+ // byte, so any separator is a separator the content can forge — and two
188
+ // different trees that agreed after forgery would be reported as
189
+ // unchanged, which is the one wrong answer this file is arranged to avoid.
190
+ const hash = createHash('sha256')
191
+ for (const part of parts) hash.update(`${Buffer.byteLength(part, 'utf8')}:${part}`)
192
+ return hash.digest('hex')
193
+ }