@namzu/sdk 53.2.2 → 54.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 (111) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/compaction/context-breakdown.d.ts +85 -0
  3. package/dist/compaction/context-breakdown.d.ts.map +1 -0
  4. package/dist/compaction/context-breakdown.js +149 -0
  5. package/dist/compaction/context-breakdown.js.map +1 -0
  6. package/dist/compaction/token-estimate.d.ts +2 -0
  7. package/dist/compaction/token-estimate.d.ts.map +1 -1
  8. package/dist/compaction/token-estimate.js +4 -0
  9. package/dist/compaction/token-estimate.js.map +1 -1
  10. package/dist/public-runtime.d.ts +7 -2
  11. package/dist/public-runtime.d.ts.map +1 -1
  12. package/dist/public-runtime.js +6 -1
  13. package/dist/public-runtime.js.map +1 -1
  14. package/dist/runtime/cache-clock.d.ts +39 -0
  15. package/dist/runtime/cache-clock.d.ts.map +1 -0
  16. package/dist/runtime/cache-clock.js +48 -0
  17. package/dist/runtime/cache-clock.js.map +1 -0
  18. package/dist/runtime/query/compact-session.d.ts +37 -2
  19. package/dist/runtime/query/compact-session.d.ts.map +1 -1
  20. package/dist/runtime/query/compact-session.js +77 -3
  21. package/dist/runtime/query/compact-session.js.map +1 -1
  22. package/dist/runtime/query/executor/tool-call-admission.d.ts.map +1 -1
  23. package/dist/runtime/query/executor/tool-call-admission.js +3 -0
  24. package/dist/runtime/query/executor/tool-call-admission.js.map +1 -1
  25. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  26. package/dist/runtime/query/iteration/index.js +8 -0
  27. package/dist/runtime/query/iteration/index.js.map +1 -1
  28. package/dist/store/budget/disk.js +4 -1
  29. package/dist/store/budget/disk.js.map +1 -1
  30. package/dist/tools/admit-facts.d.ts.map +1 -1
  31. package/dist/tools/admit-facts.js +6 -0
  32. package/dist/tools/admit-facts.js.map +1 -1
  33. package/dist/tools/builtins/bash.d.ts.map +1 -1
  34. package/dist/tools/builtins/bash.js +23 -9
  35. package/dist/tools/builtins/bash.js.map +1 -1
  36. package/dist/tools/builtins/edit-apply.d.ts +7 -4
  37. package/dist/tools/builtins/edit-apply.d.ts.map +1 -1
  38. package/dist/tools/builtins/edit-apply.js +3 -0
  39. package/dist/tools/builtins/edit-apply.js.map +1 -1
  40. package/dist/tools/builtins/edit.d.ts.map +1 -1
  41. package/dist/tools/builtins/edit.js +27 -7
  42. package/dist/tools/builtins/edit.js.map +1 -1
  43. package/dist/tools/builtins/exit-semantics.d.ts +31 -0
  44. package/dist/tools/builtins/exit-semantics.d.ts.map +1 -0
  45. package/dist/tools/builtins/exit-semantics.js +198 -0
  46. package/dist/tools/builtins/exit-semantics.js.map +1 -0
  47. package/dist/tools/builtins/write-file.js +7 -2
  48. package/dist/tools/builtins/write-file.js.map +1 -1
  49. package/dist/tools/memory/delete.js +1 -1
  50. package/dist/tools/memory/delete.js.map +1 -1
  51. package/dist/tools/memory/read.js +1 -1
  52. package/dist/tools/memory/read.js.map +1 -1
  53. package/dist/tools/memory/save.d.ts.map +1 -1
  54. package/dist/tools/memory/save.js +4 -3
  55. package/dist/tools/memory/save.js.map +1 -1
  56. package/dist/tools/memory/search.js +1 -1
  57. package/dist/tools/memory/search.js.map +1 -1
  58. package/dist/tools/memory/update.js +1 -1
  59. package/dist/tools/memory/update.js.map +1 -1
  60. package/dist/toolsets/manager.d.ts.map +1 -1
  61. package/dist/toolsets/manager.js +6 -1
  62. package/dist/toolsets/manager.js.map +1 -1
  63. package/dist/types/common/index.d.ts +19 -0
  64. package/dist/types/common/index.d.ts.map +1 -1
  65. package/dist/types/common/index.js +9 -0
  66. package/dist/types/common/index.js.map +1 -1
  67. package/dist/types/provider/chat.d.ts +8 -0
  68. package/dist/types/provider/chat.d.ts.map +1 -1
  69. package/dist/types/provider/config.d.ts +6 -0
  70. package/dist/types/provider/config.d.ts.map +1 -1
  71. package/dist/types/session/checkpoint.d.ts +52 -0
  72. package/dist/types/session/checkpoint.d.ts.map +1 -1
  73. package/dist/types/session/checkpoint.js +5 -0
  74. package/dist/types/session/checkpoint.js.map +1 -1
  75. package/dist/types/session/events.d.ts +9 -0
  76. package/dist/types/session/events.d.ts.map +1 -1
  77. package/dist/types/session/events.js.map +1 -1
  78. package/dist/types/session/records.d.ts +181 -6
  79. package/dist/types/session/records.d.ts.map +1 -1
  80. package/dist/types/session/records.js +1 -1
  81. package/dist/types/session/records.js.map +1 -1
  82. package/dist/types/tool/presentation.d.ts +17 -1
  83. package/dist/types/tool/presentation.d.ts.map +1 -1
  84. package/package.json +1 -1
  85. package/src/compaction/context-breakdown.ts +228 -0
  86. package/src/compaction/token-estimate.ts +5 -0
  87. package/src/public-runtime.ts +23 -2
  88. package/src/runtime/cache-clock.ts +67 -0
  89. package/src/runtime/query/compact-session.ts +101 -3
  90. package/src/runtime/query/executor/tool-call-admission.ts +3 -0
  91. package/src/runtime/query/iteration/index.ts +8 -0
  92. package/src/store/budget/disk.ts +4 -1
  93. package/src/tools/admit-facts.ts +6 -0
  94. package/src/tools/builtins/bash.ts +25 -9
  95. package/src/tools/builtins/edit-apply.ts +12 -2
  96. package/src/tools/builtins/edit.ts +34 -8
  97. package/src/tools/builtins/exit-semantics.ts +198 -0
  98. package/src/tools/builtins/write-file.ts +7 -2
  99. package/src/tools/memory/delete.ts +1 -1
  100. package/src/tools/memory/read.ts +1 -1
  101. package/src/tools/memory/save.ts +4 -3
  102. package/src/tools/memory/search.ts +1 -1
  103. package/src/tools/memory/update.ts +1 -1
  104. package/src/toolsets/manager.ts +6 -1
  105. package/src/types/common/index.ts +27 -0
  106. package/src/types/provider/chat.ts +8 -0
  107. package/src/types/provider/config.ts +6 -0
  108. package/src/types/session/checkpoint.ts +5 -0
  109. package/src/types/session/events.ts +6 -0
  110. package/src/types/session/records.ts +1 -1
  111. package/src/types/tool/presentation.ts +16 -0
@@ -23,6 +23,7 @@ import { defineTool } from '../defineTool.js'
23
23
  import { scrubInheritedEnv } from '../env-scrub.js'
24
24
  import { createHostOutputDecoder } from '../host-output.js'
25
25
  import { looksBinary, windowText } from '../text-window.js'
26
+ import { judgeExit } from './exit-semantics.js'
26
27
  import { jsonStringEscapes } from './json-string-hint.js'
27
28
 
28
29
  // Namzu owns its own bash timeout knob — `NAMZU_BASH_TIMEOUT_MS` — and
@@ -669,11 +670,14 @@ const BashToolBase = defineTool({
669
670
  .filter(Boolean)
670
671
  .join('\n\n')
671
672
 
673
+ const judged = result.timedOut ? undefined : judgeExit(input.command, result.exitCode)
674
+ const answered = judged !== undefined && !judged.isError
672
675
  return {
673
- success: !result.timedOut && result.exitCode === 0,
676
+ success: answered,
674
677
  output: output || '(no output)',
675
678
  data: {
676
679
  exitCode: result.exitCode,
680
+ ...(judged?.note ? { exitNote: judged.note } : {}),
677
681
  sandboxed: true,
678
682
  timedOut: result.timedOut,
679
683
  stdoutTruncated: result.stdoutTruncated ?? false,
@@ -681,12 +685,13 @@ const BashToolBase = defineTool({
681
685
  },
682
686
  error: result.timedOut
683
687
  ? `Command timed out after ${input.timeout}ms. Any captured output before the deadline is above.`
684
- : result.exitCode !== 0
688
+ : !answered
685
689
  ? `Command exited with code ${result.exitCode}`
686
690
  : undefined,
687
691
  facts: commandFacts({
688
692
  command: input.command,
689
693
  exitCode: result.exitCode,
694
+ ...(judged?.note ? { exitNote: judged.note } : {}),
690
695
  timedOut: result.timedOut,
691
696
  stdout: result.stdout,
692
697
  stderr: result.stderr,
@@ -801,35 +806,44 @@ const BashToolBase = defineTool({
801
806
  // and appending this to every result would make the common case noisy
802
807
  // to buy nothing. A failing one is exactly where "authentication
803
808
  // failed" has to be distinguishable from "the variable was withheld".
809
+ // A status the command uses to answer (no match, files differ) is a
810
+ // result, not a failure; the model sees it as an ordinary call.
811
+ const judged =
812
+ !timedOut && exitCode !== undefined ? judgeExit(input.command, exitCode) : undefined
813
+ const answered = judged !== undefined && !judged.isError
804
814
  const output = [
805
815
  formatShellOutput(failure.stdout, failure.stderr),
806
816
  clipped.length > 0
807
817
  ? `[${clipped.join(' and ')} ${clipped.length === 1 ? 'was' : 'were'} truncated by the host output cap. The omitted output is unavailable in this result.]`
808
818
  : '',
809
- describeWithheldEnv(inherited.dropped),
819
+ answered ? '' : describeWithheldEnv(inherited.dropped),
810
820
  ]
811
821
  .filter(Boolean)
812
822
  .join('\n\n')
813
823
 
814
824
  return {
815
- success: false,
825
+ success: answered,
816
826
  output: output || '(no output)',
817
827
  data: {
818
828
  ...(exitCode !== undefined ? { exitCode } : {}),
829
+ ...(judged?.note ? { exitNote: judged.note } : {}),
819
830
  ...(leaveSandbox ? { sandboxed: false, sandboxEscape: true } : {}),
820
831
  timedOut,
821
832
  stdoutTruncated: failure.stdoutTruncated ?? false,
822
833
  stderrTruncated: failure.stderrTruncated ?? false,
823
834
  ...(failure.signal ? { signal: failure.signal } : {}),
824
835
  },
825
- error: timedOut
826
- ? `Command timed out after ${input.timeout}ms. Any output it produced before the deadline is above.`
827
- : exitCode !== undefined
828
- ? `Command exited with code ${exitCode}`
829
- : `Command failed: ${failure.message}`,
836
+ error: answered
837
+ ? undefined
838
+ : timedOut
839
+ ? `Command timed out after ${input.timeout}ms. Any output it produced before the deadline is above.`
840
+ : exitCode !== undefined
841
+ ? `Command exited with code ${exitCode}`
842
+ : `Command failed: ${failure.message}`,
830
843
  facts: commandFacts({
831
844
  command: input.command,
832
845
  ...(exitCode !== undefined ? { exitCode } : {}),
846
+ ...(judged?.note ? { exitNote: judged.note } : {}),
833
847
  timedOut,
834
848
  ...(failure.signal ? { signal: failure.signal } : {}),
835
849
  stdout: failure.stdout ?? '',
@@ -851,6 +865,7 @@ const BashToolBase = defineTool({
851
865
  function commandFacts(input: {
852
866
  command: string
853
867
  exitCode?: number
868
+ exitNote?: string
854
869
  timedOut?: boolean
855
870
  signal?: string
856
871
  stdout: string
@@ -867,6 +882,7 @@ function commandFacts(input: {
867
882
  kind: 'command',
868
883
  command: input.command,
869
884
  ...(input.exitCode !== undefined ? { exitCode: input.exitCode } : {}),
885
+ ...(input.exitNote ? { exitNote: input.exitNote } : {}),
870
886
  ...(input.timedOut ? { timedOut: true } : {}),
871
887
  ...(input.signal ? { signal: input.signal } : {}),
872
888
  ...(input.stdout && !outBinary ? { stdout: windowText(input.stdout, outBytes) } : {}),
@@ -130,6 +130,13 @@ function normalizeInsertLine(
130
130
  return { success: true, value }
131
131
  }
132
132
 
133
+ /** A refused edit, with the kind of refusal when it is one a host can phrase. */
134
+ export type EditApplyFailure = {
135
+ success: false
136
+ error: string
137
+ reason?: 'text_not_found' | 'not_unique'
138
+ }
139
+
133
140
  /**
134
141
  * Apply every operation in order, or none of them.
135
142
  *
@@ -149,7 +156,7 @@ function normalizeInsertLine(
149
156
  export function applyEdit(
150
157
  content: string,
151
158
  operations: readonly NormalizedEditInput[],
152
- ): { success: true; content: string; replacements: number } | { success: false; error: string } {
159
+ ): { success: true; content: string; replacements: number } | EditApplyFailure {
153
160
  let current = content
154
161
  let replacements = 0
155
162
 
@@ -159,6 +166,7 @@ export function applyEdit(
159
166
  return {
160
167
  success: false,
161
168
  error: framedError(result.error, index, operations.length),
169
+ ...(result.reason ? { reason: result.reason } : {}),
162
170
  }
163
171
  }
164
172
  current = result.content
@@ -171,7 +179,7 @@ export function applyEdit(
171
179
  function applyOne(
172
180
  content: string,
173
181
  input: NormalizedEditInput,
174
- ): { success: true; content: string; replacements: number } | { success: false; error: string } {
182
+ ): { success: true; content: string; replacements: number } | EditApplyFailure {
175
183
  if (input.operation === 'insert') {
176
184
  return applyLineInsert(content, input)
177
185
  }
@@ -191,6 +199,7 @@ function applyOne(
191
199
  if (resolved === null) {
192
200
  return {
193
201
  success: false,
202
+ reason: 'text_not_found',
194
203
  error:
195
204
  'old_string/oldStr not found in file. Make sure the string matches exactly, including whitespace and indentation.',
196
205
  }
@@ -225,6 +234,7 @@ function applyOne(
225
234
  const secondLine = content.slice(0, secondIndex).split('\n').length
226
235
  return {
227
236
  success: false,
237
+ reason: 'not_unique',
228
238
  error: `old_string/oldStr is not unique — found at lines ${lineNumber} and ${secondLine}. Provide more surrounding context to make it unique, or use replace_all: true.`,
229
239
  }
230
240
  }
@@ -2,12 +2,18 @@ import { readFile } from 'node:fs/promises'
2
2
 
3
3
  import { z } from 'zod'
4
4
  import type { ToolContext, ToolResult } from '../../types/tool/index.js'
5
+ import type { ToolFacts } from '../../types/tool/presentation.js'
5
6
  import { defineTool } from '../defineTool.js'
6
7
  import { resolveWithinAnyReal, toolRoots } from '../paths.js'
7
8
  import { atomicWriteFile } from './atomic-write-file.js'
8
9
  import { clip, describeChange } from './classifier-input.js'
9
10
  import { fingerprintContent, staleFileError, unreadFileError } from './content-fingerprint.js'
10
- import { type NormalizedEditInput, applyEdit, normalizeEditInput } from './edit-apply.js'
11
+ import {
12
+ type EditApplyFailure,
13
+ type NormalizedEditInput,
14
+ applyEdit,
15
+ normalizeEditInput,
16
+ } from './edit-apply.js'
11
17
  import { withFileMutationLock } from './file-mutation-lock.js'
12
18
  import { jsonStringEscapes } from './json-string-hint.js'
13
19
  import { lineDiff } from './line-diff.js'
@@ -334,7 +340,7 @@ function recordDrift(context: ToolContext, key: string): void {
334
340
  }
335
341
 
336
342
  type EditAdmission =
337
- | { success: false; output: ''; error: string }
343
+ | { success: false; output: ''; error: string; facts: ToolFacts }
338
344
  | { success: true; result: ReturnType<typeof applyEdit> }
339
345
 
340
346
  /**
@@ -359,7 +365,12 @@ function admitAgainstRead(
359
365
  ): EditAdmission {
360
366
  const tracker = context.fileReadTracker
361
367
  if (tracker && !tracker.hasRead(key)) {
362
- return { success: false, output: '', error: unreadFileError(key, 'edit') }
368
+ return {
369
+ success: false,
370
+ output: '',
371
+ error: unreadFileError(key, 'edit'),
372
+ facts: { kind: 'failure', reason: 'not_read', subject: key },
373
+ }
363
374
  }
364
375
  const seen = tracker?.fingerprint?.(key)
365
376
  const applied = applyEdit(content, operations)
@@ -367,7 +378,12 @@ function admitAgainstRead(
367
378
  const recoverable = applied.success && operations.every((o) => o.operation === 'replace')
368
379
  if (!recoverable) {
369
380
  recordDrift(context, key)
370
- return { success: false, output: '', error: staleFileError(key) }
381
+ return {
382
+ success: false,
383
+ output: '',
384
+ error: staleFileError(key),
385
+ facts: { kind: 'failure', reason: 'changed_on_disk', subject: key },
386
+ }
371
387
  }
372
388
  }
373
389
  return { success: true, result: applied }
@@ -397,6 +413,18 @@ function editFacts(path: string, replacements: number, before: string, after: st
397
413
  }
398
414
  }
399
415
 
416
+ /** A refused edit, with the kind of refusal when the text said it. */
417
+ function refusedEdit(result: EditApplyFailure, path: string) {
418
+ return {
419
+ success: false as const,
420
+ output: '' as const,
421
+ error: result.error,
422
+ ...(result.reason
423
+ ? { facts: { kind: 'failure' as const, reason: result.reason, subject: path } }
424
+ : {}),
425
+ }
426
+ }
427
+
400
428
  export const EditTool = defineTool({
401
429
  name: 'edit',
402
430
  description:
@@ -529,9 +557,7 @@ export const EditTool = defineTool({
529
557
  const admitted = admitAgainstRead(context, parsed.data.path, content, normalized.operations)
530
558
  if (!admitted.success) return admitted
531
559
  const result = admitted.result
532
- if (!result.success) {
533
- return { success: false as const, output: '', error: result.error }
534
- }
560
+ if (!result.success) return refusedEdit(result, parsed.data.path)
535
561
 
536
562
  await context.sandbox.writeFile(parsed.data.path, result.content)
537
563
  recordEditedContent(context, parsed.data.path, result.content)
@@ -554,7 +580,7 @@ export const EditTool = defineTool({
554
580
  const admitted = admitAgainstRead(context, hostPath, content, normalized.operations)
555
581
  if (!admitted.success) return admitted
556
582
  const result = admitted.result
557
- if (!result.success) return { success: false as const, output: '', error: result.error }
583
+ if (!result.success) return refusedEdit(result, hostPath)
558
584
 
559
585
  // Temp file, fsync, rename — a reader sees the old body or the new
560
586
  // one, never a half-written one. A plain `writeFile` that fails
@@ -0,0 +1,198 @@
1
+ /**
2
+ * How a shell command's exit code is judged.
3
+ *
4
+ * Some programs use a non-zero status to answer a question rather than to
5
+ * report a failure: `grep` exits 1 when nothing matched, `diff` exits 1 when
6
+ * the files differ, `test` exits 1 when the condition is false. Reading those
7
+ * as failures turns a normal answer into a red error and sends the model off
8
+ * to "fix" it. Every other command keeps the plain rule: non-zero is a failure.
9
+ *
10
+ * The command is judged by the last command on its line (the one whose status
11
+ * the shell reports), but only when nothing earlier in an `&&` chain could
12
+ * have supplied that status: after `cd missing && grep x f` a 1 may come from
13
+ * `cd`, so the line is judged by the plain rule.
14
+ */
15
+
16
+ /** What an exit code means for one command line. */
17
+ export interface ExitJudgement {
18
+ /** The command failed. */
19
+ readonly isError: boolean
20
+ /** A short plain sentence for a non-zero status that is not a failure. */
21
+ readonly note?: string
22
+ }
23
+
24
+ /** Longest line the splitter reads; a longer one gets the plain rule. */
25
+ const MAX_COMMAND_CHARS = 10_000
26
+
27
+ const PLAIN_FAILURE: ExitJudgement = { isError: true }
28
+
29
+ interface Parsed {
30
+ /** Command texts in order, with the separator that preceded each. */
31
+ readonly segments: readonly string[]
32
+ readonly separators: readonly string[]
33
+ }
34
+
35
+ /**
36
+ * Cut a line at unquoted `|`, `|&`, `||`, `&&`, `;`, `&` and newlines.
37
+ * Undefined when the line has a shape this reading does not follow (an open
38
+ * quote, a subshell or group, a here-document): the caller then keeps the
39
+ * plain rule, which is the safe reading.
40
+ */
41
+ function split(command: string): Parsed | undefined {
42
+ const segments: string[] = []
43
+ const separators: string[] = []
44
+ let current = ''
45
+ let pending = ''
46
+ let quote: '"' | "'" | null = null
47
+ const push = (): void => {
48
+ segments.push(current)
49
+ separators.push(pending)
50
+ current = ''
51
+ }
52
+ for (let i = 0; i < command.length; i++) {
53
+ const ch = command.charAt(i)
54
+ const next = command.charAt(i + 1)
55
+ if (quote === "'") {
56
+ if (ch === "'") quote = null
57
+ current += ch
58
+ continue
59
+ }
60
+ if (ch === '\\') {
61
+ current += ch + next
62
+ i += 1
63
+ continue
64
+ }
65
+ if (ch === '`') {
66
+ const end = command.indexOf('`', i + 1)
67
+ if (end === -1) return undefined
68
+ current += command.slice(i, end + 1)
69
+ i = end
70
+ continue
71
+ }
72
+ if (ch === '$' && (next === '(' || next === '{')) {
73
+ const close = next === '(' ? ')' : '}'
74
+ let depth = 0
75
+ let j = i + 1
76
+ for (; j < command.length; j++) {
77
+ const c = command.charAt(j)
78
+ if (c === next) depth += 1
79
+ else if (c === close && --depth === 0) break
80
+ }
81
+ if (j >= command.length) return undefined
82
+ current += command.slice(i, j + 1)
83
+ i = j
84
+ continue
85
+ }
86
+ if (quote === '"') {
87
+ if (ch === '"') quote = null
88
+ current += ch
89
+ continue
90
+ }
91
+ if (ch === '"' || ch === "'") {
92
+ quote = ch
93
+ current += ch
94
+ continue
95
+ }
96
+ if (ch === '(' || ch === ')') return undefined
97
+ if (ch === '<' && next === '<' && command.charAt(i + 2) !== '<') return undefined
98
+ if (ch === '>' && next === '&') {
99
+ // `2>&1` and `>&2` are redirects, not a background `&`.
100
+ current += ch + next
101
+ i += 1
102
+ continue
103
+ }
104
+ if (ch === '&' && command.charAt(i - 1) === '>') {
105
+ current += ch
106
+ continue
107
+ }
108
+ if (ch === '|' || ch === '&' || ch === ';' || ch === '\n') {
109
+ let sep = ch
110
+ if ((ch === '|' || ch === '&') && next === ch) {
111
+ sep = ch + ch
112
+ i += 1
113
+ } else if (ch === '|' && next === '&') {
114
+ sep = '|&'
115
+ i += 1
116
+ }
117
+ push()
118
+ pending = sep === '\n' ? ';' : sep
119
+ continue
120
+ }
121
+ current += ch
122
+ }
123
+ if (quote !== null) return undefined
124
+ push()
125
+ // A trailing separator (`a;`) leaves an empty last segment: drop it.
126
+ const kept: string[] = []
127
+ const keptSeparators: string[] = []
128
+ segments.forEach((segment, index) => {
129
+ if (segment.trim().length === 0) return
130
+ kept.push(segment.trim())
131
+ keptSeparators.push(separators[index] ?? '')
132
+ })
133
+ return kept.length === 0 ? undefined : { segments: kept, separators: keptSeparators }
134
+ }
135
+
136
+ /**
137
+ * Whether an earlier command in an `&&` chain could be the one whose status
138
+ * the line ended with: the last pipeline is joined to what precedes it by `&&`.
139
+ */
140
+ function followsAndChain(parsed: Parsed): boolean {
141
+ let index = parsed.segments.length - 1
142
+ // Walk back over the pipeline the last command belongs to.
143
+ while (index > 0 && (parsed.separators[index] === '|' || parsed.separators[index] === '|&')) {
144
+ index -= 1
145
+ }
146
+ return parsed.separators[index] === '&&'
147
+ }
148
+
149
+ const NO_MATCHES = 'No matches found'
150
+
151
+ const SEARCH_WORDS = new Set(['grep', 'rg', 'egrep', 'fgrep'])
152
+
153
+ /** The subcommand of a `git` line, skipping global options (`-C <dir>`, `-c <k=v>`). */
154
+ function gitSubcommand(words: readonly string[]): string | undefined {
155
+ if (words[0] !== 'git') return undefined
156
+ for (let i = 1; i < words.length; i++) {
157
+ const word = words[i] as string
158
+ if (word.startsWith('-')) {
159
+ if (word === '-C' || word === '-c') i += 1
160
+ continue
161
+ }
162
+ return word
163
+ }
164
+ return undefined
165
+ }
166
+
167
+ function questionNote(command: string): string | undefined {
168
+ const words = command.trim().split(/\s+/)
169
+ const first = words[0] ?? ''
170
+ if (first === 'git') {
171
+ const sub = gitSubcommand(words)
172
+ if (sub === 'diff') return 'Files differ'
173
+ if (sub === 'grep') return NO_MATCHES
174
+ return undefined
175
+ }
176
+ if (SEARCH_WORDS.has(first)) return NO_MATCHES
177
+ if (first === 'find') return 'Some directories were inaccessible'
178
+ if (first === 'diff') return 'Files differ'
179
+ if (first === 'test' || first === '[') return 'Condition is false'
180
+ return undefined
181
+ }
182
+
183
+ /**
184
+ * Judge a finished command's exit code.
185
+ *
186
+ * Exit 0 is never a failure. For `grep`, `rg`, `egrep`, `fgrep`, `find`,
187
+ * `diff`, `test`, `[`, `git diff` and `git grep` as the last command of the
188
+ * line, exit 1 is an answer (with a short note) and 2 or more is a failure.
189
+ * Anything else, or a line the reading cannot follow, fails on any non-zero.
190
+ */
191
+ export function judgeExit(command: string, exitCode: number): ExitJudgement {
192
+ if (exitCode === 0) return { isError: false }
193
+ if (exitCode !== 1 || command.length > MAX_COMMAND_CHARS) return PLAIN_FAILURE
194
+ const parsed = split(command)
195
+ if (!parsed || followsAndChain(parsed)) return PLAIN_FAILURE
196
+ const note = questionNote(parsed.segments[parsed.segments.length - 1] as string)
197
+ return note === undefined ? PLAIN_FAILURE : { isError: false, note }
198
+ }
@@ -210,7 +210,7 @@ function enforceReadBeforeOverwrite(
210
210
  success: false,
211
211
  output: '',
212
212
  error: `${key} already exists. Use the \`read\` tool on it first in this conversation, then call \`write\` again — or prefer \`edit\` for a targeted change.`,
213
- facts: { kind: 'failure', reason: 'already_exists', subject: key },
213
+ facts: { kind: 'failure', reason: 'not_read', subject: key },
214
214
  }
215
215
  }
216
216
 
@@ -232,7 +232,12 @@ function enforceFreshOverwrite(
232
232
  // so a reader that cannot touch the filesystem stops referencing this
233
233
  // path. Optional on the interface; an older tracker keeps its behavior.
234
234
  context.fileReadTracker?.recordDriftObserved?.(key)
235
- return { success: false, output: '', error: staleFileError(key, 'write') }
235
+ return {
236
+ success: false,
237
+ output: '',
238
+ error: staleFileError(key, 'write'),
239
+ facts: { kind: 'failure', reason: 'changed_on_disk', subject: key },
240
+ }
236
241
  }
237
242
  return null
238
243
  }
@@ -10,7 +10,7 @@ export function buildDeleteMemoryTool(store: MemoryStore): ToolDefinition {
10
10
  return defineTool({
11
11
  name: 'delete_memory',
12
12
  description:
13
- 'Permanently delete a stored memory by ID. Use update_memory with status archived when the record should remain available for inspection. Deletion does not erase past conversation transcripts. Pass the revision from read_memory when available to refuse a stale deletion.',
13
+ 'Permanently remove a memory by ID, for one that should not be kept at all. Use update_memory with status archived when the record should stay available to inspect. Past conversation transcripts are not changed. Pass the revision from read_memory when available, so a deletion of a memory that changed since you read it is refused. Returns a confirmation.',
14
14
  inputSchema: z.object({
15
15
  id: z.string().describe('Memory ID to permanently delete'),
16
16
  revision: z
@@ -16,7 +16,7 @@ export function buildReadMemoryTool(store: MemoryStore): ToolDefinition {
16
16
  return defineTool({
17
17
  name: 'read_memory',
18
18
  description:
19
- 'Read the full content of a specific memory by its ID or its name. For a text or Markdown memory the result ends with how old the memory is and resolves any [[name]] links it contains.',
19
+ 'Read one memory in full by its ID or its name. Use it when the index or a search shows that a memory bears on the task. Returns the memory text; a text or Markdown memory ends with how old it is and the memories its [[name]] links point to.',
20
20
  inputSchema: z.object({
21
21
  id: z
22
22
  .string()
@@ -10,11 +10,12 @@ export function buildSaveMemoryTool(store: MemoryStore): ToolDefinition {
10
10
  return defineTool({
11
11
  name: 'save_memory',
12
12
  description: [
13
- 'Save a new memory for future sessions: something true now that a later session could not work out from the code, the git history or the files themselves. Do not save what those already say.',
14
- 'Choose a type: user (who the user is and how they like to work), feedback (a rule the user gave about how to work), project (a fact or decision about the work), reference (where to look for something).',
13
+ 'Save one memory for later sessions in this project: a single fact that the code, the history and the docs do not already hold.',
14
+ 'Choose a type: user (who the person is and how they like to work), feedback (a rule they gave about how to work), project (a fact or decision about the work), reference (where to look for something).',
15
15
  'For feedback and project memories, write the rule or fact first, then a line starting "Why:" and a line starting "How to apply:".',
16
16
  'Link a related memory by writing [[its-name]] in the content.',
17
- 'If a memory on this already exists, use update_memory instead: a name another memory holds is refused.',
17
+ 'The name must be new: a name another memory holds is refused, and the reply points to update_memory.',
18
+ "Returns the saved memory's id and name.",
18
19
  ].join(' '),
19
20
  inputSchema: z.object({
20
21
  title: z.string().min(1).describe('Short descriptive title'),
@@ -16,7 +16,7 @@ function defineSearchMemoryTool(search: SearchMemory): ToolDefinition {
16
16
  return defineTool({
17
17
  name: 'search_memory',
18
18
  description:
19
- 'Search active stored memories by relevant words or tags. The built-in stores rank matches in names, titles, descriptions, summaries and full content. Built-in search examines at most 256 recent candidates per call; if incomplete, continue with the returned scan_offset. Use read_memory for evidence. Set status to archived to inspect obsolete records.',
19
+ "Find stored memories by words or tags. Use it when the topic is not in the index, before saving (to see whether a memory already covers it), and to reach what earlier turns recorded on their own. Matches are ranked across names, titles, descriptions, summaries and content; one call examines at most 256 recent candidates, and an incomplete result returns a scan_offset to continue from. Set status to archived to look at retired memories. Returns a numbered list with each memory's id, title, type and description; open one with read_memory.",
20
20
  inputSchema: z.object({
21
21
  query: z.string().optional().describe('Relevant words or identifiers to search'),
22
22
  tags: z.array(z.string()).optional().describe('Filter by tags (all must match)'),
@@ -12,7 +12,7 @@ export function buildUpdateMemoryTool(store: MemoryStore): ToolDefinition {
12
12
  return defineTool({
13
13
  name: 'update_memory',
14
14
  description:
15
- 'Correct an existing memory in place, or archive an obsolete claim so it is no longer recalled. Prefer this to saving a second memory about the same thing. Identify it by the ID from search_memory or by its name from the memory index; current evidence should determine corrections. Pass the revision from read_memory when available to refuse a stale correction.',
15
+ 'Correct a memory in place, or archive one that is no longer true so it stops being recalled. Use it when a memory on the same topic already exists, which keeps the store free of duplicates, and when current evidence shows a memory is wrong. Identify the memory by the ID from search_memory or by its name from the index. Pass the revision from read_memory when available, so a change made since you read it is refused instead of overwritten. Returns the memory as it now stands.',
16
16
  inputSchema: z.object({
17
17
  id: z
18
18
  .string()
@@ -1012,7 +1012,12 @@ Executable tool names, descriptions, and JSON input schemas are attached through
1012
1012
  'namzu.registry.errors': errorMessage,
1013
1013
  'namzu.registry.empty': isEmptyInput,
1014
1014
  })
1015
- return { success: false, output: '', error: enrichedMessage }
1015
+ return {
1016
+ success: false,
1017
+ output: '',
1018
+ error: enrichedMessage,
1019
+ facts: { kind: 'failure', reason: 'invalid_arguments' },
1020
+ }
1016
1021
  }
1017
1022
  }
1018
1023
 
@@ -24,6 +24,24 @@ export interface TokenUsage {
24
24
  * not zero.
25
25
  */
26
26
  reasoningTokens?: number
27
+
28
+ /**
29
+ * How long the provider keeps the prompt cache this call used, in
30
+ * milliseconds, as the call itself reported or requested it.
31
+ *
32
+ * A fact about ONE model call, so it is never summed: {@link accumulateTokenUsage}
33
+ * drops it, and {@link mergeTokenUsage} (frames of one streamed call) keeps the
34
+ * latest value. Absent means the driver does not report a lifetime (or does
35
+ * not cache); a host must not guess one.
36
+ */
37
+ cacheTtlMs?: number
38
+
39
+ /**
40
+ * Cache-write tokens split by the lifetime they were written with. Present
41
+ * only when the provider breaks its write count down. Per call, like
42
+ * {@link TokenUsage.cacheTtlMs}.
43
+ */
44
+ cacheWriteTtlTokens?: { fiveMin?: number; oneHour?: number }
27
45
  }
28
46
 
29
47
  export function accumulateTokenUsage(current: TokenUsage, addition: TokenUsage): TokenUsage {
@@ -40,6 +58,8 @@ export function accumulateTokenUsage(current: TokenUsage, addition: TokenUsage):
40
58
  ...(current.reasoningTokens !== undefined || addition.reasoningTokens !== undefined
41
59
  ? { reasoningTokens: (current.reasoningTokens ?? 0) + (addition.reasoningTokens ?? 0) }
42
60
  : {}),
61
+ // `cacheTtlMs` and `cacheWriteTtlTokens` are per-call facts and are
62
+ // deliberately not carried into a sum.
43
63
  }
44
64
  }
45
65
 
@@ -74,6 +94,13 @@ export function mergeTokenUsage(current: TokenUsage, next: TokenUsage): TokenUsa
74
94
  ...(current.reasoningTokens !== undefined || next.reasoningTokens !== undefined
75
95
  ? { reasoningTokens: Math.max(current.reasoningTokens ?? 0, next.reasoningTokens ?? 0) }
76
96
  : {}),
97
+ // Frames of one call describe the same cache: the latest report wins.
98
+ ...(next.cacheTtlMs !== undefined || current.cacheTtlMs !== undefined
99
+ ? { cacheTtlMs: next.cacheTtlMs ?? current.cacheTtlMs }
100
+ : {}),
101
+ ...(next.cacheWriteTtlTokens !== undefined || current.cacheWriteTtlTokens !== undefined
102
+ ? { cacheWriteTtlTokens: next.cacheWriteTtlTokens ?? current.cacheWriteTtlTokens }
103
+ : {}),
77
104
  }
78
105
  }
79
106
 
@@ -21,6 +21,14 @@ export type ResponseFormat =
21
21
 
22
22
  export interface CacheControl {
23
23
  type: 'auto' | 'ephemeral'
24
+ /**
25
+ * How long the provider should keep this prefix cached: `'5m'` (the
26
+ * default every provider uses when this is absent) or `'1h'`. Honoured only
27
+ * by a driver that has an extended lifetime; others ignore it. The longer
28
+ * lifetime is priced higher for the cache write, so asking for it is a cost
29
+ * decision, not a translation.
30
+ */
31
+ ttl?: '5m' | '1h'
24
32
  }
25
33
 
26
34
  export interface ChatCompletionParams {
@@ -157,6 +157,12 @@ export interface ProviderCapabilities {
157
157
  /** Whether the driver maps document blocks returned by tools onto its result wire. */
158
158
  supportsToolResultDocuments?: boolean
159
159
  maxOutputTokens?: number
160
+ /**
161
+ * Prompt-cache lifetimes the driver can be asked for through
162
+ * `cacheControl.ttl`. Absent means the driver offers none (it has no cache,
163
+ * or the lifetime is the provider's own and cannot be chosen or read).
164
+ */
165
+ cacheTtl?: readonly ('5m' | '1h')[]
160
166
  }
161
167
 
162
168
  export interface ProviderFactoryResult {
@@ -79,6 +79,11 @@ export const TokenUsageSchema = z
79
79
  cachedTokens: count,
80
80
  cacheWriteTokens: count,
81
81
  reasoningTokens: count.optional(),
82
+ cacheTtlMs: count.optional(),
83
+ cacheWriteTtlTokens: z
84
+ .object({ fiveMin: count.optional(), oneHour: count.optional() })
85
+ .strict()
86
+ .optional(),
82
87
  })
83
88
  .strict()
84
89
 
@@ -811,6 +811,12 @@ type CoreSessionEvent =
811
811
  turnId: TurnId
812
812
  usage: TokenUsage
813
813
  cost: CostInfo
814
+ /**
815
+ * The prompt cache after the latest model call alone (not the turn
816
+ * total above). Present only when the driver reports a lifetime;
817
+ * absent means unknown, and a host must not assume one.
818
+ */
819
+ cache?: { ttlMs: number; touched: boolean }
814
820
  /**
815
821
  * How large the CONTEXT is right now, and how large it may get.
816
822
  *
@@ -494,7 +494,7 @@ export const DecisionExpiredRecordSchema = inTurn('decision_expired', {
494
494
  export const CompactionRecordSchema = recordSchema('compaction', {
495
495
  compactionId: text.min(1),
496
496
  strategy: text.min(1),
497
- trigger: z.enum(['auto', 'manual']),
497
+ trigger: z.enum(['auto', 'manual', 'idle']),
498
498
  replacesSeqRange: z.tuple([positive, positive]),
499
499
  summary: z.union([z.array(messageBody), spill]),
500
500
  keptMessageIds: z.array(messageId),