@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.
- package/CHANGELOG.md +45 -0
- package/dist/config/registry.d.ts +2 -0
- package/dist/config/registry.d.ts.map +1 -1
- package/dist/config/registry.js +23 -3
- package/dist/config/registry.js.map +1 -1
- package/dist/connector/mcp/discovery.d.ts +9 -5
- package/dist/connector/mcp/discovery.d.ts.map +1 -1
- package/dist/connector/mcp/discovery.js +16 -8
- package/dist/connector/mcp/discovery.js.map +1 -1
- package/dist/connector/mcp/mcp-toolset.d.ts +3 -0
- package/dist/connector/mcp/mcp-toolset.d.ts.map +1 -1
- package/dist/connector/mcp/mcp-toolset.js +20 -2
- package/dist/connector/mcp/mcp-toolset.js.map +1 -1
- package/dist/connector/mcp/reconnect.d.ts +2 -0
- package/dist/connector/mcp/reconnect.d.ts.map +1 -1
- package/dist/connector/mcp/reconnect.js +36 -5
- package/dist/connector/mcp/reconnect.js.map +1 -1
- package/dist/plugin/lifecycle.d.ts +14 -10
- package/dist/plugin/lifecycle.d.ts.map +1 -1
- package/dist/plugin/lifecycle.js +178 -41
- package/dist/plugin/lifecycle.js.map +1 -1
- package/dist/prompt/resident-learning.js +2 -2
- package/dist/prompt/resident-learning.js.map +1 -1
- package/dist/prompt/resident-step.d.ts +2 -2
- package/dist/prompt/resident-step.js +4 -4
- package/dist/prompt/resident-step.js.map +1 -1
- package/dist/public-runtime.d.ts +2 -2
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +2 -2
- package/dist/public-runtime.js.map +1 -1
- package/dist/registry/tool/screen.d.ts.map +1 -1
- package/dist/registry/tool/screen.js +33 -2
- package/dist/registry/tool/screen.js.map +1 -1
- package/dist/runtime/jobs/registry.d.ts.map +1 -1
- package/dist/runtime/jobs/registry.js +41 -12
- package/dist/runtime/jobs/registry.js.map +1 -1
- package/dist/runtime/query/index.d.ts +6 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +20 -1
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +32 -8
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/outstanding-work.d.ts.map +1 -1
- package/dist/runtime/query/iteration/outstanding-work.js +2 -0
- package/dist/runtime/query/iteration/outstanding-work.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +4 -0
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.js +7 -1
- package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
- package/dist/runtime/query/iteration/step-shaping.d.ts +2 -0
- package/dist/runtime/query/iteration/step-shaping.d.ts.map +1 -1
- package/dist/runtime/query/iteration/step-shaping.js +5 -1
- package/dist/runtime/query/iteration/step-shaping.js.map +1 -1
- package/dist/store/index.d.ts +1 -0
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +1 -0
- package/dist/store/index.js.map +1 -1
- package/dist/store/memory/disk.d.ts +7 -2
- package/dist/store/memory/disk.d.ts.map +1 -1
- package/dist/store/memory/disk.js +58 -5
- package/dist/store/memory/disk.js.map +1 -1
- package/dist/store/memory/index.d.ts +9 -0
- package/dist/store/memory/index.d.ts.map +1 -1
- package/dist/store/memory/index.js +43 -5
- package/dist/store/memory/index.js.map +1 -1
- package/dist/store/memory/markdown.d.ts +7 -2
- package/dist/store/memory/markdown.d.ts.map +1 -1
- package/dist/store/memory/markdown.js +33 -0
- package/dist/store/memory/markdown.js.map +1 -1
- package/dist/store/memory/memory.d.ts +7 -2
- package/dist/store/memory/memory.d.ts.map +1 -1
- package/dist/store/memory/memory.js +34 -0
- package/dist/store/memory/memory.js.map +1 -1
- package/dist/store/memory/revision.d.ts +16 -0
- package/dist/store/memory/revision.d.ts.map +1 -0
- package/dist/store/memory/revision.js +144 -0
- package/dist/store/memory/revision.js.map +1 -0
- package/dist/tools/builtins/wait-for-job-bounds.d.ts +6 -4
- package/dist/tools/builtins/wait-for-job-bounds.d.ts.map +1 -1
- package/dist/tools/builtins/wait-for-job-bounds.js +46 -8
- package/dist/tools/builtins/wait-for-job-bounds.js.map +1 -1
- package/dist/tools/builtins/wait-for-job.d.ts +1 -0
- package/dist/tools/builtins/wait-for-job.d.ts.map +1 -1
- package/dist/tools/builtins/wait-for-job.js +24 -10
- package/dist/tools/builtins/wait-for-job.js.map +1 -1
- package/dist/tools/coordinator/wait-with-idle-bound.d.ts.map +1 -1
- package/dist/tools/coordinator/wait-with-idle-bound.js +4 -0
- package/dist/tools/coordinator/wait-with-idle-bound.js.map +1 -1
- package/dist/tools/memory/delete.d.ts.map +1 -1
- package/dist/tools/memory/delete.js +38 -3
- package/dist/tools/memory/delete.js.map +1 -1
- package/dist/tools/memory/read.d.ts.map +1 -1
- package/dist/tools/memory/read.js +38 -2
- package/dist/tools/memory/read.js.map +1 -1
- package/dist/tools/memory/search.d.ts.map +1 -1
- package/dist/tools/memory/search.js +38 -7
- package/dist/tools/memory/search.js.map +1 -1
- package/dist/tools/memory/update.d.ts.map +1 -1
- package/dist/tools/memory/update.js +30 -3
- package/dist/tools/memory/update.js.map +1 -1
- package/dist/toolsets/manager.d.ts +13 -0
- package/dist/toolsets/manager.d.ts.map +1 -1
- package/dist/toolsets/manager.js +60 -1
- package/dist/toolsets/manager.js.map +1 -1
- package/dist/turn/memory-recall.d.ts +2 -0
- package/dist/turn/memory-recall.d.ts.map +1 -1
- package/dist/turn/memory-recall.js +3 -1
- package/dist/turn/memory-recall.js.map +1 -1
- package/dist/types/memory/index.d.ts +29 -0
- package/dist/types/memory/index.d.ts.map +1 -1
- package/dist/types/memory/index.js +6 -0
- package/dist/types/memory/index.js.map +1 -1
- package/dist/types/plugin/index.d.ts +12 -2
- package/dist/types/plugin/index.d.ts.map +1 -1
- package/dist/types/plugin/index.js +5 -2
- package/dist/types/plugin/index.js.map +1 -1
- package/package.json +1 -1
- package/src/config/registry.ts +24 -3
- package/src/connector/mcp/discovery.ts +29 -8
- package/src/connector/mcp/mcp-toolset.ts +25 -4
- package/src/connector/mcp/reconnect.ts +34 -4
- package/src/plugin/lifecycle.ts +217 -46
- package/src/prompt/resident-learning.ts +2 -2
- package/src/prompt/resident-step.ts +4 -4
- package/src/public-runtime.ts +2 -0
- package/src/registry/tool/screen.ts +34 -2
- package/src/runtime/jobs/registry.ts +39 -15
- package/src/runtime/query/index.ts +24 -1
- package/src/runtime/query/iteration/index.ts +35 -10
- package/src/runtime/query/iteration/outstanding-work.ts +2 -0
- package/src/runtime/query/iteration/phases/context.ts +4 -0
- package/src/runtime/query/iteration/phases/tool-review.ts +6 -1
- package/src/runtime/query/iteration/step-shaping.ts +9 -1
- package/src/store/index.ts +1 -0
- package/src/store/memory/disk.ts +82 -7
- package/src/store/memory/index.ts +66 -7
- package/src/store/memory/markdown.ts +54 -2
- package/src/store/memory/memory.ts +52 -2
- package/src/store/memory/revision.ts +158 -0
- package/src/tools/builtins/wait-for-job-bounds.ts +45 -7
- package/src/tools/builtins/wait-for-job.ts +25 -11
- package/src/tools/coordinator/wait-with-idle-bound.ts +3 -0
- package/src/tools/memory/delete.ts +33 -3
- package/src/tools/memory/read.ts +35 -2
- package/src/tools/memory/search.ts +38 -7
- package/src/tools/memory/update.ts +28 -3
- package/src/toolsets/manager.ts +74 -1
- package/src/turn/memory-recall.ts +5 -1
- package/src/types/memory/index.ts +43 -0
- 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
|
|
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
|
|
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,
|
|
89
|
+
* Await a job under both bounds, retaining a bounded tail as it goes.
|
|
72
90
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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)
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
150
|
-
|
|
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: `${
|
|
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
|
-
|
|
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.`,
|