@try-works/dsh-memory 0.1.32 → 0.2.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/STATUS.md +27 -2
- package/package.json +1 -1
- package/src/assets/describe.js +181 -0
- package/src/assets/entry.js +169 -0
- package/src/assets/index.js +648 -0
- package/src/assets/lfs.js +169 -0
- package/src/assets/objects.js +199 -0
- package/src/assets/policy.js +259 -0
- package/src/config.js +12 -0
- package/src/defaults.js +11 -0
- package/src/entry.js +58 -13
- package/src/host.js +202 -3
- package/src/refresh.js +7 -2
- package/src/search.js +47 -3
- package/src/tools.js +735 -10
package/src/tools.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
|
-
// Agent tools: memory_search / remember / workspaces / associate / sync / forget
|
|
2
|
-
|
|
3
|
-
import {
|
|
1
|
+
// Agent tools: memory_search / remember / workspaces / associate / sync / forget, plus the
|
|
2
|
+
// memory_asset_* surface of the asset store.
|
|
3
|
+
import { existsSync, realpathSync, statSync } from 'node:fs'
|
|
4
|
+
import { basename, isAbsolute, resolve } from 'node:path'
|
|
5
|
+
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
|
|
4
6
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
5
7
|
import { search } from './search.js'
|
|
6
8
|
import { remember } from './remember.js'
|
|
7
9
|
import { summarizeState, writeStateSection } from './state.js'
|
|
8
10
|
import { rerankHits } from './rerank.js'
|
|
9
|
-
import { logRun } from './telemetry.js'
|
|
11
|
+
import { logRun, record } from './telemetry.js'
|
|
10
12
|
import { isSubagentSession } from './session.js'
|
|
11
13
|
import { appendActivity, clearActivity, readWorking, workingSummary, writeWorkingSection } from './working.js'
|
|
12
14
|
import { associate, listWorkspaces } from './workspace.js'
|
|
13
15
|
import { forget, forgetUndo } from './forget.js'
|
|
14
16
|
import { buildMap } from './map.js'
|
|
15
17
|
import { backupTasks, defaultSchedulePath, describeTask, listTasks, planRestore, readScheduleStore, TASK_STATES, writeTask } from './tasks.js'
|
|
18
|
+
import { parseModelRef } from './defaults.js'
|
|
19
|
+
import { describeAsset, usedForFrom } from './assets/describe.js'
|
|
16
20
|
|
|
17
21
|
function renderHits(hits) {
|
|
18
22
|
if (!hits || hits.length === 0) return [{ type: 'text', text: '(no memory matches)' }]
|
|
@@ -104,6 +108,313 @@ export function registerTools(ctx, store, extras = {}) {
|
|
|
104
108
|
const tasksRestore = extras.tasksRestore
|
|
105
109
|
const schedulePathOf = extras.schedulePath
|
|
106
110
|
|
|
111
|
+
// ---------------------------------------------------------------------------------------------
|
|
112
|
+
// Asset store surface
|
|
113
|
+
//
|
|
114
|
+
// src/assets/index.js is loaded LAZILY, at the first asset call that needs it. Two reasons:
|
|
115
|
+
// the module is a different owner's work in flight (a top-level import would make the whole
|
|
116
|
+
// plugin unloadable if it were temporarily missing), and nothing should touch the store until
|
|
117
|
+
// an asset operation actually runs. `extras.assets` is the test/embedding seam - production
|
|
118
|
+
// passes nothing and the real module is imported.
|
|
119
|
+
// ---------------------------------------------------------------------------------------------
|
|
120
|
+
let assetsModulePromise
|
|
121
|
+
const assetsOverride = extras.assets
|
|
122
|
+
async function assetsApi() {
|
|
123
|
+
if (assetsOverride) return assetsOverride
|
|
124
|
+
assetsModulePromise = assetsModulePromise ?? import('./assets/index.js')
|
|
125
|
+
return assetsModulePromise
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The resolved configuration. MemoryStore already holds it (src/store.js: this.config). */
|
|
129
|
+
function assetConfig() { return store.config ?? {} }
|
|
130
|
+
|
|
131
|
+
function formatBytes(bytes) {
|
|
132
|
+
const value = Number(bytes) || 0
|
|
133
|
+
if (value >= 1024 * 1024 * 1024) return (value / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
|
|
134
|
+
if (value >= 1024 * 1024) return (value / (1024 * 1024)).toFixed(1) + ' MB'
|
|
135
|
+
if (value >= 1024) return (value / 1024).toFixed(1) + ' KB'
|
|
136
|
+
return value + ' bytes'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The git runner assets-core needs for its LFS gate. Services are resolved at USE - this runs
|
|
141
|
+
* inside putAsset, during a tool call, when the host is fully mounted - never at wire time.
|
|
142
|
+
*/
|
|
143
|
+
async function assetGitRunner(argv, options = {}) {
|
|
144
|
+
const subprocess = ctx.get('subprocess')
|
|
145
|
+
if (!subprocess || typeof subprocess.spawn !== 'function' || typeof subprocess.resolveExecutable !== 'function') {
|
|
146
|
+
return { exitCode: 127, stdout: '', stderr: 'subprocess service unavailable' }
|
|
147
|
+
}
|
|
148
|
+
let executable
|
|
149
|
+
try { executable = await subprocess.resolveExecutable('git', undefined, undefined) } catch { executable = null }
|
|
150
|
+
if (!executable) return { exitCode: 127, stdout: '', stderr: 'git unavailable' }
|
|
151
|
+
try {
|
|
152
|
+
const handle = subprocess.spawn({
|
|
153
|
+
argv: [executable, ...argv],
|
|
154
|
+
cwd: options.cwd,
|
|
155
|
+
stdio: { stdin: 'ignore', stdout: { maxBytes: 4 * 1024 * 1024 }, stderr: { maxBytes: 16 * 1024 } },
|
|
156
|
+
graceMs: 2000,
|
|
157
|
+
env: { GIT_CONFIG_COUNT: '0', GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C' },
|
|
158
|
+
})
|
|
159
|
+
const outcome = await handle.done
|
|
160
|
+
const stdout = handle.collected?.stdout?.readFrom?.(0)?.text ?? ''
|
|
161
|
+
const stderr = handle.collected?.stderr?.readFrom?.(0)?.text ?? ''
|
|
162
|
+
return { exitCode: outcome?.exitCode ?? null, stdout, stderr }
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return { exitCode: 1, stdout: '', stderr: String(error) }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One async (prompt) => text for the description model, or undefined when nothing is routable.
|
|
170
|
+
* Resolved at use from the live services: the configured memory model first, then the session's
|
|
171
|
+
* route, then anything the registry can enumerate. The description is optional by design, so
|
|
172
|
+
* every failure here ends in describe.js's deterministic fallback rather than in a failed put.
|
|
173
|
+
*/
|
|
174
|
+
async function assetDescribeLlm(agent) {
|
|
175
|
+
const llm = ctx.get('llm')
|
|
176
|
+
if (!llm || typeof llm.stream !== 'function') return undefined
|
|
177
|
+
const cfg = assetConfig()
|
|
178
|
+
const configured = parseModelRef(cfg.summaryModel)
|
|
179
|
+
const header = agent?.session?.requestHeader?.()?.config
|
|
180
|
+
const sessionRoute = header && header.provider && header.model ? { provider: header.provider, model: header.model } : undefined
|
|
181
|
+
let target = configured ?? sessionRoute
|
|
182
|
+
if (!target) {
|
|
183
|
+
try {
|
|
184
|
+
for (const provider of llm.listProviders?.() ?? []) {
|
|
185
|
+
try {
|
|
186
|
+
const models = await llm.listModels(provider.id)
|
|
187
|
+
if (models && models.length > 0) { target = { provider: provider.id, model: models[0].id }; break }
|
|
188
|
+
} catch { /* one unroutable provider must not hide the description */ }
|
|
189
|
+
}
|
|
190
|
+
} catch { /* fall through to the deterministic fallback */ }
|
|
191
|
+
}
|
|
192
|
+
if (!target) return undefined
|
|
193
|
+
return async (prompt) => {
|
|
194
|
+
record(store.root, 'model-call', { modelCalls: 1, purpose: 'asset-describe', detail: { provider: target.provider, model: target.model } })
|
|
195
|
+
const assembler = new BlockAssembler()
|
|
196
|
+
for await (const chunk of llm.stream({
|
|
197
|
+
provider: target.provider,
|
|
198
|
+
model: target.model,
|
|
199
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
|
200
|
+
maxTokens: 700,
|
|
201
|
+
purpose: 'asset-describe',
|
|
202
|
+
})) assembler.push(chunk)
|
|
203
|
+
return assembler.blocks().filter((block) => block && block.type === 'text').map((block) => block.text || '').join('\n').trim()
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The describe hook assets-core awaits inside putAsset: one model call, never file contents. */
|
|
208
|
+
async function assetDescribeHook(agent) {
|
|
209
|
+
// assetsDescribe: false is the "never spend a model call on documentation" switch. When it is
|
|
210
|
+
// off, no hook is passed and the store's own deterministic floor writes the sections.
|
|
211
|
+
if (assetConfig().assetsDescribe === false) return undefined
|
|
212
|
+
const llm = await assetDescribeLlm(agent)
|
|
213
|
+
return (context) => describeAsset(context, { llm })
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The durable attachment reference -> the bytes or a host path putAsset can copy. */
|
|
217
|
+
async function attachmentSource(attachment) {
|
|
218
|
+
const attachments = ctx.get('attachments')
|
|
219
|
+
if (!attachments) {
|
|
220
|
+
throw new Error('memory_asset_put: reading an attachment needs the attachments service, which is unavailable here; pass path or base64 instead')
|
|
221
|
+
}
|
|
222
|
+
const ref = typeof attachment === 'string' ? { attachmentId: attachment } : (attachment ?? {})
|
|
223
|
+
const image = ref.kind === 'image' || (typeof ref.mediaType === 'string' && ref.mediaType.startsWith('image/'))
|
|
224
|
+
if (image) {
|
|
225
|
+
// A durable image reference often has no display name. The store classifies and names by the
|
|
226
|
+
// media type, so synthesize the extension rather than passing an unnameable blob through.
|
|
227
|
+
const name = ref.name ?? ('image.' + ({ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' }[ref.mediaType] ?? 'png'))
|
|
228
|
+
const hostPath = typeof attachments.imageHostPath === 'function' ? attachments.imageHostPath(ref) : undefined
|
|
229
|
+
if (hostPath && existsSync(hostPath)) return { path: hostPath, name, mime: ref.mediaType }
|
|
230
|
+
if (typeof attachments.readImage !== 'function') throw new Error('memory_asset_put: this attachment provider cannot read images; pass path or base64 instead')
|
|
231
|
+
const stored = await attachments.readImage(ref)
|
|
232
|
+
return { bytes: stored?.data, name, mime: ref.mediaType }
|
|
233
|
+
}
|
|
234
|
+
if (!ref.name) throw new Error('memory_asset_put: a file attachment needs its name to be resolved; pass the durable reference as it appears in the message')
|
|
235
|
+
const fileRef = { attachmentId: ref.attachmentId, name: ref.name, bytes: ref.bytes }
|
|
236
|
+
const hostPath = typeof attachments.fileHostPath === 'function' ? attachments.fileHostPath(fileRef) : undefined
|
|
237
|
+
if (hostPath && existsSync(hostPath)) return { path: hostPath, name: ref.name }
|
|
238
|
+
if (typeof attachments.readFileStream !== 'function') throw new Error('memory_asset_put: this attachment provider cannot read files; pass path or base64 instead')
|
|
239
|
+
const chunks = []
|
|
240
|
+
for await (const chunk of attachments.readFileStream(fileRef)) chunks.push(Buffer.from(chunk))
|
|
241
|
+
return { bytes: Buffer.concat(chunks), name: ref.name }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* How many bytes a capture carries, for quota arithmetic. Only the two shapes putAsset accepts
|
|
246
|
+
* are measured; anything else counts as zero and the store's own check remains the authority.
|
|
247
|
+
*/
|
|
248
|
+
function captureBytes(input) {
|
|
249
|
+
if (input.bytes && typeof input.bytes.byteLength === 'number') return input.bytes.byteLength
|
|
250
|
+
if (input.path) {
|
|
251
|
+
try { return statSync(input.path).size } catch { return 0 }
|
|
252
|
+
}
|
|
253
|
+
return 0
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function scopeQuota(policy, scope, key) {
|
|
257
|
+
if (scope === 'workspace') {
|
|
258
|
+
const entry = policy.workspaces?.[key]
|
|
259
|
+
return entry && Number.isFinite(entry.quotaBytes) ? entry.quotaBytes : undefined
|
|
260
|
+
}
|
|
261
|
+
return policy.global && Number.isFinite(policy.global.quotaBytes) ? policy.global.quotaBytes : undefined
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function setScopeQuota(policy, scope, key, quotaBytes) {
|
|
265
|
+
if (scope === 'workspace') {
|
|
266
|
+
policy.workspaces = policy.workspaces ?? {}
|
|
267
|
+
policy.workspaces[key] = { ...(policy.workspaces[key] ?? {}), quotaBytes }
|
|
268
|
+
} else {
|
|
269
|
+
policy.global = { ...(policy.global ?? {}), quotaBytes }
|
|
270
|
+
}
|
|
271
|
+
return policy
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The one question a quota refusal may put to the user. Four options, exactly. */
|
|
275
|
+
function quotaQuestion(detail = {}) {
|
|
276
|
+
const scopeName = detail.scope === 'workspace'
|
|
277
|
+
? 'workspace ' + String(detail.key ?? '')
|
|
278
|
+
: 'the global scope'
|
|
279
|
+
const largest = Array.isArray(detail.largest) ? detail.largest : []
|
|
280
|
+
const listed = largest.length
|
|
281
|
+
? largest.map((row) => String(row.name ?? row.id) + ' (' + formatBytes(row.bytes) + ')').join(', ')
|
|
282
|
+
: '(no other assets in this scope)'
|
|
283
|
+
return {
|
|
284
|
+
id: 'asset-quota',
|
|
285
|
+
header: 'Memory asset quota',
|
|
286
|
+
question: 'Asset quota reached for ' + scopeName + ' (' + formatBytes(detail.usedBytes) + ' of ' + formatBytes(detail.quotaBytes) + ').',
|
|
287
|
+
detail: 'The largest assets are: ' + listed + '. '
|
|
288
|
+
+ '"Forget an asset to make room" forgets the largest listed asset(s) until this capture fits. '
|
|
289
|
+
+ '"Raise" doubles this scope\'s quota in state/assets.json. '
|
|
290
|
+
+ 'Nothing is deleted unless you choose Forget.',
|
|
291
|
+
options: [
|
|
292
|
+
{ label: 'Forgive this capture', description: 'Store this one asset without changing the quota.' },
|
|
293
|
+
{ label: "Raise this workspace's quota", description: 'Double the quota for ' + scopeName + ' and retry.' },
|
|
294
|
+
{ label: 'Forget an asset to make room', description: 'Forget the largest listed asset(s), then retry the capture.' },
|
|
295
|
+
{ label: 'Cancel', description: 'Store nothing; nothing is deleted.' },
|
|
296
|
+
],
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Map a user-questions answer onto one of the four decisions. Unknown answers cancel. */
|
|
301
|
+
function quotaChoice(answer, question) {
|
|
302
|
+
const batch = Array.isArray(answer?.answers) ? answer.answers : []
|
|
303
|
+
const item = batch.find((entry) => entry && entry.id === question.id) ?? batch[0]
|
|
304
|
+
const selected = Array.isArray(item?.selected) ? item.selected : []
|
|
305
|
+
const text = [selected[0], item?.custom].filter(Boolean).join(' ').toLowerCase()
|
|
306
|
+
if (text.includes('forgive')) return 'forgive'
|
|
307
|
+
if (text.includes('raise')) return 'raise'
|
|
308
|
+
if (text.includes('forget')) return 'forget'
|
|
309
|
+
return 'cancel'
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Put one asset, and if the scope quota refuses it, ASK THE USER (exactly once) and act.
|
|
314
|
+
*
|
|
315
|
+
* This is the only path that asks: an explicit memory_asset_put has a caller waiting for the
|
|
316
|
+
* answer. Automatic attachment capture stays silent and records a notice instead. Each answer
|
|
317
|
+
* has a distinct effect:
|
|
318
|
+
* Forgive - retry once with allowOverQuota (the store's one-shot bypass); policy unchanged.
|
|
319
|
+
* Raise - double the scope quota in the policy, then retry normally.
|
|
320
|
+
* Forget - forget the largest listed asset(s) until the capture fits, then retry.
|
|
321
|
+
* Cancel - return the refusal untouched; nothing is stored and nothing is deleted.
|
|
322
|
+
* A missing or failing user-questions service degrades to the plain refusal.
|
|
323
|
+
*/
|
|
324
|
+
async function putAssetWithQuotaDecision(assets, input, exec) {
|
|
325
|
+
const cfg = assetConfig()
|
|
326
|
+
const first = await assets.putAsset(store, cfg, input)
|
|
327
|
+
if (first?.ok || first?.code !== 'quota-exceeded') return first
|
|
328
|
+
const questions = ctx.get('userQuestions')
|
|
329
|
+
if (!questions || typeof questions.ask !== 'function') return first
|
|
330
|
+
const detail = first.detail ?? {}
|
|
331
|
+
const question = quotaQuestion(detail)
|
|
332
|
+
let answer
|
|
333
|
+
try {
|
|
334
|
+
answer = await questions.ask({
|
|
335
|
+
questions: [question],
|
|
336
|
+
...(exec?.agent ? { agent: exec.agent } : {}),
|
|
337
|
+
...(exec?.signal ? { signal: exec.signal } : {}),
|
|
338
|
+
})
|
|
339
|
+
} catch {
|
|
340
|
+
// Asking failed (headless, aborted, no answerer): the refusal is the honest result.
|
|
341
|
+
return first
|
|
342
|
+
}
|
|
343
|
+
const choice = quotaChoice(answer, question)
|
|
344
|
+
if (choice === 'forgive') {
|
|
345
|
+
return assets.putAsset(store, cfg, { ...input, allowOverQuota: true })
|
|
346
|
+
}
|
|
347
|
+
if (choice === 'raise') {
|
|
348
|
+
try {
|
|
349
|
+
const policy = assets.readPolicy(store.root)
|
|
350
|
+
const current = Number(detail.quotaBytes) || scopeQuota(policy, detail.scope, detail.key) || 0
|
|
351
|
+
const next = Math.max(current * 2, (Number(detail.usedBytes) || 0) + captureBytes(input))
|
|
352
|
+
if (Number.isSafeInteger(next) && next > 0) {
|
|
353
|
+
assets.writePolicy(store.root, setScopeQuota(policy, detail.scope, detail.key, next))
|
|
354
|
+
}
|
|
355
|
+
} catch { /* an unwritable policy degrades to the refusal the retry will produce */ }
|
|
356
|
+
return assets.putAsset(store, cfg, input)
|
|
357
|
+
}
|
|
358
|
+
if (choice === 'forget') {
|
|
359
|
+
const deficit = Math.max(0, (Number(detail.usedBytes) || 0) + captureBytes(input) - (Number(detail.quotaBytes) || 0))
|
|
360
|
+
let freed = 0
|
|
361
|
+
for (const candidate of Array.isArray(detail.largest) ? detail.largest : []) {
|
|
362
|
+
if (freed >= deficit) break
|
|
363
|
+
try {
|
|
364
|
+
const result = await assets.forgetAsset(store, cfg, { id: candidate.id })
|
|
365
|
+
if (result?.ok) freed += Number(result.bytesFreed) || 0
|
|
366
|
+
} catch { /* keep trying the next listed asset */ }
|
|
367
|
+
}
|
|
368
|
+
return assets.putAsset(store, cfg, input)
|
|
369
|
+
}
|
|
370
|
+
return first
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Turn a putAsset result into the tool's lossless, honest JSON. */
|
|
374
|
+
function putResultValue(result, { scope, key }) {
|
|
375
|
+
if (result && result.ok === true) {
|
|
376
|
+
const id = String(result.entryId ?? '')
|
|
377
|
+
return {
|
|
378
|
+
ok: true,
|
|
379
|
+
id,
|
|
380
|
+
// Both names, because the store's contract calls it entryId and every memory_* tool calls
|
|
381
|
+
// the same thing id; an agent reading either should not have to guess.
|
|
382
|
+
entryId: id,
|
|
383
|
+
sha256: String(result.sha256 ?? ''),
|
|
384
|
+
bytes: Number(result.bytes) || 0,
|
|
385
|
+
deduped: Boolean(result.deduped),
|
|
386
|
+
...(result.entryPath ? { file: String(result.entryPath) } : {}),
|
|
387
|
+
scope,
|
|
388
|
+
...(key ? { key: String(key) } : {}),
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const failure = result && typeof result === 'object' ? result : { code: 'write-failed', message: 'the asset store returned no result' }
|
|
392
|
+
const code = String(failure.code ?? 'write-failed')
|
|
393
|
+
return {
|
|
394
|
+
ok: false,
|
|
395
|
+
code,
|
|
396
|
+
message: String(failure.message ?? 'the capture was refused'),
|
|
397
|
+
...(failure.detail && typeof failure.detail === 'object' ? { detail: failure.detail } : {}),
|
|
398
|
+
...(code === 'lfs-unavailable' ? { hint: 'This store syncs asset bytes through Git LFS. Install `git lfs` on the host and retry; nothing was stored.' } : {}),
|
|
399
|
+
scope,
|
|
400
|
+
...(key ? { key: String(key) } : {}),
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Turn any other contract refusal into the tool's lossless, honest JSON. */
|
|
405
|
+
function refusalValue(result, { hint } = {}) {
|
|
406
|
+
const failure = result && typeof result === 'object' ? result : { code: 'read-failed', message: 'the asset store returned no result' }
|
|
407
|
+
const code = String(failure.code ?? 'read-failed')
|
|
408
|
+
return {
|
|
409
|
+
ok: false,
|
|
410
|
+
code,
|
|
411
|
+
message: String(failure.message ?? 'the asset operation was refused'),
|
|
412
|
+
...(failure.detail && typeof failure.detail === 'object' ? { detail: failure.detail } : {}),
|
|
413
|
+
...(hint ? { hint } : {}),
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
|
|
107
418
|
ctx.tools.register(defineTool({
|
|
108
419
|
name: 'memory_search',
|
|
109
420
|
description: 'Search the dsh-memory store (global + workspace layers). Returns ranked facts with file:line citations.',
|
|
@@ -130,6 +441,17 @@ export function registerTools(ctx, store, extras = {}) {
|
|
|
130
441
|
snippet: { type: 'string', required: true },
|
|
131
442
|
id: { type: 'string' },
|
|
132
443
|
score: { type: 'number' },
|
|
444
|
+
asset: {
|
|
445
|
+
type: 'object', additionalProperties: false,
|
|
446
|
+
description: 'Present when the hit is an asset entry: the blob exists and can be fetched.',
|
|
447
|
+
properties: {
|
|
448
|
+
sha256: { type: 'string', required: true },
|
|
449
|
+
mime: { type: 'string', required: true },
|
|
450
|
+
name: { type: 'string', required: true },
|
|
451
|
+
bytes: { type: 'integer', required: true },
|
|
452
|
+
class: { type: 'string', required: true },
|
|
453
|
+
},
|
|
454
|
+
},
|
|
133
455
|
},
|
|
134
456
|
},
|
|
135
457
|
},
|
|
@@ -560,6 +882,10 @@ export function registerTools(ctx, store, extras = {}) {
|
|
|
560
882
|
modelCalls: { type: 'integer' },
|
|
561
883
|
eventCounts: { type: 'object', additionalProperties: true },
|
|
562
884
|
savedBy: { type: 'object', additionalProperties: true },
|
|
885
|
+
assets: {
|
|
886
|
+
type: 'object', additionalProperties: true,
|
|
887
|
+
description: 'Asset bytes and any quota notice an automatic capture could not ask about.',
|
|
888
|
+
},
|
|
563
889
|
queue: {
|
|
564
890
|
type: 'object', additionalProperties: false,
|
|
565
891
|
properties: {
|
|
@@ -589,18 +915,30 @@ export function registerTools(ctx, store, extras = {}) {
|
|
|
589
915
|
parts.push('needs attention: ' + classes.join(', ') + (attention.length > 3 ? ', +' + (attention.length - 3) + ' more kinds' : ''))
|
|
590
916
|
}
|
|
591
917
|
if (value.conflicted) parts.push('THE LAST SYNC CONFLICTED (see state/CONFLICT.md)')
|
|
918
|
+
const assets = value.assets
|
|
919
|
+
if (assets) {
|
|
920
|
+
const counts = assets.perClass ?? {}
|
|
921
|
+
parts.push('assets ' + formatBytes(assets.totalBytes) + ' (' + Object.entries(counts).map(([cls, n]) => n + ' ' + cls).join(', ') + ')')
|
|
922
|
+
const notices = assets.quotaNotices ?? []
|
|
923
|
+
// A quota wall an automatic capture hit is surfaced here because no one was asked at the
|
|
924
|
+
// time. It is a notice, not an error: nothing was dropped, and nothing was deleted.
|
|
925
|
+
if (notices.length) {
|
|
926
|
+
parts.push('asset quota notices: ' + notices.map((notice) => (notice.scope === 'global' ? 'global' : 'workspace ' + notice.key) + ' (' + formatBytes(notice.usedBytes) + ' of ' + formatBytes(notice.quotaBytes) + ')').join(', '))
|
|
927
|
+
}
|
|
928
|
+
}
|
|
592
929
|
parts.push('model calls ' + (value.modelCalls || 0))
|
|
593
930
|
return [{ type: 'text', text: parts.join('; ') + '.' }]
|
|
594
931
|
},
|
|
595
932
|
},
|
|
596
933
|
async execute() {
|
|
597
934
|
if (!statusNow) throw new Error('memory_status is unavailable in this context')
|
|
598
|
-
const s = statusNow()
|
|
935
|
+
const s = await statusNow()
|
|
599
936
|
return {
|
|
600
937
|
root: s.root, pendingDigests: s.pendingDigests, conflicted: s.conflicted,
|
|
601
938
|
...(s.lastSync && typeof s.lastSync.ok === 'boolean' ? { lastSyncOk: s.lastSync.ok } : {}),
|
|
602
939
|
modelCalls: s.metrics?.modelCalls ?? 0, eventCounts: s.metrics?.events ?? {}, savedBy: s.metrics?.savedBy ?? {},
|
|
603
940
|
...(s.queue ? { queue: s.queue } : {}),
|
|
941
|
+
...(s.assets ? { assets: s.assets } : {}),
|
|
604
942
|
}
|
|
605
943
|
},
|
|
606
944
|
}))
|
|
@@ -758,11 +1096,11 @@ export function registerTools(ctx, store, extras = {}) {
|
|
|
758
1096
|
if (!syncNow) return { synced: false }
|
|
759
1097
|
const status = await syncNow()
|
|
760
1098
|
return {
|
|
761
|
-
// `synced` means SYNCED, not 'it did something'. It used to include committed and
|
|
762
|
-
// initialized, so a push that FAILED still reported synced:true whenever the local commit
|
|
763
|
-
// had succeeded - the result read {"synced":true,"conflict":true}, claiming success and
|
|
764
|
-
// failure at once. A local commit is progress, not a sync, and saying otherwise is the same
|
|
765
|
-
// defect as every other one this layer has produced: a green answer over a silent failure.
|
|
1099
|
+
// `synced` means SYNCED, not 'it did something'. It used to include committed and
|
|
1100
|
+
// initialized, so a push that FAILED still reported synced:true whenever the local commit
|
|
1101
|
+
// had succeeded - the result read {"synced":true,"conflict":true}, claiming success and
|
|
1102
|
+
// failure at once. A local commit is progress, not a sync, and saying otherwise is the same
|
|
1103
|
+
// defect as every other one this layer has produced: a green answer over a silent failure.
|
|
766
1104
|
synced: Boolean(status && status.synced),
|
|
767
1105
|
...(status && status.conflict ? { conflict: true } : {}),
|
|
768
1106
|
...(status && status.error ? { error: String(status.error) } : {}),
|
|
@@ -792,6 +1130,393 @@ export function registerTools(ctx, store, extras = {}) {
|
|
|
792
1130
|
},
|
|
793
1131
|
}))
|
|
794
1132
|
|
|
1133
|
+
ctx.tools.register(defineTool({
|
|
1134
|
+
name: 'memory_asset_put',
|
|
1135
|
+
description: 'Store a file in the memory asset store: documents (PDF/DOCX/PPTX/TXT/MD), media (PNG/JPEG/WebP/GIF) and data (CSV/TSV/JSON/XLSX), subject to the class toggles. The bytes are deduplicated by sha256 and the entry is documented from the capture context (never by reading the file). Returns the entry id and whether the content was already present. If the scope quota refuses the capture, ONE question is put to the user (forgive / raise / forget / cancel) and the answer is acted on.',
|
|
1136
|
+
parameters: {
|
|
1137
|
+
path: { type: 'string', description: 'Path to the file to store. One of path, base64, or attachment is required.' },
|
|
1138
|
+
base64: { type: 'string', description: 'The file bytes, canonical base64. One of path, base64, or attachment is required.' },
|
|
1139
|
+
attachment: { type: 'object', additionalProperties: true, description: 'A durable attachment reference from a session message: { attachmentId, name?, mediaType?, bytes?, kind? }. One of path, base64, or attachment is required.' },
|
|
1140
|
+
name: { type: 'string', description: 'Display name. Defaults to the file name or a synthesized image name.' },
|
|
1141
|
+
mime: { type: 'string', description: 'Media type. Inferred from the name when omitted; images use the attachment media type.' },
|
|
1142
|
+
class: { type: 'string', description: 'documents, media, or data. Inferred from the name/mime when omitted.' },
|
|
1143
|
+
scope: { type: 'string', description: 'global or workspace. Defaults to the session workspace when there is one, else global.' },
|
|
1144
|
+
workspace: { type: 'string', description: 'Workspace key when scope is workspace.' },
|
|
1145
|
+
userMessage: { type: 'string', description: 'What the user was asking at capture time. This is what the entry is documented from.' },
|
|
1146
|
+
toolCall: { type: 'string', description: 'The action being taken when the file was captured.' },
|
|
1147
|
+
task: { type: 'string', description: 'The task in hand at capture time.' },
|
|
1148
|
+
},
|
|
1149
|
+
output: {
|
|
1150
|
+
schema: {
|
|
1151
|
+
type: 'object', additionalProperties: false,
|
|
1152
|
+
properties: {
|
|
1153
|
+
ok: { type: 'boolean', required: true },
|
|
1154
|
+
id: { type: 'string' },
|
|
1155
|
+
entryId: { type: 'string' },
|
|
1156
|
+
sha256: { type: 'string' },
|
|
1157
|
+
bytes: { type: 'integer' },
|
|
1158
|
+
deduped: { type: 'boolean' },
|
|
1159
|
+
file: { type: 'string' },
|
|
1160
|
+
scope: { type: 'string' },
|
|
1161
|
+
key: { type: 'string' },
|
|
1162
|
+
code: { type: 'string' },
|
|
1163
|
+
message: { type: 'string' },
|
|
1164
|
+
detail: { type: 'object', additionalProperties: true },
|
|
1165
|
+
hint: { type: 'string' },
|
|
1166
|
+
},
|
|
1167
|
+
},
|
|
1168
|
+
render: (_args, value) => [{
|
|
1169
|
+
type: 'text',
|
|
1170
|
+
text: value.ok
|
|
1171
|
+
? 'Stored ' + (value.id || 'an asset') + ' (' + formatBytes(value.bytes) + ', sha256 ' + String(value.sha256 || '').slice(0, 12) + (value.deduped ? ', content already present' : '') + ')' + (value.file ? ' -> ' + value.file : '') + '.'
|
|
1172
|
+
: 'Not stored (' + value.code + '): ' + value.message + (value.hint ? ' ' + value.hint : ''),
|
|
1173
|
+
}],
|
|
1174
|
+
},
|
|
1175
|
+
async execute(args, exec) {
|
|
1176
|
+
refuseSubagentWrite(exec, 'memory_asset_put')
|
|
1177
|
+
const assets = await assetsApi()
|
|
1178
|
+
const cfg = assetConfig()
|
|
1179
|
+
const inferred = (workspaceOf && exec?.agent) ? await workspaceOf(exec.agent) : undefined
|
|
1180
|
+
let scope = args.scope
|
|
1181
|
+
let key = args.workspace
|
|
1182
|
+
if (scope === undefined) {
|
|
1183
|
+
if (inferred) { scope = 'workspace'; key = key ?? inferred } else scope = 'global'
|
|
1184
|
+
} else if (scope === 'workspace' && !key) {
|
|
1185
|
+
key = inferred
|
|
1186
|
+
}
|
|
1187
|
+
if (scope !== 'global' && scope !== 'workspace') throw new Error('memory_asset_put: scope must be "global" or "workspace"')
|
|
1188
|
+
if (scope === 'workspace' && !key) throw new Error('memory_asset_put: scope workspace needs a workspace key, and none could be inferred for this session. Pass workspace, or use scope global.')
|
|
1189
|
+
|
|
1190
|
+
let source
|
|
1191
|
+
if (args.path) {
|
|
1192
|
+
const base = exec?.agent?.session?.header?.cwd
|
|
1193
|
+
const abs = isAbsolute(args.path) ? args.path : (base ? resolve(base, args.path) : resolve(args.path))
|
|
1194
|
+
if (!existsSync(abs)) throw new Error('memory_asset_put: no such file: ' + abs)
|
|
1195
|
+
let stat
|
|
1196
|
+
try { stat = statSync(abs) } catch { stat = undefined }
|
|
1197
|
+
if (!stat || !stat.isFile()) throw new Error('memory_asset_put: not a regular file: ' + abs)
|
|
1198
|
+
source = { path: abs, name: args.name ?? basename(abs) }
|
|
1199
|
+
} else if (typeof args.base64 === 'string') {
|
|
1200
|
+
const bytes = Buffer.from(args.base64, 'base64')
|
|
1201
|
+
if (bytes.byteLength === 0) {
|
|
1202
|
+
// Honest refusal rather than a throw: an empty payload is a contract failure the same
|
|
1203
|
+
// shape as `missing-bytes`, and the caller can act on it.
|
|
1204
|
+
return { ok: false, code: 'missing-bytes', message: 'base64 decoded to zero bytes; nothing to store', scope, ...(key ? { key: String(key) } : {}) }
|
|
1205
|
+
}
|
|
1206
|
+
source = { bytes, name: args.name ?? 'inline.bin' }
|
|
1207
|
+
} else if (args.attachment) {
|
|
1208
|
+
source = await attachmentSource(args.attachment)
|
|
1209
|
+
} else {
|
|
1210
|
+
throw new Error('memory_asset_put needs one of path, base64, or attachment (a durable attachment reference)')
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const name = args.name ?? source.name ?? (source.path ? basename(source.path) : 'inline.bin')
|
|
1214
|
+
const mime = args.mime ?? source.mime
|
|
1215
|
+
const captureContext = {
|
|
1216
|
+
...(args.userMessage ? { userMessage: args.userMessage } : {}),
|
|
1217
|
+
...(args.toolCall ? { toolCall: args.toolCall } : {}),
|
|
1218
|
+
...(args.task ? { task: args.task } : {}),
|
|
1219
|
+
name,
|
|
1220
|
+
...(mime ? { mime } : {}),
|
|
1221
|
+
...(args.class ? { class: args.class } : {}),
|
|
1222
|
+
}
|
|
1223
|
+
const input = {
|
|
1224
|
+
...(source.path ? { path: source.path } : { bytes: source.bytes }),
|
|
1225
|
+
name,
|
|
1226
|
+
...(mime ? { mime } : {}),
|
|
1227
|
+
...(args.class ? { class: args.class } : {}),
|
|
1228
|
+
scope,
|
|
1229
|
+
...(key ? { key } : {}),
|
|
1230
|
+
context: captureContext,
|
|
1231
|
+
usedFor: usedForFrom(captureContext),
|
|
1232
|
+
describe: await assetDescribeHook(exec?.agent),
|
|
1233
|
+
runner: (argv, options) => assetGitRunner(argv, options),
|
|
1234
|
+
}
|
|
1235
|
+
const result = await putAssetWithQuotaDecision(assets, input, exec)
|
|
1236
|
+
return putResultValue(result, { scope, key })
|
|
1237
|
+
},
|
|
1238
|
+
}))
|
|
1239
|
+
|
|
1240
|
+
ctx.tools.register(defineTool({
|
|
1241
|
+
name: 'memory_asset_get',
|
|
1242
|
+
description: 'Resolve one stored asset to its metadata, and optionally copy the bytes to a path the agent can open. The only asset tool that touches blob bytes, and it touches exactly one. Reports missing-bytes honestly (an LFS pointer or a pruned object is not data).',
|
|
1243
|
+
parameters: {
|
|
1244
|
+
id: { type: 'string', description: 'Asset entry id (e.g. global-ast-0007).' },
|
|
1245
|
+
sha256: { type: 'string', description: 'Content hash of the asset bytes, when the id is unknown.' },
|
|
1246
|
+
toPath: { type: 'string', description: 'Copy the bytes to this path (relative paths resolve against the session working directory). Omit to read metadata only.' },
|
|
1247
|
+
},
|
|
1248
|
+
output: {
|
|
1249
|
+
schema: {
|
|
1250
|
+
type: 'object', additionalProperties: false,
|
|
1251
|
+
properties: {
|
|
1252
|
+
ok: { type: 'boolean', required: true },
|
|
1253
|
+
metadata: { type: 'object', additionalProperties: true },
|
|
1254
|
+
bytes: { type: 'integer' },
|
|
1255
|
+
path: { type: 'string' },
|
|
1256
|
+
code: { type: 'string' },
|
|
1257
|
+
message: { type: 'string' },
|
|
1258
|
+
detail: { type: 'object', additionalProperties: true },
|
|
1259
|
+
hint: { type: 'string' },
|
|
1260
|
+
},
|
|
1261
|
+
},
|
|
1262
|
+
render: (_args, value) => [{
|
|
1263
|
+
type: 'text',
|
|
1264
|
+
text: value.ok
|
|
1265
|
+
? 'Asset ' + String(value.metadata?.id ?? '') + ': ' + String(value.metadata?.name ?? '') + ' (' + String(value.metadata?.mime ?? '') + ', ' + formatBytes(value.metadata?.bytes) + ', sha256 ' + String(value.metadata?.sha256 ?? '').slice(0, 12) + ')' + (value.path ? ' -> ' + value.path : '') + '.'
|
|
1266
|
+
: 'Could not read the asset (' + value.code + '): ' + value.message + (value.hint ? ' ' + value.hint : ''),
|
|
1267
|
+
}],
|
|
1268
|
+
},
|
|
1269
|
+
async execute(args, exec) {
|
|
1270
|
+
if (!args.id && !args.sha256) throw new Error('memory_asset_get needs id or sha256')
|
|
1271
|
+
const assets = await assetsApi()
|
|
1272
|
+
let toPath = args.toPath
|
|
1273
|
+
if (toPath) {
|
|
1274
|
+
const base = exec?.agent?.session?.header?.cwd
|
|
1275
|
+
toPath = isAbsolute(toPath) ? toPath : (base ? resolve(base, toPath) : resolve(toPath))
|
|
1276
|
+
}
|
|
1277
|
+
const result = await assets.getAsset(store, assetConfig(), {
|
|
1278
|
+
...(args.id ? { id: args.id } : {}),
|
|
1279
|
+
...(args.sha256 ? { sha256: args.sha256 } : {}),
|
|
1280
|
+
...(toPath ? { toPath } : {}),
|
|
1281
|
+
})
|
|
1282
|
+
if (result && result.ok === true) {
|
|
1283
|
+
// Without toPath the store hands back the object BYTES; a tool result must be lossless JSON,
|
|
1284
|
+
// so what travels is the byte count (the metadata already carries the same number).
|
|
1285
|
+
const byteCount = typeof result.bytes === 'number'
|
|
1286
|
+
? result.bytes
|
|
1287
|
+
: (result.bytes && typeof result.bytes.byteLength === 'number' ? result.bytes.byteLength : undefined)
|
|
1288
|
+
return {
|
|
1289
|
+
ok: true,
|
|
1290
|
+
metadata: result.metadata && typeof result.metadata === 'object' ? result.metadata : {},
|
|
1291
|
+
...(typeof byteCount === 'number' ? { bytes: byteCount } : {}),
|
|
1292
|
+
...(result.path ? { path: String(result.path) } : {}),
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
return refusalValue(result, {
|
|
1296
|
+
hint: String(result && result.code) === 'missing-bytes'
|
|
1297
|
+
? 'The bytes are missing from the store (an LFS pointer file, or a pruned object). Run `git lfs pull` in the memory store, then retry; the entry and its metadata are intact.'
|
|
1298
|
+
: undefined,
|
|
1299
|
+
})
|
|
1300
|
+
},
|
|
1301
|
+
}))
|
|
1302
|
+
|
|
1303
|
+
ctx.tools.register(defineTool({
|
|
1304
|
+
name: 'memory_asset_list',
|
|
1305
|
+
description: 'List stored asset entries by scope, workspace and class, newest first and bounded. Reads entries only - it never walks the blob store.',
|
|
1306
|
+
parameters: {
|
|
1307
|
+
scope: { type: 'string', description: 'global, workspace, or all (default all).' },
|
|
1308
|
+
workspace: { type: 'string', description: 'Workspace key when scope is workspace.' },
|
|
1309
|
+
class: { type: 'string', description: 'documents, media, or data.' },
|
|
1310
|
+
limit: { type: 'integer', description: 'Maximum rows (default 50).' },
|
|
1311
|
+
},
|
|
1312
|
+
output: {
|
|
1313
|
+
schema: {
|
|
1314
|
+
type: 'object', additionalProperties: false,
|
|
1315
|
+
properties: {
|
|
1316
|
+
ok: { type: 'boolean', required: true },
|
|
1317
|
+
scope: { type: 'string' },
|
|
1318
|
+
key: { type: 'string' },
|
|
1319
|
+
assets: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
1320
|
+
code: { type: 'string' },
|
|
1321
|
+
message: { type: 'string' },
|
|
1322
|
+
detail: { type: 'object', additionalProperties: true },
|
|
1323
|
+
},
|
|
1324
|
+
},
|
|
1325
|
+
render: (_args, value) => {
|
|
1326
|
+
if (!value.ok) return [{ type: 'text', text: 'Could not list assets (' + value.code + '): ' + value.message }]
|
|
1327
|
+
const rows = value.assets ?? []
|
|
1328
|
+
if (rows.length === 0) return [{ type: 'text', text: '(no stored assets in ' + value.scope + (value.key ? ' ' + value.key : '') + ')' }]
|
|
1329
|
+
return [{
|
|
1330
|
+
type: 'text',
|
|
1331
|
+
text: rows.map((row) => row.id + ' [' + row.class + '] ' + row.name + ' (' + formatBytes(row.bytes) + ', ' + String(row.sha256).slice(0, 12) + ')').join('\n'),
|
|
1332
|
+
}]
|
|
1333
|
+
},
|
|
1334
|
+
},
|
|
1335
|
+
async execute(args, exec) {
|
|
1336
|
+
const assets = await assetsApi()
|
|
1337
|
+
const inferred = (workspaceOf && exec?.agent) ? await workspaceOf(exec.agent) : undefined
|
|
1338
|
+
const scope = args.scope ?? 'all'
|
|
1339
|
+
if (scope !== 'global' && scope !== 'workspace' && scope !== 'all') throw new Error('memory_asset_list: scope must be "global", "workspace", or "all"')
|
|
1340
|
+
const key = scope === 'workspace' ? (args.workspace ?? inferred) : args.workspace
|
|
1341
|
+
if (scope === 'workspace' && !key) throw new Error('memory_asset_list: scope workspace needs a workspace key, and none could be inferred for this session')
|
|
1342
|
+
const result = await assets.listAssets(store, {
|
|
1343
|
+
scope,
|
|
1344
|
+
...(key ? { key } : {}),
|
|
1345
|
+
...(args.class ? { class: args.class } : {}),
|
|
1346
|
+
limit: args.limit ?? 50,
|
|
1347
|
+
})
|
|
1348
|
+
if (result && result.ok === true) {
|
|
1349
|
+
return {
|
|
1350
|
+
ok: true,
|
|
1351
|
+
scope,
|
|
1352
|
+
...(scope === 'workspace' ? { key } : {}),
|
|
1353
|
+
assets: Array.isArray(result.assets) ? result.assets : [],
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
return refusalValue(result)
|
|
1357
|
+
},
|
|
1358
|
+
}))
|
|
1359
|
+
|
|
1360
|
+
ctx.tools.register(defineTool({
|
|
1361
|
+
name: 'memory_asset_forget',
|
|
1362
|
+
description: 'Delete one asset entry. The content-addressed object is deleted only when its last reference is gone, so another scope that stores the same bytes keeps them.',
|
|
1363
|
+
parameters: {
|
|
1364
|
+
id: { type: 'string', required: true, description: 'Asset entry id (e.g. global-ast-0007).' },
|
|
1365
|
+
},
|
|
1366
|
+
output: {
|
|
1367
|
+
schema: {
|
|
1368
|
+
type: 'object', additionalProperties: false,
|
|
1369
|
+
properties: {
|
|
1370
|
+
ok: { type: 'boolean', required: true },
|
|
1371
|
+
entryId: { type: 'string' },
|
|
1372
|
+
objectDeleted: { type: 'boolean' },
|
|
1373
|
+
bytesFreed: { type: 'integer' },
|
|
1374
|
+
code: { type: 'string' },
|
|
1375
|
+
message: { type: 'string' },
|
|
1376
|
+
detail: { type: 'object', additionalProperties: true },
|
|
1377
|
+
},
|
|
1378
|
+
},
|
|
1379
|
+
render: (_args, value) => [{
|
|
1380
|
+
type: 'text',
|
|
1381
|
+
text: value.ok
|
|
1382
|
+
? 'Forgot ' + value.entryId + ' (' + formatBytes(value.bytesFreed) + ' freed' + (value.objectDeleted ? ', the object was deleted' : ', the object is still referenced elsewhere') + ').'
|
|
1383
|
+
: 'Could not forget (' + value.code + '): ' + value.message,
|
|
1384
|
+
}],
|
|
1385
|
+
},
|
|
1386
|
+
async execute(args, exec) {
|
|
1387
|
+
refuseSubagentWrite(exec, 'memory_asset_forget')
|
|
1388
|
+
const assets = await assetsApi()
|
|
1389
|
+
const result = await assets.forgetAsset(store, assetConfig(), { id: args.id })
|
|
1390
|
+
if (result && result.ok === true) {
|
|
1391
|
+
return {
|
|
1392
|
+
ok: true,
|
|
1393
|
+
entryId: String(result.entryId ?? args.id),
|
|
1394
|
+
objectDeleted: Boolean(result.objectDeleted),
|
|
1395
|
+
bytesFreed: Number(result.bytesFreed) || 0,
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
return refusalValue(result)
|
|
1399
|
+
},
|
|
1400
|
+
}))
|
|
1401
|
+
|
|
1402
|
+
ctx.tools.register(defineTool({
|
|
1403
|
+
name: 'memory_asset_policy',
|
|
1404
|
+
description: 'Read or set the per-scope asset toggles (documents, media, data) and the scope quota. The profile config is a hard upper bound: a class switched off there cannot be turned back on here, and the refusal names the level. Turning a class off is narrowing, never deletion - stored assets stay readable and searchable.',
|
|
1405
|
+
parameters: {
|
|
1406
|
+
scope: { type: 'string', description: 'global or workspace. Defaults to the session workspace when there is one, else global.' },
|
|
1407
|
+
workspace: { type: 'string', description: 'Workspace key when scope is workspace.' },
|
|
1408
|
+
documents: { type: 'boolean', description: 'Allow documents capture in this scope.' },
|
|
1409
|
+
media: { type: 'boolean', description: 'Allow media capture in this scope.' },
|
|
1410
|
+
data: { type: 'boolean', description: 'Allow data capture in this scope.' },
|
|
1411
|
+
quotaBytes: { type: 'integer', description: 'Byte quota for this scope (positive integer).' },
|
|
1412
|
+
},
|
|
1413
|
+
output: {
|
|
1414
|
+
schema: {
|
|
1415
|
+
type: 'object', additionalProperties: false,
|
|
1416
|
+
properties: {
|
|
1417
|
+
ok: { type: 'boolean', required: true },
|
|
1418
|
+
scope: { type: 'string' },
|
|
1419
|
+
key: { type: 'string' },
|
|
1420
|
+
config: { type: 'object', additionalProperties: true },
|
|
1421
|
+
policy: { type: 'object', additionalProperties: true },
|
|
1422
|
+
effective: { type: 'object', additionalProperties: true },
|
|
1423
|
+
changed: { type: 'object', additionalProperties: true },
|
|
1424
|
+
level: { type: 'string' },
|
|
1425
|
+
detail: { type: 'object', additionalProperties: true },
|
|
1426
|
+
code: { type: 'string' },
|
|
1427
|
+
message: { type: 'string' },
|
|
1428
|
+
},
|
|
1429
|
+
},
|
|
1430
|
+
render: (_args, value) => {
|
|
1431
|
+
if (!value.ok) return [{ type: 'text', text: 'Policy unchanged (' + value.code + '): ' + value.message }]
|
|
1432
|
+
const e = value.effective ?? {}
|
|
1433
|
+
return [{
|
|
1434
|
+
type: 'text',
|
|
1435
|
+
text: 'Asset policy for ' + value.scope + (value.key ? ' ' + value.key : '') + ': '
|
|
1436
|
+
+ 'documents ' + (e.documents ? 'on' : 'off') + ', media ' + (e.media ? 'on' : 'off') + ', data ' + (e.data ? 'on' : 'off')
|
|
1437
|
+
+ (value.changed ? ' (changed: ' + Object.entries(value.changed).map(([k, v]) => k + '=' + String(v)).join(', ') + ')' : ''),
|
|
1438
|
+
}]
|
|
1439
|
+
},
|
|
1440
|
+
},
|
|
1441
|
+
async execute(args, exec) {
|
|
1442
|
+
// Reading the policy is a harmless READ, allowed from a subagent. Only a change is a WRITE.
|
|
1443
|
+
const writes = args.documents !== undefined || args.media !== undefined || args.data !== undefined || args.quotaBytes !== undefined
|
|
1444
|
+
if (writes) refuseSubagentWrite(exec, 'memory_asset_policy')
|
|
1445
|
+
const assets = await assetsApi()
|
|
1446
|
+
const cfg = assetConfig()
|
|
1447
|
+
const inferred = (workspaceOf && exec?.agent) ? await workspaceOf(exec.agent) : undefined
|
|
1448
|
+
let scope = args.scope
|
|
1449
|
+
let key = args.workspace
|
|
1450
|
+
if (scope === undefined) {
|
|
1451
|
+
if (inferred) { scope = 'workspace'; key = key ?? inferred } else scope = 'global'
|
|
1452
|
+
} else if (scope === 'workspace' && !key) {
|
|
1453
|
+
key = inferred
|
|
1454
|
+
}
|
|
1455
|
+
if (scope !== 'global' && scope !== 'workspace') throw new Error('memory_asset_policy: scope must be "global" or "workspace"')
|
|
1456
|
+
if (scope === 'workspace' && !key) throw new Error('memory_asset_policy: scope workspace needs a workspace key, and none could be inferred for this session')
|
|
1457
|
+
|
|
1458
|
+
const config = {
|
|
1459
|
+
enabled: cfg.assetsEnabled === true,
|
|
1460
|
+
documents: cfg.assetsDocuments !== false,
|
|
1461
|
+
media: cfg.assetsMedia !== false,
|
|
1462
|
+
data: cfg.assetsData !== false,
|
|
1463
|
+
}
|
|
1464
|
+
const policy = assets.readPolicy(store.root)
|
|
1465
|
+
const target = scope === 'global'
|
|
1466
|
+
? { ...(policy.global ?? {}) }
|
|
1467
|
+
: { ...(policy.workspaces?.[key] ?? {}) }
|
|
1468
|
+
const changed = {}
|
|
1469
|
+
const fieldNames = { documents: 'assetsDocuments', media: 'assetsMedia', data: 'assetsData' }
|
|
1470
|
+
for (const cls of ['documents', 'media', 'data']) {
|
|
1471
|
+
if (args[cls] === undefined) continue
|
|
1472
|
+
if (args[cls] === true && !config.enabled) {
|
|
1473
|
+
const message = 'config ' + cls + ': the profile config (assetsEnabled) is a hard upper bound, so a per-scope policy cannot turn asset capture on'
|
|
1474
|
+
return {
|
|
1475
|
+
ok: false, code: 'toggle-off', message, level: 'config',
|
|
1476
|
+
detail: { scope, ...(key ? { key } : {}), class: cls, level: 'config', configField: 'assetsEnabled' },
|
|
1477
|
+
scope, ...(key ? { key } : {}),
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
if (args[cls] === true && config[cls] === false) {
|
|
1481
|
+
const field = fieldNames[cls]
|
|
1482
|
+
const message = 'config ' + cls + ': the profile config (' + field + ') is a hard upper bound, so a per-scope policy cannot turn ' + cls + ' back on'
|
|
1483
|
+
return {
|
|
1484
|
+
ok: false, code: 'toggle-off', message, level: 'config',
|
|
1485
|
+
detail: { scope, ...(key ? { key } : {}), class: cls, level: 'config', configField: field },
|
|
1486
|
+
scope, ...(key ? { key } : {}),
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
target[cls] = Boolean(args[cls])
|
|
1490
|
+
changed[cls] = Boolean(args[cls])
|
|
1491
|
+
}
|
|
1492
|
+
if (args.quotaBytes !== undefined) {
|
|
1493
|
+
if (!Number.isSafeInteger(args.quotaBytes) || args.quotaBytes <= 0) {
|
|
1494
|
+
throw new Error('memory_asset_policy: quotaBytes must be a positive integer')
|
|
1495
|
+
}
|
|
1496
|
+
target.quotaBytes = args.quotaBytes
|
|
1497
|
+
changed.quotaBytes = args.quotaBytes
|
|
1498
|
+
}
|
|
1499
|
+
if (Object.keys(changed).length > 0) {
|
|
1500
|
+
if (scope === 'global') policy.global = target
|
|
1501
|
+
else {
|
|
1502
|
+
policy.workspaces = policy.workspaces ?? {}
|
|
1503
|
+
policy.workspaces[key] = target
|
|
1504
|
+
}
|
|
1505
|
+
assets.writePolicy(store.root, policy)
|
|
1506
|
+
}
|
|
1507
|
+
const effective = assets.resolveToggles(cfg, store.root, { scope, ...(key ? { key } : {}) })
|
|
1508
|
+
return {
|
|
1509
|
+
ok: true,
|
|
1510
|
+
scope,
|
|
1511
|
+
...(scope === 'workspace' ? { key } : {}),
|
|
1512
|
+
config,
|
|
1513
|
+
policy: target,
|
|
1514
|
+
effective,
|
|
1515
|
+
...(Object.keys(changed).length > 0 ? { changed } : {}),
|
|
1516
|
+
}
|
|
1517
|
+
},
|
|
1518
|
+
}))
|
|
1519
|
+
|
|
795
1520
|
ctx.tools.register(defineTool({
|
|
796
1521
|
name: 'memory_consolidate',
|
|
797
1522
|
description: 'Consolidate pending captured session digests into durable memory entries (LLM pass). Forces memory to update now instead of waiting for the automatic cadence.',
|