@namzu/sdk 53.2.1 → 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 (129) hide show
  1. package/CHANGELOG.md +30 -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/browser.js +1 -1
  37. package/dist/tools/builtins/browser.js.map +1 -1
  38. package/dist/tools/builtins/computer-use.js +2 -2
  39. package/dist/tools/builtins/computer-use.js.map +1 -1
  40. package/dist/tools/builtins/edit-apply.d.ts +7 -4
  41. package/dist/tools/builtins/edit-apply.d.ts.map +1 -1
  42. package/dist/tools/builtins/edit-apply.js +3 -0
  43. package/dist/tools/builtins/edit-apply.js.map +1 -1
  44. package/dist/tools/builtins/edit.d.ts.map +1 -1
  45. package/dist/tools/builtins/edit.js +27 -7
  46. package/dist/tools/builtins/edit.js.map +1 -1
  47. package/dist/tools/builtins/exit-semantics.d.ts +31 -0
  48. package/dist/tools/builtins/exit-semantics.d.ts.map +1 -0
  49. package/dist/tools/builtins/exit-semantics.js +198 -0
  50. package/dist/tools/builtins/exit-semantics.js.map +1 -0
  51. package/dist/tools/builtins/write-file.js +7 -2
  52. package/dist/tools/builtins/write-file.js.map +1 -1
  53. package/dist/tools/coordinator/ask-user-question.js +1 -1
  54. package/dist/tools/coordinator/ask-user-question.js.map +1 -1
  55. package/dist/tools/coordinator/index.js +2 -2
  56. package/dist/tools/coordinator/index.js.map +1 -1
  57. package/dist/tools/memory/delete.js +1 -1
  58. package/dist/tools/memory/delete.js.map +1 -1
  59. package/dist/tools/memory/fields.js +1 -1
  60. package/dist/tools/memory/fields.js.map +1 -1
  61. package/dist/tools/memory/read.js +1 -1
  62. package/dist/tools/memory/read.js.map +1 -1
  63. package/dist/tools/memory/save.d.ts.map +1 -1
  64. package/dist/tools/memory/save.js +4 -3
  65. package/dist/tools/memory/save.js.map +1 -1
  66. package/dist/tools/memory/search.js +1 -1
  67. package/dist/tools/memory/search.js.map +1 -1
  68. package/dist/tools/memory/update.js +1 -1
  69. package/dist/tools/memory/update.js.map +1 -1
  70. package/dist/tools/schedules/schedule-tool.js +3 -3
  71. package/dist/tools/schedules/schedule-tool.js.map +1 -1
  72. package/dist/toolsets/manager.d.ts.map +1 -1
  73. package/dist/toolsets/manager.js +6 -1
  74. package/dist/toolsets/manager.js.map +1 -1
  75. package/dist/types/common/index.d.ts +19 -0
  76. package/dist/types/common/index.d.ts.map +1 -1
  77. package/dist/types/common/index.js +9 -0
  78. package/dist/types/common/index.js.map +1 -1
  79. package/dist/types/provider/chat.d.ts +8 -0
  80. package/dist/types/provider/chat.d.ts.map +1 -1
  81. package/dist/types/provider/config.d.ts +6 -0
  82. package/dist/types/provider/config.d.ts.map +1 -1
  83. package/dist/types/session/checkpoint.d.ts +52 -0
  84. package/dist/types/session/checkpoint.d.ts.map +1 -1
  85. package/dist/types/session/checkpoint.js +5 -0
  86. package/dist/types/session/checkpoint.js.map +1 -1
  87. package/dist/types/session/events.d.ts +9 -0
  88. package/dist/types/session/events.d.ts.map +1 -1
  89. package/dist/types/session/events.js.map +1 -1
  90. package/dist/types/session/records.d.ts +181 -6
  91. package/dist/types/session/records.d.ts.map +1 -1
  92. package/dist/types/session/records.js +1 -1
  93. package/dist/types/session/records.js.map +1 -1
  94. package/dist/types/tool/presentation.d.ts +17 -1
  95. package/dist/types/tool/presentation.d.ts.map +1 -1
  96. package/package.json +1 -1
  97. package/src/compaction/context-breakdown.ts +228 -0
  98. package/src/compaction/token-estimate.ts +5 -0
  99. package/src/public-runtime.ts +23 -2
  100. package/src/runtime/cache-clock.ts +67 -0
  101. package/src/runtime/query/compact-session.ts +101 -3
  102. package/src/runtime/query/executor/tool-call-admission.ts +3 -0
  103. package/src/runtime/query/iteration/index.ts +8 -0
  104. package/src/store/budget/disk.ts +4 -1
  105. package/src/tools/admit-facts.ts +6 -0
  106. package/src/tools/builtins/bash.ts +25 -9
  107. package/src/tools/builtins/browser.ts +1 -1
  108. package/src/tools/builtins/computer-use.ts +2 -2
  109. package/src/tools/builtins/edit-apply.ts +12 -2
  110. package/src/tools/builtins/edit.ts +34 -8
  111. package/src/tools/builtins/exit-semantics.ts +198 -0
  112. package/src/tools/builtins/write-file.ts +7 -2
  113. package/src/tools/coordinator/ask-user-question.ts +1 -1
  114. package/src/tools/coordinator/index.ts +2 -2
  115. package/src/tools/memory/delete.ts +1 -1
  116. package/src/tools/memory/fields.ts +1 -1
  117. package/src/tools/memory/read.ts +1 -1
  118. package/src/tools/memory/save.ts +4 -3
  119. package/src/tools/memory/search.ts +1 -1
  120. package/src/tools/memory/update.ts +1 -1
  121. package/src/tools/schedules/schedule-tool.ts +3 -3
  122. package/src/toolsets/manager.ts +6 -1
  123. package/src/types/common/index.ts +27 -0
  124. package/src/types/provider/chat.ts +8 -0
  125. package/src/types/provider/config.ts +6 -0
  126. package/src/types/session/checkpoint.ts +5 -0
  127. package/src/types/session/events.ts +6 -0
  128. package/src/types/session/records.ts +1 -1
  129. 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) } : {}),
@@ -280,7 +280,7 @@ function actModelSchema(actions: readonly BrowserActActionName[]) {
280
280
  origin: {
281
281
  type: 'string',
282
282
  description:
283
- 'REQUIRED. The origin from the latest snapshot header ("Page: <origin> — …"), e.g. https://github.com. The call is refused if the page is no longer there.',
283
+ 'REQUIRED. The origin from the latest snapshot header ("Page: <origin> — …"), e.g. https://example.com. The call is refused if the page is no longer there.',
284
284
  },
285
285
  ref: { type: 'string', description: 'Element ref from the latest snapshot, like e12.' },
286
286
  text: { type: 'string' },
@@ -109,7 +109,7 @@ export function computerUseUnavailableReason(
109
109
  model?: string,
110
110
  ): string | undefined {
111
111
  if (!resolveProviderCapabilities(provider).supportsToolResultImages) {
112
- return `The ${provider.id} provider cannot return images in tool results, so the model would never see a screenshot. Use a provider that can (for example Anthropic, Codex or Google) for computer use.`
112
+ return `The ${provider.id} provider cannot return images in tool results, so the model would never see a screenshot. Use a provider that can return images in tool results for computer use.`
113
113
  }
114
114
  // The driver can carry the image; the model behind it may still not read one.
115
115
  // It would act on a screen it never saw, and the first screenshot would make
@@ -508,7 +508,7 @@ function buildDescription(
508
508
  available.includes('type_text')
509
509
  )
510
510
  lines.push(
511
- 'To start a Windows program, a shell command (Start-Process notepad, or cmd.exe /c start calc) is surest when you have a shell tool. The Start menu searches by display names in the system language, and ENTER on a name it does not find opens a web search in the browser.',
511
+ 'To start a Windows program, a shell command (Start-Process <program>, or cmd.exe /c start <program>) is surest when you have a shell tool. The Start menu searches by display names in the system language, and ENTER on a name it does not find opens a web search in the browser.',
512
512
  )
513
513
  if (available.includes('list_windows'))
514
514
  lines.push(
@@ -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
  }
@@ -366,7 +366,7 @@ export function buildAskUserQuestionTool(config: AskUserQuestionToolOptions): To
366
366
  return defineTool({
367
367
  name: ASK_USER_QUESTION_TOOL_NAME,
368
368
  description:
369
- 'Ask the user when you are blocked on a decision that is genuinely theirs to make — one you cannot resolve from their request, your tools, the files you can read, or sensible defaults. The question must be the genuinely undecidable thing in THIS task. Never ask for information a tool can discover (do not ask what you can read, list, or search), never re-ask what the conversation already answers, and never ask meta-questions like "Shall I proceed?" — plan ratification goes through approve_plan. Provide 2-4 genuinely distinct options derived from the actual context — concrete paths, never generic placeholders (for example, asked to prepare a presentation, ask "Who is the audience?" with options like Board / Engineering team / Customer); keep labels short (1-5 words) and give each option a one-line description of what practically changes if it is chosen. If you recommend an option, put it FIRST and set recommended: true on it; the host marks it for the user, so never write "(Recommended)" or any translation of it into a label, and never end a label with a parenthesised note (put qualifiers in the description). Set multiSelect: true only when several options can apply at once. An option may carry a preview (plain text or markdown) that the host shows beside the list while it is focused; use it only when seeing the thing helps the choice. A free-text "Something else" escape hatch is always shown automatically — do not add your own "Other" option. Put the questions in the questions array. Use ONE entry for a single decision. Put several entries in the same call only when they are related decisions that ALL block your next step and the user can answer them together (for example which account, for what purpose, which environment before a credentialed step), at most 4, each with a short unique header; the user answers them all in one card and you get one answer per question. Never batch questions that depend on one another\'s answers — ask the first, then decide. Most work needs at most 2-3 questions in all, so prefer proceeding on stated defaults over interrogating the user. Never invent answers or synthetic content on the user\'s behalf unless they explicitly asked for a random/test scenario. The answers arrive as this tool\'s result; a question the result says was not answered must not be assumed, and if the result says the user did not answer at all, do not ask again — proceed on your best judgment without assuming consent.',
369
+ 'Ask the user when you are blocked on a decision that is genuinely theirs to make — one you cannot resolve from their request, your tools, the files you can read, or sensible defaults. The question must be the genuinely undecidable thing in THIS task. Never ask for information a tool can discover (do not ask what you can read, list, or search), never re-ask what the conversation already answers, and never ask meta-questions like "Shall I proceed?" — plan ratification goes through approve_plan. Provide 2-4 genuinely distinct options derived from the actual context — concrete paths, never generic placeholders (for example, asked to prepare a presentation, ask who the audience is, with an option for each plausible audience); keep labels short (1-5 words) and give each option a one-line description of what practically changes if it is chosen. If you recommend an option, put it FIRST and set recommended: true on it; the host marks it for the user, so never write "(Recommended)" or any translation of it into a label, and never end a label with a parenthesised note (put qualifiers in the description). Set multiSelect: true only when several options can apply at once. An option may carry a preview (plain text or markdown) that the host shows beside the list while it is focused; use it only when seeing the thing helps the choice. A free-text "Something else" escape hatch is always shown automatically — do not add your own "Other" option. Put the questions in the questions array. Use ONE entry for a single decision. Put several entries in the same call only when they are related decisions that ALL block your next step and the user can answer them together (for example which account, for what purpose, which environment before a credentialed step), at most 4, each with a short unique header; the user answers them all in one card and you get one answer per question. Never batch questions that depend on one another\'s answers — ask the first, then decide. Most work needs at most 2-3 questions in all, so prefer proceeding on stated defaults over interrogating the user. Never invent answers or synthetic content on the user\'s behalf unless they explicitly asked for a random/test scenario. The answers arrive as this tool\'s result; a question the result says was not answered must not be assumed, and if the result says the user did not answer at all, do not ask again — proceed on your best judgment without assuming consent.',
370
370
  inputSchema,
371
371
  modelInputSchema: structuredClone(askUserQuestionModelInputSchema),
372
372
  enforceModelInput: true,
@@ -252,7 +252,7 @@ const approvePlanModelInputSchema: Record<string, unknown> = {
252
252
  properties: {
253
253
  title: {
254
254
  type: 'string',
255
- description: 'Short title for the plan (e.g. "TypeScript Security & Performance Review").',
255
+ description: 'Short title for the plan (e.g. "Security & Performance Review").',
256
256
  },
257
257
  summary: {
258
258
  type: 'string',
@@ -1279,7 +1279,7 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
1279
1279
  inputSchema: z.object({
1280
1280
  title: z
1281
1281
  .string()
1282
- .describe('Short title for the plan (e.g. "TypeScript Security & Performance Review")'),
1282
+ .describe('Short title for the plan (e.g. "Security & Performance Review")'),
1283
1283
  summary: z.string().describe('1-3 sentence summary of what you plan to do'),
1284
1284
  steps: z
1285
1285
  .preprocess(normalizeApprovePlanSteps, z.array(approvePlanStepSchema))
@@ -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
@@ -9,7 +9,7 @@ export const memoryFieldsSchema = {
9
9
  .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
10
10
  .optional()
11
11
  .describe(
12
- 'Unique kebab-case slug, e.g. "deploys-need-a-changeset"; other memories link to it as [[name]]',
12
+ 'Unique kebab-case slug, e.g. "prefers-short-answers"; other memories link to it as [[name]]',
13
13
  ),
14
14
  description: z
15
15
  .string()
@@ -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()
@@ -121,7 +121,7 @@ const inputSchema = z.object({
121
121
  rules: z
122
122
  .record(z.string(), z.union([EFFECT, z.record(z.string(), EFFECT)]))
123
123
  .optional()
124
- .describe('Extra rules, e.g. {"bash": {"npm test*": "allow"}}'),
124
+ .describe('Extra rules, e.g. {"bash": {"<command> *": "allow"}}'),
125
125
  browser: z
126
126
  .object({
127
127
  profile: z
@@ -131,7 +131,7 @@ const inputSchema = z.object({
131
131
  sites: z
132
132
  .record(z.string(), z.enum(['read', 'ask', 'act']))
133
133
  .describe(
134
- 'Site to level, e.g. {"https://github.com": "read"}. read: open and read only; ask: changes wait for the operator; act: changes run unasked. Unlisted sites are denied; there is no "*".',
134
+ 'Site to level, e.g. {"https://example.com": "read"}. read: open and read only; ask: changes wait for the operator; act: changes run unasked. Unlisted sites are denied; there is no "*".',
135
135
  ),
136
136
  headed: z.boolean().optional().describe('Show the browser window; default no window'),
137
137
  })
@@ -239,7 +239,7 @@ function browserGrant(
239
239
  return {
240
240
  ok: false,
241
241
  error:
242
- 'permissions.browser.sites is empty; list each site the run may open, e.g. {"https://github.com": "read"}.',
242
+ 'permissions.browser.sites is empty; list each site the run may open, e.g. {"https://example.com": "read"}.',
243
243
  }
244
244
  }
245
245
  const sites: Record<string, ScheduleBrowserSiteLevel> = {}
@@ -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