@try-works/dsh-memory 0.1.31 → 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/capture.js +7 -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/entry.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Entry format (Format 2), parsing, and id allocation. Pure (no fs) — unit-testable.
|
|
2
|
-
export const LAYER_CODES = { semantic: 'sem', episodic: 'epi', procedural: 'pro', learning: 'learning', codebase: 'code', history: 'hist', decision: 'dec' }
|
|
3
|
-
export const LAYER_FILES = { semantic: 'semantic.md', episodic: 'episodic.md', procedural: 'procedural.md', learning: 'learnings.md', codebase: 'codebase.md', history: 'history.md', decision: 'decisions.md' }
|
|
2
|
+
export const LAYER_CODES = { semantic: 'sem', episodic: 'epi', procedural: 'pro', learning: 'learning', codebase: 'code', history: 'hist', decision: 'dec', asset: 'ast' }
|
|
3
|
+
export const LAYER_FILES = { semantic: 'semantic.md', episodic: 'episodic.md', procedural: 'procedural.md', learning: 'learnings.md', codebase: 'codebase.md', history: 'history.md', decision: 'decisions.md', asset: 'assets.md' }
|
|
4
4
|
export const LAYERS = Object.keys(LAYER_CODES)
|
|
5
5
|
|
|
6
6
|
/** Sub-directory holding the per-topic files for each layered kind. */
|
|
7
|
-
export const LAYER_DIRS = { semantic: 'semantic', episodic: 'episodic', procedural: 'procedural', learning: 'learnings', codebase: 'codebase', history: 'history', decision: 'decisions' }
|
|
7
|
+
export const LAYER_DIRS = { semantic: 'semantic', episodic: 'episodic', procedural: 'procedural', learning: 'learnings', codebase: 'codebase', history: 'history', decision: 'decisions', asset: 'assets' }
|
|
8
8
|
|
|
9
9
|
/** Layered kinds, in a stable order. */
|
|
10
10
|
export const LAYERED = Object.keys(LAYER_DIRS)
|
|
@@ -84,6 +84,39 @@ export function formatEntry({ id, source, date, importance = 2, status = 'active
|
|
|
84
84
|
return lines.join('\n') + '\n\n' + content
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Section titles used inside an asset entry's body. They are RENDERED as H3 (`### What it is`,
|
|
89
|
+
* `### When to read it`, `### Notes`) because `##` is Format 2's entry delimiter; H2 titles are
|
|
90
|
+
* still accepted defensively for entries captured before that decision, so an asset entry is
|
|
91
|
+
* recognised before one of these is allowed to start a new entry.
|
|
92
|
+
*/
|
|
93
|
+
export const ASSET_SECTION_TITLES = ['What it is', 'When to read it', 'Notes']
|
|
94
|
+
|
|
95
|
+
function isAssetSection(title) {
|
|
96
|
+
return ASSET_SECTION_TITLES.some((section) => section.toLowerCase() === String(title ?? '').trim().toLowerCase())
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** True when `line` starts a NEW entry for the entry whose header lines begin at `from`. */
|
|
100
|
+
function isAssetSectionHeading(current, line) {
|
|
101
|
+
if (current === null || current.meta['Asset-Sha256'] === undefined) return false
|
|
102
|
+
const heading = line.match(/^##\s+(.+)$/)
|
|
103
|
+
return heading !== null && isAssetSection(heading[1])
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Find the line index where the entry containing `idIdx` ends: the next `## ` line that is not an
|
|
108
|
+
* asset body section. Shared by the in-place editors so a refresh cannot cut an asset body apart.
|
|
109
|
+
*/
|
|
110
|
+
function entryEndLine(lines, idIdx) {
|
|
111
|
+
for (let i = idIdx + 1; i < lines.length; i++) {
|
|
112
|
+
const heading = lines[i].match(/^##\s+(.+)$/)
|
|
113
|
+
if (!heading) continue
|
|
114
|
+
const isAsset = lines.slice(idIdx, i).some((line) => /^\*\*Asset-Sha256:\*\*/.test(line))
|
|
115
|
+
if (!(isAsset && isAssetSection(heading[1]))) return i
|
|
116
|
+
}
|
|
117
|
+
return lines.length
|
|
118
|
+
}
|
|
119
|
+
|
|
87
120
|
/**
|
|
88
121
|
* Parse a layer file into entries: each '## heading' starts an entry, following
|
|
89
122
|
* '**Key:** value' lines populate its metadata and the rest is the body. Returns
|
|
@@ -98,10 +131,14 @@ export function parseEntries(text) {
|
|
|
98
131
|
for (let i = 0; i < lines.length; i++) {
|
|
99
132
|
const line = lines[i]
|
|
100
133
|
const h = line.match(/^##\s+(.+)$/)
|
|
101
|
-
|
|
134
|
+
const assetBodySection = isAssetSectionHeading(current, line)
|
|
135
|
+
if (h && !assetBodySection) { push(); current = { heading: h[1].trim(), startLine: i + 1, endLine: undefined, lastLine: i + 1, meta: {}, body: [] }; continue }
|
|
102
136
|
if (!current) continue
|
|
103
137
|
current.lastLine = i + 1
|
|
104
|
-
|
|
138
|
+
// Header keys may contain digits (`Asset-Sha256`, `Asset-Bytes`): the original
|
|
139
|
+
// [A-Za-z-]+ class silently pushed those lines into the body, so the asset headers could be
|
|
140
|
+
// rendered but never parsed back.
|
|
141
|
+
const m = line.match(/^\*\*([A-Za-z0-9_-]+):\*\*\s*(.*)$/)
|
|
105
142
|
if (m) current.meta[m[1]] = m[2].trim()
|
|
106
143
|
else current.body.push(line)
|
|
107
144
|
}
|
|
@@ -111,6 +148,10 @@ export function parseEntries(text) {
|
|
|
111
148
|
.map((e) => ({
|
|
112
149
|
id: e.meta['Id'],
|
|
113
150
|
heading: e.heading,
|
|
151
|
+
// The raw header map, exposed because feature-specific headers (Asset-*, and any future
|
|
152
|
+
// kind) cannot all be projected here without turning this module into a schema of every
|
|
153
|
+
// feature. The known fields below stay the stable projection.
|
|
154
|
+
meta: e.meta,
|
|
114
155
|
importance: e.meta['Importance'] ? Number(e.meta['Importance']) : 2,
|
|
115
156
|
status: e.meta['Status'] ?? 'active',
|
|
116
157
|
date: e.meta['Date'],
|
|
@@ -133,8 +174,7 @@ export function setEntryStatus(text, id, status, supersededBy) {
|
|
|
133
174
|
const lines = String(text).split('\n')
|
|
134
175
|
const idIdx = lines.findIndex((l) => l.includes('**Id:** ' + id))
|
|
135
176
|
if (idIdx < 0) return String(text)
|
|
136
|
-
|
|
137
|
-
for (let i = idIdx + 1; i < lines.length; i++) { if (/^##\s+/.test(lines[i])) { end = i; break } }
|
|
177
|
+
const end = entryEndLine(lines, idIdx)
|
|
138
178
|
for (let i = idIdx; i < end; i++) {
|
|
139
179
|
if (/^\*\*Status:\*\*/.test(lines[i])) {
|
|
140
180
|
lines[i] = '**Status:** ' + status
|
|
@@ -153,8 +193,7 @@ export function setEntryBody(text, id, newBody) {
|
|
|
153
193
|
const lines = String(text).split('\n')
|
|
154
194
|
const idIdx = lines.findIndex((l) => l.includes('**Id:** ' + id))
|
|
155
195
|
if (idIdx < 0) return String(text)
|
|
156
|
-
|
|
157
|
-
for (let i = idIdx + 1; i < lines.length; i++) { if (/^##\s+/.test(lines[i])) { end = i; break } }
|
|
196
|
+
const end = entryEndLine(lines, idIdx)
|
|
158
197
|
// A refresh rewrites the body because the fact was RE-CONFIRMED, so it must reset the
|
|
159
198
|
// freshness clock too. Verified is the age basis, so bumping only Updated would leave a
|
|
160
199
|
// rewritten entry permanently stale - the refresh pass could never cure anything.
|
|
@@ -169,11 +208,17 @@ export function setEntryBody(text, id, newBody) {
|
|
|
169
208
|
const updatedAt = lines.findIndex((l, i) => i > idIdx && i < end && /^\*\*Updated:\*\*/.test(l))
|
|
170
209
|
if (updatedAt >= 0) lines.splice(updatedAt + 1, 0, '**Verified:** ' + stamp)
|
|
171
210
|
}
|
|
172
|
-
|
|
173
|
-
|
|
211
|
+
// Recompute the boundary AFTER the insertion above: it shifted every later line by one, and a
|
|
212
|
+
// stale end left the last old body line behind on every rewrite of an entry without Verified.
|
|
213
|
+
const bodyEnd = entryEndLine(lines, idIdx)
|
|
214
|
+
let bodyStart = bodyEnd
|
|
215
|
+
for (let i = idIdx; i < bodyEnd; i++) {
|
|
174
216
|
if (/^\*\*Format:\*\*/.test(lines[i])) { bodyStart = i + 1; break }
|
|
175
217
|
}
|
|
176
|
-
|
|
177
|
-
|
|
218
|
+
// Asset headers are written after Format, so a plain "skip blank lines" body rewrite would
|
|
219
|
+
// replace the metadata (sha256, mime, bytes, origin) together with the documentation. The body
|
|
220
|
+
// starts after the last Asset-* header; a non-asset entry is unaffected.
|
|
221
|
+
while (bodyStart < bodyEnd && (lines[bodyStart].trim() === '' || /^\*\*Asset-[A-Za-z0-9-]+:\*\*/.test(lines[bodyStart]))) bodyStart++
|
|
222
|
+
lines.splice(bodyStart, bodyEnd - bodyStart, ...String(newBody).split('\n'))
|
|
178
223
|
return lines.join('\n')
|
|
179
224
|
}
|
package/src/host.js
CHANGED
|
@@ -24,6 +24,7 @@ import { buildMap } from './map.js'
|
|
|
24
24
|
import { backupTasks, defaultSchedulePath, planRestore, readScheduleStore } from './tasks.js'
|
|
25
25
|
import { autoAssociateSession } from './session.js'
|
|
26
26
|
import { readAssociations } from './workspace.js'
|
|
27
|
+
import { describeAsset, usedForFrom } from './assets/describe.js'
|
|
27
28
|
|
|
28
29
|
const PLUGIN = 'dsh-memory'
|
|
29
30
|
|
|
@@ -95,7 +96,7 @@ function resolveTarget(agent) {
|
|
|
95
96
|
return undefined
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
export function wireHost(ctx, store, cfg) {
|
|
99
|
+
export function wireHost(ctx, store, cfg, options = {}) {
|
|
99
100
|
const keyBySession = new Map()
|
|
100
101
|
const scheduler = new SyncScheduler({ commitDebounceMs: cfg.commitDebounceMs, syncIntervalMs: cfg.autoSyncIntervalMs })
|
|
101
102
|
const captureThrottle = new Map()
|
|
@@ -110,6 +111,15 @@ export function wireHost(ctx, store, cfg) {
|
|
|
110
111
|
let lastHistoryAttempt = 0
|
|
111
112
|
let syncInFlight = false
|
|
112
113
|
|
|
114
|
+
// The asset store module is loaded lazily, at the first asset operation. `options.assets` is the
|
|
115
|
+
// test/embedding seam; production passes nothing and the real module is imported.
|
|
116
|
+
let assetsModulePromise
|
|
117
|
+
async function assetsApi() {
|
|
118
|
+
if (options.assets) return options.assets
|
|
119
|
+
assetsModulePromise = assetsModulePromise ?? import('./assets/index.js')
|
|
120
|
+
return assetsModulePromise
|
|
121
|
+
}
|
|
122
|
+
|
|
113
123
|
const git = makeGitRunner(ctx)
|
|
114
124
|
const llmService = ctx.get('llm')
|
|
115
125
|
|
|
@@ -309,6 +319,183 @@ export function wireHost(ctx, store, cfg) {
|
|
|
309
319
|
.catch((error) => ctx.logger.warn('dsh-memory: capture failed: ' + (error && error.message)))
|
|
310
320
|
}
|
|
311
321
|
|
|
322
|
+
// --- automatic attachment capture ---------------------------------------------------------
|
|
323
|
+
// An image or file the user puts in a message is stored automatically when its class toggle is
|
|
324
|
+
// on. The design is explicit that this does NOT prompt: the session event has no caller waiting
|
|
325
|
+
// for an answer, so a quota wall is recorded as a notice memory_status can surface rather than a
|
|
326
|
+
// question that would block a turn. The bytes are read from the attachment service AT USE, and
|
|
327
|
+
// never at wire time.
|
|
328
|
+
const capturedAttachments = new Set()
|
|
329
|
+
const IMAGE_EXTENSIONS = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' }
|
|
330
|
+
|
|
331
|
+
function userMessageText(content) {
|
|
332
|
+
const parts = Array.isArray(content) ? content : []
|
|
333
|
+
return parts
|
|
334
|
+
.filter((part) => part && part.type === 'text' && typeof part.text === 'string')
|
|
335
|
+
.map((part) => part.text)
|
|
336
|
+
.join(' ')
|
|
337
|
+
.replace(/\s+/g, ' ')
|
|
338
|
+
.trim()
|
|
339
|
+
.slice(0, 500)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The durable attachment references one user message carries, image and file parts alike. */
|
|
343
|
+
function attachmentRefs(content) {
|
|
344
|
+
const parts = Array.isArray(content) ? content : []
|
|
345
|
+
const refs = []
|
|
346
|
+
for (const part of parts) {
|
|
347
|
+
if (!part || typeof part !== 'object') continue
|
|
348
|
+
if (part.type !== 'image' && part.type !== 'file') continue
|
|
349
|
+
if (!part.attachment || typeof part.attachment !== 'object') continue
|
|
350
|
+
refs.push({ kind: part.type, ref: part.attachment })
|
|
351
|
+
}
|
|
352
|
+
return refs
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The reference resolved to a host path (cheap) or verified bytes (fallback). */
|
|
356
|
+
async function readAttachment(item) {
|
|
357
|
+
const attachments = ctx.get('attachments')
|
|
358
|
+
if (!attachments) return undefined
|
|
359
|
+
try {
|
|
360
|
+
if (item.kind === 'image') {
|
|
361
|
+
const hostPath = typeof attachments.imageHostPath === 'function' ? attachments.imageHostPath(item.ref) : undefined
|
|
362
|
+
if (hostPath && existsSync(hostPath)) return { path: hostPath }
|
|
363
|
+
if (typeof attachments.readImage !== 'function') return undefined
|
|
364
|
+
const stored = await attachments.readImage(item.ref)
|
|
365
|
+
return { bytes: stored?.data }
|
|
366
|
+
}
|
|
367
|
+
const hostPath = typeof attachments.fileHostPath === 'function' ? attachments.fileHostPath(item.ref) : undefined
|
|
368
|
+
if (hostPath && existsSync(hostPath)) return { path: hostPath }
|
|
369
|
+
if (typeof attachments.readFileStream !== 'function') return undefined
|
|
370
|
+
const chunks = []
|
|
371
|
+
for await (const chunk of attachments.readFileStream(item.ref)) chunks.push(Buffer.from(chunk))
|
|
372
|
+
return { bytes: Buffer.concat(chunks) }
|
|
373
|
+
} catch (error) {
|
|
374
|
+
ctx.logger.debug('dsh-memory: could not read a user attachment: ' + (error && error.message))
|
|
375
|
+
return undefined
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function maybeCaptureAttachments(session, event) {
|
|
380
|
+
if (isSubagentSession(session)) return
|
|
381
|
+
const data = event?.data
|
|
382
|
+
// Only a HUMAN message: plugin-injected context and question replies carry their own sources.
|
|
383
|
+
// A missing source is treated as human because a harness-written user/message is the human
|
|
384
|
+
// turn; every non-human producer in this deployment names its kind.
|
|
385
|
+
const sourceKind = data?.source?.kind
|
|
386
|
+
if (!data || (sourceKind !== undefined && sourceKind !== 'user')) return
|
|
387
|
+
const refs = attachmentRefs(data.content)
|
|
388
|
+
if (refs.length === 0) return
|
|
389
|
+
// DEFERRED, like the map publisher: this handler runs inside an append, and capture reads
|
|
390
|
+
// bytes and may run a model call. A macrotask lands after the append has settled.
|
|
391
|
+
setTimeout(() => {
|
|
392
|
+
void captureAttachments(session, data, refs).catch((error) => ctx.logger.warn('dsh-memory: asset capture failed: ' + (error && error.message)))
|
|
393
|
+
}, 0)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function captureAttachments(session, data, refs) {
|
|
397
|
+
const assets = await assetsApi()
|
|
398
|
+
const key = await resolveKey(session)
|
|
399
|
+
const scope = key === 'global' ? 'global' : 'workspace'
|
|
400
|
+
const keyArg = scope === 'global' ? undefined : key
|
|
401
|
+
const toggles = assets.resolveToggles(cfg, store.root, { scope, ...(keyArg ? { key: keyArg } : {}) })
|
|
402
|
+
const userMessage = userMessageText(data.content)
|
|
403
|
+
// assetsDescribe: false means no model call is spent documenting a capture; the store's
|
|
404
|
+
// deterministic floor writes the two sections instead.
|
|
405
|
+
const describe = cfg.assetsDescribe === false
|
|
406
|
+
? undefined
|
|
407
|
+
: (context) => describeAsset(context, { llm: llmService ? (prompt) => runLlm(undefined, prompt, 'asset-describe', 700) : undefined })
|
|
408
|
+
for (const item of refs) {
|
|
409
|
+
const attachmentId = String(item.ref?.attachmentId ?? '')
|
|
410
|
+
const dedupeKey = session.id + ':' + attachmentId
|
|
411
|
+
if (attachmentId === '' || capturedAttachments.has(dedupeKey)) continue
|
|
412
|
+
|
|
413
|
+
const mime = item.kind === 'image' && typeof item.ref.mediaType === 'string' ? item.ref.mediaType : undefined
|
|
414
|
+
const name = typeof item.ref.name === 'string' && item.ref.name !== ''
|
|
415
|
+
? item.ref.name
|
|
416
|
+
: item.kind === 'image' ? ('image.' + (IMAGE_EXTENSIONS[mime] ?? 'png')) : 'attachment'
|
|
417
|
+
const assetClass = assets.classify({ name, ...(mime ? { mime } : {}) })
|
|
418
|
+
const reason = !toggles.enabled
|
|
419
|
+
? 'feature-off'
|
|
420
|
+
: assetClass === undefined ? 'unsupported-class'
|
|
421
|
+
: !toggles[assetClass] ? 'toggle-off' : undefined
|
|
422
|
+
if (reason) {
|
|
423
|
+
// A counted skip, never a silent drop: "why wasn't this stored?" has an answer.
|
|
424
|
+
record(store.root, 'asset-skipped', {
|
|
425
|
+
count: 1,
|
|
426
|
+
detail: { class: assetClass ?? 'unknown', reason, scope, ...(keyArg ? { key: keyArg } : {}), name },
|
|
427
|
+
})
|
|
428
|
+
continue
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const source = await readAttachment(item)
|
|
432
|
+
if (source === undefined) {
|
|
433
|
+
record(store.root, 'asset-skipped', {
|
|
434
|
+
count: 1,
|
|
435
|
+
detail: { class: assetClass, reason: 'attachments-unavailable', scope, ...(keyArg ? { key: keyArg } : {}), name },
|
|
436
|
+
})
|
|
437
|
+
continue
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const captureContext = {
|
|
441
|
+
...(userMessage ? { userMessage } : {}),
|
|
442
|
+
toolCall: 'session/event: user attachment',
|
|
443
|
+
name,
|
|
444
|
+
...(mime ? { mime } : {}),
|
|
445
|
+
class: assetClass,
|
|
446
|
+
}
|
|
447
|
+
const input = {
|
|
448
|
+
...(source.path ? { path: source.path } : { bytes: source.bytes }),
|
|
449
|
+
name,
|
|
450
|
+
...(mime ? { mime } : {}),
|
|
451
|
+
class: assetClass,
|
|
452
|
+
scope,
|
|
453
|
+
...(keyArg ? { key: keyArg } : {}),
|
|
454
|
+
context: captureContext,
|
|
455
|
+
usedFor: usedForFrom(captureContext),
|
|
456
|
+
...(describe ? { describe } : {}),
|
|
457
|
+
runner: (argv, args) => git.run(argv, args),
|
|
458
|
+
}
|
|
459
|
+
let result
|
|
460
|
+
try { result = await assets.putAsset(store, cfg, input) } catch (error) {
|
|
461
|
+
result = { ok: false, code: 'write-failed', message: String(error?.message ?? error) }
|
|
462
|
+
}
|
|
463
|
+
if (result && result.ok) {
|
|
464
|
+
// Mark the attachment only AFTER it was stored: a failed capture must be retryable if the
|
|
465
|
+
// same file is sent again, and `putAsset` already recorded `asset-added` itself.
|
|
466
|
+
capturedAttachments.add(dedupeKey)
|
|
467
|
+
// The scheduler still needs to know the store changed, or the bytes would wait for the
|
|
468
|
+
// next cadence to be committed and synced.
|
|
469
|
+
scheduler.markDirty()
|
|
470
|
+
continue
|
|
471
|
+
}
|
|
472
|
+
const code = String(result?.code ?? 'write-failed')
|
|
473
|
+
const detail = result?.detail && typeof result.detail === 'object' ? result.detail : {}
|
|
474
|
+
if (code === 'quota-exceeded') {
|
|
475
|
+
try {
|
|
476
|
+
assets.addQuotaNotice(store, {
|
|
477
|
+
scope,
|
|
478
|
+
...(keyArg ? { key: keyArg } : {}),
|
|
479
|
+
usedBytes: detail.usedBytes,
|
|
480
|
+
quotaBytes: detail.quotaBytes,
|
|
481
|
+
})
|
|
482
|
+
} catch { /* a diagnostic notice must never fail the turn */ }
|
|
483
|
+
}
|
|
484
|
+
record(store.root, 'asset-skipped', {
|
|
485
|
+
count: 1,
|
|
486
|
+
detail: {
|
|
487
|
+
class: assetClass,
|
|
488
|
+
reason: code,
|
|
489
|
+
scope,
|
|
490
|
+
...(keyArg ? { key: keyArg } : {}),
|
|
491
|
+
...(code === 'quota-exceeded'
|
|
492
|
+
? { usedBytes: Number(detail.usedBytes) || 0, quotaBytes: Number(detail.quotaBytes) || 0 }
|
|
493
|
+
: {}),
|
|
494
|
+
},
|
|
495
|
+
})
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
312
499
|
/**
|
|
313
500
|
* What the consolidation cadence should act on now.
|
|
314
501
|
*
|
|
@@ -465,7 +652,7 @@ export function wireHost(ctx, store, cfg) {
|
|
|
465
652
|
* states mean different things and only some of them are work. 'abandoned' and 'orphaned' in
|
|
466
653
|
* particular are the answers to 'did anything quietly give up', which nothing could answer before.
|
|
467
654
|
*/
|
|
468
|
-
function statusNow(agent) {
|
|
655
|
+
async function statusNow(agent) {
|
|
469
656
|
const base = store.status()
|
|
470
657
|
const states = summarizeQueue(store.root)
|
|
471
658
|
const counts = {}
|
|
@@ -484,6 +671,15 @@ export function wireHost(ctx, store, cfg) {
|
|
|
484
671
|
// 1), then a sample of ids to act on. The full list is one query away for anyone who wants it.
|
|
485
672
|
const attention = groupProblems(problems(store.root))
|
|
486
673
|
const work = due(store.root)
|
|
674
|
+
// The asset numbers and any quota wall an automatic capture met. assetBytesReport reads entries
|
|
675
|
+
// and the inventory only; readQuotaNotices is the pending board for "why wasn't that stored?".
|
|
676
|
+
let assets
|
|
677
|
+
try {
|
|
678
|
+
const api = await assetsApi()
|
|
679
|
+
const report = api.assetBytesReport(store, cfg)
|
|
680
|
+
const quotaNotices = typeof api.readQuotaNotices === 'function' ? api.readQuotaNotices(store) : []
|
|
681
|
+
assets = { ...report, ...(quotaNotices.length > 0 ? { quotaNotices } : {}) }
|
|
682
|
+
} catch { /* the asset layer is optional; its absence must not hide the rest of the status */ }
|
|
487
683
|
return {
|
|
488
684
|
root: base.root,
|
|
489
685
|
pendingDigests: pendingDigests(store).length,
|
|
@@ -500,7 +696,7 @@ export function wireHost(ctx, store, cfg) {
|
|
|
500
696
|
conflicted: Boolean(base.sync && base.sync.conflicted),
|
|
501
697
|
lastSync: base.sync && base.sync.last ? base.sync.last : null,
|
|
502
698
|
metrics: readMetrics(store.root),
|
|
503
|
-
...(
|
|
699
|
+
...(assets ? { assets } : {}),
|
|
504
700
|
}
|
|
505
701
|
}
|
|
506
702
|
|
|
@@ -878,6 +1074,9 @@ export function wireHost(ctx, store, cfg) {
|
|
|
878
1074
|
} else if (event.type === 'turn/end') {
|
|
879
1075
|
maybeCapture(session)
|
|
880
1076
|
setTimeout(() => publishMemoryMap(session), 0)
|
|
1077
|
+
} else if (event.type === 'user/message') {
|
|
1078
|
+
// Automatic attachment capture. Deferred inside the helper; never asks a question.
|
|
1079
|
+
maybeCaptureAttachments(session, event)
|
|
881
1080
|
}
|
|
882
1081
|
})
|
|
883
1082
|
|
package/src/refresh.js
CHANGED
|
@@ -41,9 +41,14 @@ export function parseRefreshDecisions(text) {
|
|
|
41
41
|
|
|
42
42
|
export async function runRefresh(store, { llm, limit = 3, scope, key, nowMs = Date.now() } = {}) {
|
|
43
43
|
const ledger = buildLedger(store.root)
|
|
44
|
+
// Assets are IMMUTABLE in phase 1: memory_asset_forget is the only thing that removes one. A
|
|
45
|
+
// refresh supersede would hide the entry from listAssets while its inventory refcount never
|
|
46
|
+
// dropped, leaving the blob in the store and in LFS with nothing pointing at it. The layer is
|
|
47
|
+
// excluded before the freshness scan, so the model is never even asked about it.
|
|
48
|
+
const refreshable = ledger.filter((e) => e.layer !== 'asset')
|
|
44
49
|
const scoped = scope === 'workspace' && key
|
|
45
|
-
?
|
|
46
|
-
:
|
|
50
|
+
? refreshable.filter((e) => e.file.startsWith('workspaces/' + key + '/'))
|
|
51
|
+
: refreshable
|
|
47
52
|
const flagged = scanFreshness(scoped, nowMs).slice(0, limit)
|
|
48
53
|
if (flagged.length === 0) return { refreshed: 0, updated: 0, superseded: 0 }
|
|
49
54
|
const text = await llm(buildRefreshPrompt(flagged))
|
package/src/search.js
CHANGED
|
@@ -4,15 +4,47 @@
|
|
|
4
4
|
// trigram index serves SUBSTRING queries - scripts written without spaces, which the word
|
|
5
5
|
// tokenizer cannot express at all. A word query that finds nothing also retries as a substring,
|
|
6
6
|
// so a partial word still matches without paying for the trigram scan on every search.
|
|
7
|
-
import { existsSync } from 'node:fs'
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
8
|
+
import { join, sep } from 'node:path'
|
|
8
9
|
import { DatabaseSync } from 'node:sqlite'
|
|
9
10
|
import { buildFtsQuery, buildIndex, buildLikeQuery, buildTrigramQuery, indexPath, needsSubstring, SUBSTRING_TABLE } from './fts.js'
|
|
11
|
+
import { parseEntries } from './entry.js'
|
|
12
|
+
import { layerOfFile } from './files.js'
|
|
10
13
|
import { buildLedgerFor } from './ledger.js'
|
|
11
14
|
import { rankBlend } from './rank.js'
|
|
12
15
|
|
|
13
16
|
/** Rows pulled from the word index before ranking. */
|
|
14
17
|
const CANDIDATES = 200
|
|
15
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The `asset` block for a hit that is an asset entry: { sha256, mime, name, bytes, class }.
|
|
21
|
+
*
|
|
22
|
+
* It is read from the hit's ENTRY (headers + headings), never from the blob, and never by walking
|
|
23
|
+
* the object tree: search stays a documentation lookup. The block is what tells an agent that the
|
|
24
|
+
* bytes exist and can be fetched with memory_asset_get.
|
|
25
|
+
*/
|
|
26
|
+
function assetBlockFor(root, hit, cache) {
|
|
27
|
+
const file = join(root, String(hit.file).split('/').join(sep))
|
|
28
|
+
if (layerOfFile(root, file) !== 'asset') return undefined
|
|
29
|
+
let entries = cache.get(file)
|
|
30
|
+
if (entries === undefined) {
|
|
31
|
+
try { entries = parseEntries(readFileSync(file, 'utf8')) } catch { entries = [] }
|
|
32
|
+
cache.set(file, entries)
|
|
33
|
+
}
|
|
34
|
+
const entry = entries.find((candidate) => candidate.id === hit.id)
|
|
35
|
+
?? entries.find((candidate) => hit.line >= candidate.startLine && hit.line <= candidate.endLine)
|
|
36
|
+
const meta = entry && entry.meta
|
|
37
|
+
const sha256 = meta && meta['Asset-Sha256']
|
|
38
|
+
if (typeof sha256 !== 'string' || sha256 === '') return undefined
|
|
39
|
+
return {
|
|
40
|
+
sha256,
|
|
41
|
+
mime: String(meta['Asset-Mime'] ?? ''),
|
|
42
|
+
name: String(meta['Asset-Name'] ?? entry.heading ?? ''),
|
|
43
|
+
bytes: Number(meta['Asset-Bytes']) || 0,
|
|
44
|
+
class: String(meta['Asset-Class'] ?? ''),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
16
48
|
/**
|
|
17
49
|
* A LIKE scan over the indexed lines. Used only when a MATCH cannot express the query: a
|
|
18
50
|
* no-space script shorter than the trigram minimum. Bounded by the same candidate cap.
|
|
@@ -69,8 +101,20 @@ export function search(store, query, { limit = 10, scope, key } = {}) {
|
|
|
69
101
|
const hits = filtered.map((r) => ({ file: r.file, line: r.line, section: r.section, snippet: String(r.content).trim() }))
|
|
70
102
|
// Ranking needs importance and recency only for the files that matched. Building the whole
|
|
71
103
|
// ledger here read ~8,000 files for a handful of hits and made one query cost ~15 s.
|
|
72
|
-
|
|
73
|
-
|
|
104
|
+
const ranked = rankBlend(hits, buildLedgerFor(store.root, hits.map((h) => h.file))).slice(0, limit)
|
|
105
|
+
const assetCache = new Map()
|
|
106
|
+
return ranked.map((h) => {
|
|
107
|
+
const asset = assetBlockFor(store.root, h, assetCache)
|
|
108
|
+
return {
|
|
109
|
+
file: h.file,
|
|
110
|
+
line: h.line,
|
|
111
|
+
section: h.section ?? '',
|
|
112
|
+
snippet: h.snippet,
|
|
113
|
+
id: h.id ?? '',
|
|
114
|
+
score: Math.round(h.score * 1000) / 1000,
|
|
115
|
+
...(asset ? { asset } : {}),
|
|
116
|
+
}
|
|
117
|
+
})
|
|
74
118
|
} finally {
|
|
75
119
|
db.close()
|
|
76
120
|
}
|