@namzu/sdk 48.3.0 → 49.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 (153) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/config/registry.d.ts +2 -0
  3. package/dist/config/registry.d.ts.map +1 -1
  4. package/dist/config/registry.js +23 -3
  5. package/dist/config/registry.js.map +1 -1
  6. package/dist/connector/mcp/discovery.d.ts +9 -5
  7. package/dist/connector/mcp/discovery.d.ts.map +1 -1
  8. package/dist/connector/mcp/discovery.js +16 -8
  9. package/dist/connector/mcp/discovery.js.map +1 -1
  10. package/dist/connector/mcp/mcp-toolset.d.ts +3 -0
  11. package/dist/connector/mcp/mcp-toolset.d.ts.map +1 -1
  12. package/dist/connector/mcp/mcp-toolset.js +20 -2
  13. package/dist/connector/mcp/mcp-toolset.js.map +1 -1
  14. package/dist/connector/mcp/reconnect.d.ts +2 -0
  15. package/dist/connector/mcp/reconnect.d.ts.map +1 -1
  16. package/dist/connector/mcp/reconnect.js +36 -5
  17. package/dist/connector/mcp/reconnect.js.map +1 -1
  18. package/dist/plugin/lifecycle.d.ts +14 -10
  19. package/dist/plugin/lifecycle.d.ts.map +1 -1
  20. package/dist/plugin/lifecycle.js +178 -41
  21. package/dist/plugin/lifecycle.js.map +1 -1
  22. package/dist/prompt/resident-learning.js +2 -2
  23. package/dist/prompt/resident-learning.js.map +1 -1
  24. package/dist/prompt/resident-step.d.ts +2 -2
  25. package/dist/prompt/resident-step.js +4 -4
  26. package/dist/prompt/resident-step.js.map +1 -1
  27. package/dist/public-runtime.d.ts +2 -2
  28. package/dist/public-runtime.d.ts.map +1 -1
  29. package/dist/public-runtime.js +2 -2
  30. package/dist/public-runtime.js.map +1 -1
  31. package/dist/registry/tool/screen.d.ts.map +1 -1
  32. package/dist/registry/tool/screen.js +33 -2
  33. package/dist/registry/tool/screen.js.map +1 -1
  34. package/dist/runtime/jobs/registry.d.ts.map +1 -1
  35. package/dist/runtime/jobs/registry.js +41 -12
  36. package/dist/runtime/jobs/registry.js.map +1 -1
  37. package/dist/runtime/query/index.d.ts +6 -0
  38. package/dist/runtime/query/index.d.ts.map +1 -1
  39. package/dist/runtime/query/index.js +20 -1
  40. package/dist/runtime/query/index.js.map +1 -1
  41. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  42. package/dist/runtime/query/iteration/index.js +32 -8
  43. package/dist/runtime/query/iteration/index.js.map +1 -1
  44. package/dist/runtime/query/iteration/outstanding-work.d.ts.map +1 -1
  45. package/dist/runtime/query/iteration/outstanding-work.js +2 -0
  46. package/dist/runtime/query/iteration/outstanding-work.js.map +1 -1
  47. package/dist/runtime/query/iteration/phases/context.d.ts +4 -0
  48. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  49. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  50. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  51. package/dist/runtime/query/iteration/phases/tool-review.js +7 -1
  52. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  53. package/dist/runtime/query/iteration/step-shaping.d.ts +2 -0
  54. package/dist/runtime/query/iteration/step-shaping.d.ts.map +1 -1
  55. package/dist/runtime/query/iteration/step-shaping.js +5 -1
  56. package/dist/runtime/query/iteration/step-shaping.js.map +1 -1
  57. package/dist/store/index.d.ts +1 -0
  58. package/dist/store/index.d.ts.map +1 -1
  59. package/dist/store/index.js +1 -0
  60. package/dist/store/index.js.map +1 -1
  61. package/dist/store/memory/disk.d.ts +7 -2
  62. package/dist/store/memory/disk.d.ts.map +1 -1
  63. package/dist/store/memory/disk.js +58 -5
  64. package/dist/store/memory/disk.js.map +1 -1
  65. package/dist/store/memory/index.d.ts +9 -0
  66. package/dist/store/memory/index.d.ts.map +1 -1
  67. package/dist/store/memory/index.js +43 -5
  68. package/dist/store/memory/index.js.map +1 -1
  69. package/dist/store/memory/markdown.d.ts +7 -2
  70. package/dist/store/memory/markdown.d.ts.map +1 -1
  71. package/dist/store/memory/markdown.js +33 -0
  72. package/dist/store/memory/markdown.js.map +1 -1
  73. package/dist/store/memory/memory.d.ts +7 -2
  74. package/dist/store/memory/memory.d.ts.map +1 -1
  75. package/dist/store/memory/memory.js +34 -0
  76. package/dist/store/memory/memory.js.map +1 -1
  77. package/dist/store/memory/revision.d.ts +16 -0
  78. package/dist/store/memory/revision.d.ts.map +1 -0
  79. package/dist/store/memory/revision.js +144 -0
  80. package/dist/store/memory/revision.js.map +1 -0
  81. package/dist/tools/builtins/wait-for-job-bounds.d.ts +6 -4
  82. package/dist/tools/builtins/wait-for-job-bounds.d.ts.map +1 -1
  83. package/dist/tools/builtins/wait-for-job-bounds.js +46 -8
  84. package/dist/tools/builtins/wait-for-job-bounds.js.map +1 -1
  85. package/dist/tools/builtins/wait-for-job.d.ts +1 -0
  86. package/dist/tools/builtins/wait-for-job.d.ts.map +1 -1
  87. package/dist/tools/builtins/wait-for-job.js +24 -10
  88. package/dist/tools/builtins/wait-for-job.js.map +1 -1
  89. package/dist/tools/coordinator/wait-with-idle-bound.d.ts.map +1 -1
  90. package/dist/tools/coordinator/wait-with-idle-bound.js +4 -0
  91. package/dist/tools/coordinator/wait-with-idle-bound.js.map +1 -1
  92. package/dist/tools/memory/delete.d.ts.map +1 -1
  93. package/dist/tools/memory/delete.js +38 -3
  94. package/dist/tools/memory/delete.js.map +1 -1
  95. package/dist/tools/memory/read.d.ts.map +1 -1
  96. package/dist/tools/memory/read.js +38 -2
  97. package/dist/tools/memory/read.js.map +1 -1
  98. package/dist/tools/memory/search.d.ts.map +1 -1
  99. package/dist/tools/memory/search.js +38 -7
  100. package/dist/tools/memory/search.js.map +1 -1
  101. package/dist/tools/memory/update.d.ts.map +1 -1
  102. package/dist/tools/memory/update.js +30 -3
  103. package/dist/tools/memory/update.js.map +1 -1
  104. package/dist/toolsets/manager.d.ts +13 -0
  105. package/dist/toolsets/manager.d.ts.map +1 -1
  106. package/dist/toolsets/manager.js +60 -1
  107. package/dist/toolsets/manager.js.map +1 -1
  108. package/dist/turn/memory-recall.d.ts +2 -0
  109. package/dist/turn/memory-recall.d.ts.map +1 -1
  110. package/dist/turn/memory-recall.js +3 -1
  111. package/dist/turn/memory-recall.js.map +1 -1
  112. package/dist/types/memory/index.d.ts +29 -0
  113. package/dist/types/memory/index.d.ts.map +1 -1
  114. package/dist/types/memory/index.js +6 -0
  115. package/dist/types/memory/index.js.map +1 -1
  116. package/dist/types/plugin/index.d.ts +12 -2
  117. package/dist/types/plugin/index.d.ts.map +1 -1
  118. package/dist/types/plugin/index.js +5 -2
  119. package/dist/types/plugin/index.js.map +1 -1
  120. package/package.json +1 -1
  121. package/src/config/registry.ts +24 -3
  122. package/src/connector/mcp/discovery.ts +29 -8
  123. package/src/connector/mcp/mcp-toolset.ts +25 -4
  124. package/src/connector/mcp/reconnect.ts +34 -4
  125. package/src/plugin/lifecycle.ts +217 -46
  126. package/src/prompt/resident-learning.ts +2 -2
  127. package/src/prompt/resident-step.ts +4 -4
  128. package/src/public-runtime.ts +2 -0
  129. package/src/registry/tool/screen.ts +34 -2
  130. package/src/runtime/jobs/registry.ts +39 -15
  131. package/src/runtime/query/index.ts +24 -1
  132. package/src/runtime/query/iteration/index.ts +35 -10
  133. package/src/runtime/query/iteration/outstanding-work.ts +2 -0
  134. package/src/runtime/query/iteration/phases/context.ts +4 -0
  135. package/src/runtime/query/iteration/phases/tool-review.ts +6 -1
  136. package/src/runtime/query/iteration/step-shaping.ts +9 -1
  137. package/src/store/index.ts +1 -0
  138. package/src/store/memory/disk.ts +82 -7
  139. package/src/store/memory/index.ts +66 -7
  140. package/src/store/memory/markdown.ts +54 -2
  141. package/src/store/memory/memory.ts +52 -2
  142. package/src/store/memory/revision.ts +158 -0
  143. package/src/tools/builtins/wait-for-job-bounds.ts +45 -7
  144. package/src/tools/builtins/wait-for-job.ts +25 -11
  145. package/src/tools/coordinator/wait-with-idle-bound.ts +3 -0
  146. package/src/tools/memory/delete.ts +33 -3
  147. package/src/tools/memory/read.ts +35 -2
  148. package/src/tools/memory/search.ts +38 -7
  149. package/src/tools/memory/update.ts +28 -3
  150. package/src/toolsets/manager.ts +74 -1
  151. package/src/turn/memory-recall.ts +5 -1
  152. package/src/types/memory/index.ts +43 -0
  153. package/src/types/plugin/index.ts +11 -6
@@ -5,15 +5,16 @@ import { dirname, join, resolve } from 'node:path'
5
5
  import { NamzuError } from '../../types/errors/index.js'
6
6
  import type { MemoryId } from '../../types/ids/index.js'
7
7
  import type {
8
+ ConditionalMemoryStore,
8
9
  CreateMemoryParams,
9
10
  MemoryContent,
10
11
  MemoryIndexEntry,
11
12
  MemoryRecord,
12
13
  MemorySearchParams,
13
14
  MemorySearchResult,
14
- MemoryStore,
15
15
  MemoryType,
16
16
  UpdateMemoryParams,
17
+ VersionedMemoryRecord,
17
18
  } from '../../types/memory/index.js'
18
19
  import { assertMemoryStatus, isMemoryType } from '../../types/memory/index.js'
19
20
  import { atomicWriteFile } from '../../utils/atomic-write.js'
@@ -47,6 +48,7 @@ import {
47
48
  acquireMemoryOperationLock,
48
49
  validateMemoryLockTimeout,
49
50
  } from './operation-lock.js'
51
+ import { MemoryRevisionConflictError, assertMemoryRevision, memoryRevision } from './revision.js'
50
52
 
51
53
  /**
52
54
  * Versioned like every other record this SDK persists. A memory file carries
@@ -296,7 +298,7 @@ function decodeMemoryFile(
296
298
  * holds with {@link MemoryNameConflictError}, and derives a free one from the
297
299
  * title when none is given.
298
300
  */
299
- export class MarkdownMemoryStore implements MemoryStore {
301
+ export class MarkdownMemoryStore implements ConditionalMemoryStore {
300
302
  private readonly directory: string
301
303
  private readonly log: Logger
302
304
  private readonly lockTimeoutMs: number
@@ -587,6 +589,15 @@ export class MarkdownMemoryStore implements MemoryStore {
587
589
  })
588
590
  }
589
591
 
592
+ async getVersionedRecord(id: MemoryId): Promise<VersionedMemoryRecord | undefined> {
593
+ return this.withLoaded(async (_dir, loaded) => {
594
+ const memory = loaded.get(id)
595
+ if (!memory) return undefined
596
+ const record = structuredClone({ entry: memory.entry, content: memory.content })
597
+ return { ...record, revision: memoryRevision(record) }
598
+ })
599
+ }
600
+
590
601
  /** The current record held under `name`, archived included, or `undefined`. */
591
602
  async getByName(name: string): Promise<MemoryRecord | undefined> {
592
603
  return this.withLoaded(async (_dir, loaded) => {
@@ -603,10 +614,35 @@ export class MarkdownMemoryStore implements MemoryStore {
603
614
  }
604
615
 
605
616
  async update(id: MemoryId, updates: UpdateMemoryParams): Promise<MemoryIndexEntry | undefined> {
617
+ return this.updateInternal(id, updates)
618
+ }
619
+
620
+ async updateIfRevision(
621
+ id: MemoryId,
622
+ updates: UpdateMemoryParams,
623
+ expectedRevision: string,
624
+ ): Promise<MemoryIndexEntry> {
625
+ const updated = await this.updateInternal(id, updates, expectedRevision)
626
+ if (!updated) throw new MemoryRevisionConflictError(id)
627
+ return updated
628
+ }
629
+
630
+ private async updateInternal(
631
+ id: MemoryId,
632
+ updates: UpdateMemoryParams,
633
+ expectedRevision?: string,
634
+ ): Promise<MemoryIndexEntry | undefined> {
606
635
  if (updates.status !== undefined) assertMemoryStatus(updates.status)
607
636
  assertOptionalMemoryFields(updates)
608
637
  return this.mutate(async (dir, loaded) => {
609
638
  const existing = loaded.get(id)
639
+ if (expectedRevision !== undefined) {
640
+ assertMemoryRevision(
641
+ id,
642
+ existing ? { entry: existing.entry, content: existing.content } : undefined,
643
+ expectedRevision,
644
+ )
645
+ }
610
646
  if (!existing) return undefined
611
647
  const entries = this.entries(loaded)
612
648
  if (updates.name !== undefined) {
@@ -645,8 +681,24 @@ export class MarkdownMemoryStore implements MemoryStore {
645
681
  }
646
682
 
647
683
  async delete(id: MemoryId): Promise<boolean> {
684
+ return this.deleteInternal(id)
685
+ }
686
+
687
+ async deleteIfRevision(id: MemoryId, expectedRevision: string): Promise<void> {
688
+ const deleted = await this.deleteInternal(id, expectedRevision)
689
+ if (!deleted) throw new MemoryRevisionConflictError(id)
690
+ }
691
+
692
+ private async deleteInternal(id: MemoryId, expectedRevision?: string): Promise<boolean> {
648
693
  return this.mutate(async (dir, loaded) => {
649
694
  const existing = loaded.get(id)
695
+ if (expectedRevision !== undefined) {
696
+ assertMemoryRevision(
697
+ id,
698
+ existing ? { entry: existing.entry, content: existing.content } : undefined,
699
+ expectedRevision,
700
+ )
701
+ }
650
702
  if (!existing) return false
651
703
  try {
652
704
  await unlink(existing.path)
@@ -1,13 +1,14 @@
1
1
  import type { MemoryId } from '../../types/ids/index.js'
2
2
  import type {
3
+ ConditionalMemoryStore,
3
4
  CreateMemoryParams,
4
5
  MemoryContent,
5
6
  MemoryIndexEntry,
6
7
  MemoryRecord,
7
8
  MemorySearchParams,
8
9
  MemorySearchResult,
9
- MemoryStore,
10
10
  UpdateMemoryParams,
11
+ VersionedMemoryRecord,
11
12
  } from '../../types/memory/index.js'
12
13
  import { assertMemoryStatus } from '../../types/memory/index.js'
13
14
  import { generateMemoryId } from '../../utils/id.js'
@@ -18,8 +19,9 @@ import {
18
19
  nameHolder,
19
20
  withOptionalFields,
20
21
  } from './naming.js'
22
+ import { assertMemoryRevision, memoryRevision } from './revision.js'
21
23
 
22
- export class InMemoryMemoryStore implements MemoryStore {
24
+ export class InMemoryMemoryStore implements ConditionalMemoryStore {
23
25
  private content = new Map<string, MemoryContent>()
24
26
  private index = new InMemoryMemoryIndex()
25
27
 
@@ -70,10 +72,45 @@ export class InMemoryMemoryStore implements MemoryStore {
70
72
  return entry && content ? structuredClone({ entry, content }) : undefined
71
73
  }
72
74
 
75
+ async getVersionedRecord(id: MemoryId): Promise<VersionedMemoryRecord | undefined> {
76
+ const entry = this.index.getEntry(id)
77
+ const content = this.content.get(id)
78
+ if (!entry || !content) return undefined
79
+ const record = structuredClone({ entry, content })
80
+ return { ...record, revision: memoryRevision(record) }
81
+ }
82
+
73
83
  async update(id: MemoryId, updates: UpdateMemoryParams): Promise<MemoryIndexEntry | undefined> {
84
+ return this.updateNow(id, updates)
85
+ }
86
+
87
+ async updateIfRevision(
88
+ id: MemoryId,
89
+ updates: UpdateMemoryParams,
90
+ expectedRevision: string,
91
+ ): Promise<MemoryIndexEntry> {
92
+ // No await between the check and mutation: one in-memory operation turn.
93
+ const entry = this.updateNow(id, updates, expectedRevision)
94
+ if (!entry) throw new Error('A conditional memory update unexpectedly found no record.')
95
+ return entry
96
+ }
97
+
98
+ private updateNow(
99
+ id: MemoryId,
100
+ updates: UpdateMemoryParams,
101
+ expectedRevision?: string,
102
+ ): MemoryIndexEntry | undefined {
74
103
  if (updates.status !== undefined) assertMemoryStatus(updates.status)
75
104
  assertOptionalMemoryFields(updates)
76
105
  const existing = this.index.getEntry(id)
106
+ if (expectedRevision !== undefined) {
107
+ const content = this.content.get(id)
108
+ assertMemoryRevision(
109
+ id,
110
+ existing && content ? { entry: existing, content } : undefined,
111
+ expectedRevision,
112
+ )
113
+ }
77
114
  if (!existing) return undefined
78
115
  if (updates.name !== undefined) {
79
116
  const holder = nameHolder(this.index.allEntries(), updates.name, id)
@@ -118,6 +155,19 @@ export class InMemoryMemoryStore implements MemoryStore {
118
155
  }
119
156
 
120
157
  async delete(id: MemoryId): Promise<boolean> {
158
+ return this.deleteNow(id)
159
+ }
160
+
161
+ async deleteIfRevision(id: MemoryId, expectedRevision: string): Promise<void> {
162
+ this.deleteNow(id, expectedRevision)
163
+ }
164
+
165
+ private deleteNow(id: MemoryId, expectedRevision?: string): boolean {
166
+ if (expectedRevision !== undefined) {
167
+ const entry = this.index.getEntry(id)
168
+ const content = this.content.get(id)
169
+ assertMemoryRevision(id, entry && content ? { entry, content } : undefined, expectedRevision)
170
+ }
121
171
  const existed = this.index.remove(id)
122
172
  this.content.delete(id)
123
173
  return existed
@@ -0,0 +1,158 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { NamzuError } from '../../types/errors/index.js'
3
+ import type { MemoryId } from '../../types/ids/index.js'
4
+ import type { MemoryRecord } from '../../types/memory/index.js'
5
+
6
+ /** A record can be read, but its metadata cannot be fingerprinted synchronously. */
7
+ export class MemoryRevisionUnavailableError extends Error {
8
+ constructor() {
9
+ super('This memory contains metadata that cannot be revision-checked.')
10
+ this.name = 'MemoryRevisionUnavailableError'
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Encode the same structured-clone snapshot `getRecord` returns. Type tags,
16
+ * object references and sorted plain-object keys preserve values JSON loses:
17
+ * BigInt, undefined, Map/Set contents, Date, typed arrays and cycles. Map and
18
+ * Set order is kept because it is observable. Unknown host objects (notably
19
+ * Blob, whose bytes need asynchronous reading) fail closed rather than hash as
20
+ * `{}` and allow a stale conditional write.
21
+ */
22
+ function canonicalRecord(record: MemoryRecord): string {
23
+ let snapshot: MemoryRecord
24
+ try {
25
+ snapshot = structuredClone(record)
26
+ } catch {
27
+ throw new MemoryRevisionUnavailableError()
28
+ }
29
+ const seen = new WeakMap<object, number>()
30
+ let nextId = 0
31
+
32
+ function encode(value: unknown): unknown {
33
+ if (value === null) return ['null']
34
+ switch (typeof value) {
35
+ case 'undefined':
36
+ return ['undefined']
37
+ case 'string':
38
+ return ['string', value]
39
+ case 'boolean':
40
+ return ['boolean', value]
41
+ case 'bigint':
42
+ return ['bigint', value.toString()]
43
+ case 'number':
44
+ return ['number', Object.is(value, -0) ? '-0' : String(value)]
45
+ case 'function':
46
+ case 'symbol':
47
+ throw new MemoryRevisionUnavailableError()
48
+ case 'object':
49
+ break
50
+ }
51
+
52
+ const object = value as object
53
+ const previous = seen.get(object)
54
+ if (previous !== undefined) return ['ref', previous]
55
+ const id = nextId++
56
+ seen.set(object, id)
57
+ if (Array.isArray(object)) {
58
+ return [
59
+ 'array',
60
+ id,
61
+ object.length,
62
+ Object.keys(object)
63
+ .sort()
64
+ .map((key) => [key, encode((object as unknown as Record<string, unknown>)[key])]),
65
+ ]
66
+ }
67
+ if (object instanceof Map) {
68
+ return ['map', id, [...object].map(([key, item]) => [encode(key), encode(item)])]
69
+ }
70
+ if (object instanceof Set) return ['set', id, [...object].map(encode)]
71
+ if (object instanceof Date) return ['date', id, encode(object.getTime())]
72
+ if (object instanceof RegExp)
73
+ return ['regexp', id, object.source, object.flags, object.lastIndex]
74
+ if (object instanceof ArrayBuffer) {
75
+ const buffer = object as ArrayBuffer & {
76
+ readonly maxByteLength?: number
77
+ readonly resizable?: boolean
78
+ }
79
+ return [
80
+ 'array-buffer',
81
+ id,
82
+ buffer.byteLength,
83
+ buffer.maxByteLength,
84
+ buffer.resizable,
85
+ Buffer.from(buffer).toString('hex'),
86
+ ]
87
+ }
88
+ if (ArrayBuffer.isView(object)) {
89
+ // A view exposes its entire backing buffer through `.buffer`, even
90
+ // when its visible slice is one byte. Structured cloning keeps that
91
+ // backing and preserves aliases between multiple views.
92
+ if (
93
+ (typeof SharedArrayBuffer !== 'undefined' && object.buffer instanceof SharedArrayBuffer) ||
94
+ (object.buffer instanceof ArrayBuffer &&
95
+ (object.buffer as ArrayBuffer & { readonly resizable?: boolean }).resizable === true)
96
+ )
97
+ throw new MemoryRevisionUnavailableError()
98
+ return [
99
+ 'array-buffer-view',
100
+ id,
101
+ object.constructor.name,
102
+ object.byteOffset,
103
+ object.byteLength,
104
+ encode(object.buffer),
105
+ ]
106
+ }
107
+ if (object instanceof Error) {
108
+ return ['error', id, object.name, object.message, object.stack, encode(object.cause)]
109
+ }
110
+ if (object instanceof Number || object instanceof String || object instanceof Boolean)
111
+ return ['boxed', id, object.constructor.name, encode(object.valueOf())]
112
+ if (typeof SharedArrayBuffer !== 'undefined' && object instanceof SharedArrayBuffer)
113
+ throw new MemoryRevisionUnavailableError()
114
+ const prototype = Object.getPrototypeOf(object)
115
+ if (prototype !== Object.prototype && prototype !== null)
116
+ throw new MemoryRevisionUnavailableError()
117
+ return [
118
+ 'object',
119
+ id,
120
+ Object.keys(object as Record<string, unknown>)
121
+ .sort()
122
+ .map((key) => [key, encode((object as Record<string, unknown>)[key])]),
123
+ ]
124
+ }
125
+
126
+ return JSON.stringify(encode(snapshot))
127
+ }
128
+
129
+ /** A content-derived token; it includes metadata as well as the body. */
130
+ export function memoryRevision(record: MemoryRecord): string {
131
+ return `m2:${createHash('sha256').update(canonicalRecord(record)).digest('hex')}`
132
+ }
133
+
134
+ /** The record changed or disappeared after its revision was read. */
135
+ export class MemoryRevisionConflictError extends NamzuError {
136
+ readonly memoryId: MemoryId
137
+
138
+ constructor(memoryId: MemoryId) {
139
+ super({
140
+ code: 'storage_error',
141
+ message: `Memory ${memoryId} changed since it was read. Read it again before changing it.`,
142
+ details: { memoryId, reason: 'revision_conflict' },
143
+ retryable: false,
144
+ })
145
+ this.name = 'MemoryRevisionConflictError'
146
+ this.memoryId = memoryId
147
+ }
148
+ }
149
+
150
+ export function assertMemoryRevision(
151
+ id: MemoryId,
152
+ record: MemoryRecord | undefined,
153
+ expectedRevision: string,
154
+ ): asserts record is MemoryRecord {
155
+ if (!record || memoryRevision(record) !== expectedRevision) {
156
+ throw new MemoryRevisionConflictError(id)
157
+ }
158
+ }
@@ -1,4 +1,5 @@
1
1
  import type { BackgroundJobRegistryRef } from '../../types/tool/index.js'
2
+ import { subscribeToAbort } from '../../utils/abort.js'
2
3
 
3
4
  /**
4
5
  * Waiting on a background job, bounded by two different questions — the
@@ -42,6 +43,8 @@ interface JobWaitProgress {
42
43
  readonly output: string
43
44
  readonly nextOffset: number
44
45
  readonly droppedBytes: number
46
+ /** Earlier bytes omitted from this wait's own bounded output, separately from registry loss. */
47
+ readonly omittedOutputBytes: number
45
48
  }
46
49
 
47
50
  export type JobWaitOutcome =
@@ -67,12 +70,27 @@ export type JobWaitOutcome =
67
70
  */
68
71
  const POLL_INTERVAL_MS = 1_000
69
72
 
73
+ /** Leave room for the status and recovery instructions inside the tool-result budget. */
74
+ const MAX_WAIT_OUTPUT_BYTES = 32 * 1024
75
+
76
+ function boundedTail(value: string): { output: string; omittedBytes: number } {
77
+ const bytes = Buffer.from(value, 'utf8')
78
+ if (bytes.length <= MAX_WAIT_OUTPUT_BYTES) return { output: value, omittedBytes: 0 }
79
+ let start = bytes.length - MAX_WAIT_OUTPUT_BYTES
80
+ // The cap is in bytes, but the displayed tail must begin at a UTF-8 character.
81
+ while (start < bytes.length && ((bytes[start] ?? 0) & 0xc0) === 0x80) start++
82
+ return {
83
+ output: bytes.subarray(start).toString('utf8'),
84
+ omittedBytes: start,
85
+ }
86
+ }
87
+
70
88
  /**
71
- * Await a job under both bounds, accumulating its output as it goes.
89
+ * Await a job under both bounds, retaining a bounded tail as it goes.
72
90
  *
73
- * Returns the output gathered so far either way: a completed wait has all
74
- * of it, and a timed-out one has everything read up to the moment it gave
75
- * up, so the caller never has to throw away a partial answer.
91
+ * A completed or timed-out wait returns the bytes it observed up to its
92
+ * absolute cursor. If the displayed tail could not hold all of them, its
93
+ * omitted-byte count keeps that gap visible to the caller.
76
94
  */
77
95
  export async function waitForJobWithBounds(
78
96
  jobs: Pick<BackgroundJobRegistryRef, 'read' | 'waitForExit'>,
@@ -90,7 +108,16 @@ export async function waitForJobWithBounds(
90
108
  let cursor = options.fromOffset ?? 0
91
109
  let output = ''
92
110
  let droppedBytes = 0
111
+ let omittedOutputBytes = 0
93
112
  let settled = false
113
+ let poll: ReturnType<typeof setInterval> | undefined
114
+ // Each bounded wait owns its exit subscription. A timeout must release that
115
+ // subscription even when the job itself runs forever.
116
+ const waitAbort = new AbortController()
117
+ const forwardAbort = (): void => waitAbort.abort(options.signal?.reason)
118
+ let disposeCallerAbort = () => {}
119
+ if (options.signal?.aborted) forwardAbort()
120
+ else if (options.signal) disposeCallerAbort = subscribeToAbort(options.signal, forwardAbort)
94
121
 
95
122
  /** Read whatever is new since `cursor`, and count it as progress if it is. */
96
123
  const drain = (): void => {
@@ -98,11 +125,15 @@ export async function waitForJobWithBounds(
98
125
  if (chunk.droppedBytes > 0) droppedBytes += chunk.droppedBytes
99
126
  if (chunk.nextOffset > cursor) lastProgressAt = now()
100
127
  cursor = chunk.nextOffset
101
- if (chunk.chunk) output += chunk.chunk
128
+ if (chunk.chunk) {
129
+ const bounded = boundedTail(output + chunk.chunk)
130
+ output = bounded.output
131
+ omittedOutputBytes += bounded.omittedBytes
132
+ }
102
133
  }
103
134
 
104
135
  try {
105
- const exited = waitForExit(id, { signal: options.signal }).then((job): JobWaitOutcome => {
136
+ const exited = waitForExit(id, { signal: waitAbort.signal }).then((job): JobWaitOutcome => {
106
137
  // One last read: the job can exit between ticks, and the bytes
107
138
  // it wrote in its final moment are exactly the ones a caller
108
139
  // most wants — the error, the summary line, the exit trace.
@@ -114,6 +145,7 @@ export async function waitForJobWithBounds(
114
145
  output,
115
146
  nextOffset: cursor,
116
147
  droppedBytes,
148
+ omittedOutputBytes,
117
149
  }
118
150
  })
119
151
 
@@ -135,6 +167,7 @@ export async function waitForJobWithBounds(
135
167
  output,
136
168
  nextOffset: cursor,
137
169
  droppedBytes,
170
+ omittedOutputBytes,
138
171
  })
139
172
  return
140
173
  }
@@ -149,10 +182,12 @@ export async function waitForJobWithBounds(
149
182
  output,
150
183
  nextOffset: cursor,
151
184
  droppedBytes,
185
+ omittedOutputBytes,
152
186
  })
153
187
  }
154
188
  }
155
189
  }, POLL_INTERVAL_MS)
190
+ poll = tick
156
191
  // Never the reason a process stays alive — this races a real
157
192
  // exit promise, so the wait is held open by work that is
158
193
  // genuinely outstanding rather than by this timer.
@@ -162,6 +197,9 @@ export async function waitForJobWithBounds(
162
197
  return await Promise.race([exited, expiry])
163
198
  } finally {
164
199
  settled = true
200
+ if (poll !== undefined) clearInterval(poll)
201
+ disposeCallerAbort()
202
+ waitAbort.abort(new Error('Job wait finished'))
165
203
  }
166
204
  }
167
205
 
@@ -171,7 +209,7 @@ export function describeJobWaitTimeout(
171
209
  outcome: Extract<JobWaitOutcome, { kind: 'timeout' }>,
172
210
  ): string {
173
211
  const seconds = Math.round(outcome.elapsedMs / 1000)
174
- const resume = `call wait_for_job again, or job read with from_offset ${outcome.nextOffset}, to see what it does next`
212
+ const resume = `call wait_for_job or job read with from_offset ${outcome.nextOffset} to see only what comes next`
175
213
  if (outcome.cause === 'idle') {
176
214
  return `Job ${id} went quiet: no new output for a while, after ${seconds}s. It has not been stopped and may still be working — ${resume}.`
177
215
  }
@@ -35,6 +35,12 @@ const MAX_WAIT_MS = readPositiveIntEnv('NAMZU_JOB_WAIT_MAX_MS', 60 * 60 * 1000)
35
35
 
36
36
  const inputSchema = z.object({
37
37
  id: z.string().describe('The job id, as returned by bash with run_in_background.'),
38
+ from_offset: z
39
+ .number()
40
+ .int()
41
+ .nonnegative()
42
+ .optional()
43
+ .describe('Resume after a previous wait: pass its next_offset to receive only new output.'),
38
44
  timeout_ms: z
39
45
  .number()
40
46
  .int()
@@ -60,7 +66,7 @@ type WaitForJobInput = z.infer<typeof inputSchema>
60
66
  export const WaitForJobTool = defineTool({
61
67
  name: 'wait_for_job',
62
68
  description:
63
- 'Block until a background job started by bash with run_in_background ends, and return its accumulated output in one call. Use this instead of calling job with action "read" in a loop: it costs one call and no waiting turns. Gives up — WITHOUT stopping the job — if it runs too long or goes quiet for too long; either outcome says so, and the job keeps running either way.',
69
+ 'Block until a background job started by bash with run_in_background ends, and return its bounded output in one call. Use this instead of calling job with action "read" in a loop: it costs one call and no waiting turns. A wait that runs too long or goes quiet returns its output and next_offset without stopping the job; pass that offset as from_offset on the next wait to avoid repeats.',
64
70
  inputSchema,
65
71
  category: 'shell',
66
72
  permissions: ['shell_execute'],
@@ -128,6 +134,7 @@ export const WaitForJobTool = defineTool({
128
134
  outcome = await waitForJobWithBounds(jobs, input.id, {
129
135
  wallMs: input.timeout_ms ?? DEFAULT_TIMEOUT_MS,
130
136
  idleMs: input.idle_timeout_ms ?? DEFAULT_IDLE_TIMEOUT_MS,
137
+ ...(input.from_offset === undefined ? {} : { fromOffset: input.from_offset }),
131
138
  signal: context.abortSignal,
132
139
  })
133
140
  } catch {
@@ -144,26 +151,32 @@ export const WaitForJobTool = defineTool({
144
151
  }
145
152
  }
146
153
 
154
+ const notices = [
155
+ ...(outcome.droppedBytes > 0
156
+ ? [`[${outcome.droppedBytes} bytes were dropped by the job's output retention cap]`]
157
+ : []),
158
+ ...(outcome.omittedOutputBytes > 0
159
+ ? [`[${outcome.omittedOutputBytes} earlier bytes omitted from this bounded wait result]`]
160
+ : []),
161
+ ]
162
+ const observed = `${notices.length > 0 ? `${notices.join('\n')}\n` : ''}${outcome.output || '(no new output)'}`
163
+
147
164
  if (outcome.kind === 'timeout') {
148
165
  return {
149
- success: false,
150
- output: describeJobWaitTimeout(input.id, outcome),
166
+ // The wait observed a bound, not a failed command. A tool error
167
+ // would also be promoted as a durable failure by the turn's memory.
168
+ success: true,
169
+ output: `${describeJobWaitTimeout(input.id, outcome)}\n\n${observed}\n\n[next_offset ${outcome.nextOffset}]`,
151
170
  data: {
152
171
  jobId: input.id,
153
172
  timedOut: outcome.cause,
154
173
  nextOffset: outcome.nextOffset,
155
174
  droppedBytes: outcome.droppedBytes,
175
+ omittedOutputBytes: outcome.omittedOutputBytes,
156
176
  },
157
177
  }
158
178
  }
159
179
 
160
- // The dropped count is stated, never absorbed — same rule `job read`
161
- // follows, for the same reason: a job whose middle vanished quietly
162
- // reads as a complete result that happens to be short.
163
- const notice =
164
- outcome.droppedBytes > 0
165
- ? `[${outcome.droppedBytes} bytes were dropped before this point — the job produced output faster than the retention cap holds]\n`
166
- : ''
167
180
  const status =
168
181
  outcome.exitCode === undefined
169
182
  ? outcome.status
@@ -171,12 +184,13 @@ export const WaitForJobTool = defineTool({
171
184
 
172
185
  return {
173
186
  success: true,
174
- output: `${notice}${outcome.output || '(no output)'}\n\n[job ${input.id} is ${status}; next_offset ${outcome.nextOffset}]`,
187
+ output: `${observed}\n\n[job ${input.id} is ${status}; next_offset ${outcome.nextOffset}]`,
175
188
  data: {
176
189
  jobId: input.id,
177
190
  status: outcome.status,
178
191
  nextOffset: outcome.nextOffset,
179
192
  droppedBytes: outcome.droppedBytes,
193
+ omittedOutputBytes: outcome.omittedOutputBytes,
180
194
  ...(outcome.exitCode === undefined ? {} : { exitCode: outcome.exitCode }),
181
195
  },
182
196
  }
@@ -69,6 +69,7 @@ export async function waitForTaskWithBounds(
69
69
  const startedAt = now()
70
70
  let lastProgressAt = startedAt
71
71
  let settled = false
72
+ let poll: ReturnType<typeof setInterval> | undefined
72
73
 
73
74
  const detach = gateway.onTaskProgress?.((id) => {
74
75
  if (id === taskId) lastProgressAt = now()
@@ -107,6 +108,7 @@ export async function waitForTaskWithBounds(
107
108
  }
108
109
  }
109
110
  }, POLL_INTERVAL_MS)
111
+ poll = tick
110
112
  // Never the reason a process stays alive. This one is safe to unref
111
113
  // where the park recorder was not, because nothing AWAITS it alone:
112
114
  // it races a real completion promise, so the wait is held open by
@@ -117,6 +119,7 @@ export async function waitForTaskWithBounds(
117
119
  return await Promise.race([completion, expiry])
118
120
  } finally {
119
121
  settled = true
122
+ if (poll !== undefined) clearInterval(poll)
120
123
  detach?.()
121
124
  }
122
125
  }
@@ -1,5 +1,7 @@
1
1
  import { z } from 'zod'
2
+ import { MemoryRevisionConflictError } from '../../store/memory/revision.js'
2
3
  import type { MemoryStore } from '../../types/memory/index.js'
4
+ import { hasConditionalMemoryWrites } from '../../types/memory/index.js'
3
5
  import type { ToolDefinition } from '../../types/tool/index.js'
4
6
  import { asMemoryId } from '../../utils/id.js'
5
7
  import { defineTool } from '../defineTool.js'
@@ -8,17 +10,45 @@ export function buildDeleteMemoryTool(store: MemoryStore): ToolDefinition {
8
10
  return defineTool({
9
11
  name: 'delete_memory',
10
12
  description:
11
- '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.',
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.',
12
14
  inputSchema: z.object({
13
15
  id: z.string().describe('Memory ID to permanently delete'),
16
+ revision: z
17
+ .string()
18
+ .min(1)
19
+ .optional()
20
+ .describe('Opaque revision from read_memory; refuse deletion if the record changed'),
14
21
  }),
15
22
  category: 'custom',
16
23
  permissions: [],
17
24
  readOnly: false,
18
25
  destructive: true,
19
26
  concurrencySafe: true,
20
- async execute({ id }) {
21
- const removed = await store.delete(asMemoryId(id))
27
+ async execute({ id, revision }) {
28
+ if (revision !== undefined && !hasConditionalMemoryWrites(store)) {
29
+ return {
30
+ success: false,
31
+ output: 'This memory store does not support revision-checked deletion.',
32
+ error: 'Conditional memory deletion unavailable',
33
+ }
34
+ }
35
+ let removed: boolean
36
+ try {
37
+ if (revision === undefined) {
38
+ removed = await store.delete(asMemoryId(id))
39
+ } else if (hasConditionalMemoryWrites(store)) {
40
+ await store.deleteIfRevision(asMemoryId(id), revision)
41
+ removed = true
42
+ } else throw new Error('Conditional memory support changed during the deletion.')
43
+ } catch (error) {
44
+ if (!(error instanceof MemoryRevisionConflictError)) throw error
45
+ return {
46
+ success: false,
47
+ output: error.message,
48
+ error: 'Memory changed since read',
49
+ data: { reason: 'revision_conflict' },
50
+ }
51
+ }
22
52
  return {
23
53
  success: removed,
24
54
  output: removed ? `Memory deleted: ${id}.` : `Memory ${id} not found.`,