@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/src/tools.js CHANGED
@@ -1,18 +1,22 @@
1
- // Agent tools: memory_search / remember / workspaces / associate / sync / forget.
2
- import { realpathSync } from 'node:fs'
3
- import { isAbsolute, resolve } from 'node:path'
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.',