@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/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
- if (h) { push(); current = { heading: h[1].trim(), startLine: i + 1, endLine: undefined, lastLine: i + 1, meta: {}, body: [] }; continue }
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
- const m = line.match(/^\*\*([A-Za-z-]+):\*\*\s*(.*)$/)
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
- let end = lines.length
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
- let end = lines.length
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
- let bodyStart = end
173
- for (let i = idIdx; i < end; i++) {
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
- while (bodyStart < end && lines[bodyStart].trim() === '') bodyStart++
177
- lines.splice(bodyStart, end - bodyStart, ...String(newBody).split('\n'))
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
- ...(agent ? {} : {}),
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
- ? ledger.filter((e) => e.file.startsWith('workspaces/' + key + '/'))
46
- : ledger
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
- return rankBlend(hits, buildLedgerFor(store.root, hits.map((h) => h.file))).slice(0, limit)
73
- .map((h) => ({ file: h.file, line: h.line, section: h.section ?? '', snippet: h.snippet, id: h.id ?? '', score: Math.round(h.score * 1000) / 1000 }))
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
  }