@try-works/dsh-memory 0.1.32 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/STATUS.md CHANGED
@@ -3,10 +3,35 @@
3
3
  A tracker and planner: what exists, what is verified working, what is written but not
4
4
  loaded, and what is left. Kept as a record of MEASURED state, not intention.
5
5
 
6
- **Last measured: 2026-11-01** (409 tests passing; plugin at 0.1.30, published and installed
6
+ **Last measured: 2026-11-01** (522 tests passing; plugin at 0.2.0, published and installed
7
7
  byte-identical on harness 0.2.1-rc.2; queue terminal-clean; the Memory tab reads the published
8
8
  map under the new event name; the FTS index is 148.6 MB after a rebuild that used to be 436 MB,
9
- search measures 46-274 ms and an injection 3.3 s).
9
+ search measures 46-274 ms and an injection 3.3 s; the asset store ships phase 1).
10
+
11
+ ## Asset store, phase 1 (0.2.0)
12
+
13
+ Built from `docs/asset-store-proposal.md` and `docs/asset-api-contract.md` by three agents working
14
+ in parallel against one contract, with TDD throughout (failing test first; the acceptance suite is
15
+ independent of the implementation).
16
+
17
+ What exists: a new `assets` layer in both scopes; content-addressed blobs under
18
+ `assets/objects/<ab>/<sha256>.<ext>` carried by Git LFS (`.gitattributes`, `git lfs install
19
+ --local`, pointer detection); five tools (`memory_asset_put/get/list/forget/policy`); automatic
20
+ attachment capture on a human message when the class toggle is on, with no prompt; three class
21
+ toggles resolved as config (hard off) → workspace policy → global policy; 50 MB per file and 1 GB
22
+ per scope with refusals that surface the figures and ask the user through `ctx.userQuestions`
23
+ (forgive / raise / forget / cancel) instead of pruning; one model description from capture context
24
+ with a deterministic fallback; `memory_status` reporting asset bytes and quota notices; search
25
+ hits on asset entries carrying `{ sha256, mime, name, bytes, class }`.
26
+
27
+ What does NOT exist, by design: content indexing. The store documents a file ("What it is", "When
28
+ to read it"); it never extracts or indexes the file's contents, and a token that appears only
29
+ inside a PDF is deliberately not searchable (pinned by the acceptance suite).
30
+
31
+ Two integration decisions worth keeping: `refresh` skips the asset layer entirely (a supersede
32
+ would hide the entry while its inventory refcount stayed, orphaning the blob), and an automatic
33
+ capture over quota records a notice instead of asking, because `session/event` is reentrancy-
34
+ sensitive and has no answerer; only `memory_asset_put` asks.
10
35
 
11
36
  ## Store performance, 2026-11-01
12
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@try-works/dsh-memory",
3
- "version": "0.1.32",
3
+ "version": "0.2.0",
4
4
  "description": "Git-backed, workspace-scoped memory for the DeepSeek Harness (global layer + per-workspace layers)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -0,0 +1,181 @@
1
+ // The one model call that documents a captured asset.
2
+ //
3
+ // An asset is stored as bytes plus an entry, and the entry is what search finds later. At capture
4
+ // time the agent still has the context that made the file relevant - the user's request, the tool
5
+ // call, the task - and a later reader does not. This module turns that context (and ONLY that
6
+ // context: the file itself is never opened) into the two sections of the entry body:
7
+ //
8
+ // ## What it is
9
+ // ## When to read it
10
+ //
11
+ // The model is advisory. Capture must never fail for want of a description, so every failure
12
+ // (no model routed, a throw, an empty reply, an unparseable one) falls back to a deterministic
13
+ // pair of lines derived from the same context. "Deterministic" is a promise: the same context
14
+ // yields byte-identical text, with no timestamps and no randomness.
15
+
16
+ /** Bound on each generated section, so a verbose model cannot bloat the entry and the index. */
17
+ export const MAX_SECTION_CHARS = 1200
18
+ /** Bound on the excerpt of the user message quoted in the deterministic fallback. */
19
+ export const MAX_EXCERPT_CHARS = 160
20
+
21
+ /** Normalize any context value to bounded, single-line text without throwing on weird input. */
22
+ function text(value, maxChars = MAX_SECTION_CHARS) {
23
+ if (value === undefined || value === null) return ''
24
+ let rendered
25
+ if (typeof value === 'string') rendered = value
26
+ else {
27
+ try { rendered = JSON.stringify(value) } catch { rendered = String(value) }
28
+ }
29
+ return String(rendered ?? '').replace(/\s+/g, ' ').trim().slice(0, maxChars)
30
+ }
31
+
32
+ /**
33
+ * The fields this module documents from. Anything else in the input - a path, bytes, a contents
34
+ * field, a count - is deliberately ignored: this module never reads the file, so it cannot
35
+ * describe it.
36
+ *
37
+ * putAsset calls the describe hook with a small META object ({ class, mime, name, bytes,
38
+ * context: {userMessage, toolCall, task} }), while a direct caller passes the bare context. Both
39
+ * shapes are accepted here so the hook and the unit-test seam are the same function.
40
+ */
41
+ function captureFacts(context = {}) {
42
+ const source = context && typeof context === 'object' ? context : {}
43
+ const nested = source.context && typeof source.context === 'object' ? source.context : {}
44
+ return {
45
+ name: text(source.name, 240) || 'untitled file',
46
+ mime: text(source.mime, 120) || undefined,
47
+ class: text(source.class, 40) || undefined,
48
+ userMessage: text(source.userMessage ?? nested.userMessage, MAX_EXCERPT_CHARS) || undefined,
49
+ toolCall: text(source.toolCall ?? nested.toolCall, 120) || undefined,
50
+ task: text(source.task ?? nested.task, MAX_EXCERPT_CHARS) || undefined,
51
+ }
52
+ }
53
+
54
+ /**
55
+ * The deterministic `Asset-Used-For:` line.
56
+ *
57
+ * This is ALWAYS written to the entry, model or no model: it is the one sentence that survives a
58
+ * deployment with no routed model, and it records the situation in which the file was captured.
59
+ * Pure and deterministic for a given context.
60
+ */
61
+ export function usedForFrom(context = {}) {
62
+ const facts = captureFacts(context)
63
+ const parts = []
64
+ if (facts.userMessage) parts.push('while answering "' + facts.userMessage + '"')
65
+ if (facts.task) parts.push('for the task "' + facts.task + '"')
66
+ if (facts.toolCall) parts.push('(via ' + facts.toolCall + ')')
67
+ if (parts.length === 0) {
68
+ return 'Captured from the session context; the description was written without reading the file.'
69
+ }
70
+ return 'Captured ' + parts.join(' ') + '; the description was written without reading the file.'
71
+ }
72
+
73
+ /**
74
+ * The deterministic fallback description. Public because tests (and any caller that wants to
75
+ * know what the fallback will say before spending a model call) must be able to pin it.
76
+ * @returns {{whatItIs: string, whenToRead: string, usedFor: string}}
77
+ */
78
+ export function fallbackDescription(context = {}) {
79
+ const facts = captureFacts(context)
80
+ const kind = facts.class ? facts.class.replace(/s$/, '') + ' file' : 'file'
81
+ const type = facts.mime ? ' (' + facts.mime + ')' : ''
82
+ const whatItIs = 'A ' + kind + ' named "' + facts.name + '"' + type
83
+ + ', stored from the context of this session. Its contents were not read, so this line is a pointer rather than a summary.'
84
+ const trigger = facts.userMessage ? 'the request "' + facts.userMessage + '"'
85
+ : facts.task ? 'the task "' + facts.task + '"'
86
+ : 'a later request in this area'
87
+ const whenToRead = 'Read this when a task returns to ' + trigger
88
+ + ' or explicitly needs "' + facts.name + '". Open it with the appropriate reader; nothing from inside the file is indexed.'
89
+ return { whatItIs, whenToRead, usedFor: usedForFrom(context) }
90
+ }
91
+
92
+ /**
93
+ * The single prompt sent to the model. It spoils nothing beyond the capture context, and says so
94
+ * explicitly: a model asked to "describe this PDF" without being told it cannot see it will
95
+ * invent a plausible table of contents, which is exactly the failure this store can least afford.
96
+ */
97
+ export function buildDescribePrompt(context = {}) {
98
+ const facts = captureFacts(context)
99
+ const lines = [
100
+ 'Write the catalogue entry for a file being kept in a memory store.',
101
+ 'You have NOT read the file and cannot see its contents. Describe the file only from the capture context below, and say nothing that would require opening it. Do not invent sections, tables, numbers, or page counts. Write plain sentences: no markdown headings and no bullet lists.',
102
+ '',
103
+ 'Reply in exactly this form, with no preamble and no extra sections:',
104
+ '',
105
+ 'WHAT IT IS:',
106
+ '<one or two sentences on what this file is, from the context>',
107
+ '',
108
+ 'WHEN TO READ IT:',
109
+ '<one or two sentences on which future requests should open it, and which should not>',
110
+ '',
111
+ 'Capture context:',
112
+ '- name: ' + facts.name,
113
+ ]
114
+ if (facts.mime) lines.push('- mime type: ' + facts.mime)
115
+ if (facts.class) lines.push('- class: ' + facts.class)
116
+ if (facts.userMessage) lines.push('- what the user was doing: ' + facts.userMessage)
117
+ if (facts.task) lines.push('- task: ' + facts.task)
118
+ if (facts.toolCall) lines.push('- tool call: ' + facts.toolCall)
119
+ return lines.join('\n')
120
+ }
121
+
122
+ /**
123
+ * Parse the two sections out of a model reply.
124
+ *
125
+ * Tolerant of the forms a model actually produces: `WHAT IT IS:` / `When to read it:`, with or
126
+ * without markdown heading markers. Returns undefined unless BOTH sections yield non-empty text,
127
+ * because half an entry is worse than the deterministic fallback: a missing "When to read it"
128
+ * turns the asset invisible to the only query that matters. An optional NOTES section is carried
129
+ * through when the model volunteered one; the text itself never keeps its section heading.
130
+ * @returns {{whatItIs: string, whenToRead: string, notes?: string}|undefined}
131
+ */
132
+ export function parseDescription(reply) {
133
+ const body = String(reply ?? '').trim()
134
+ if (body === '') return undefined
135
+ const section = (heading) => {
136
+ const pattern = new RegExp(
137
+ '(?:^|\\n)\\s*(?:#{1,6}\\s*)?' + heading + '\\s*:?\\s*\\n([\\s\\S]*?)(?=(?:\\n\\s*(?:#{1,6}\\s*)?(?:WHAT IT IS|WHEN TO READ IT|NOTES)\\s*:?\\s*(?:\\n|$))|$)',
138
+ 'i',
139
+ )
140
+ const match = pattern.exec(body)
141
+ const value = match ? match[1].trim() : ''
142
+ return value === '' ? undefined : value.slice(0, MAX_SECTION_CHARS)
143
+ }
144
+ const whatItIs = section('WHAT IT IS')
145
+ const whenToRead = section('WHEN TO READ IT')
146
+ if (!whatItIs || !whenToRead) return undefined
147
+ const notes = section('NOTES')
148
+ return notes === undefined ? { whatItIs, whenToRead } : { whatItIs, whenToRead, notes }
149
+ }
150
+
151
+ /**
152
+ * Describe one captured asset: one model call, or the deterministic fallback.
153
+ *
154
+ * The returned text has NO heading markers of its own - the entry renderer owns the `### What it
155
+ * is` / `### When to read it` headings and demotes anything heading-like inside the text.
156
+ *
157
+ * @param context - capture context or putAsset's describe-hook meta. Only the documentation facts
158
+ * are used; a path or bytes in the input is ignored, because this function never reads the file.
159
+ * @param options.llm - optional async (prompt) => text. Called at most once.
160
+ * @returns {{whatItIs: string, whenToRead: string, usedFor: string, source: 'model'|'fallback', reason?: string, notes?: string}}
161
+ */
162
+ export async function describeAsset(context = {}, { llm } = {}) {
163
+ const fallback = fallbackDescription(context)
164
+ if (typeof llm !== 'function') return { ...fallback, source: 'fallback', reason: 'no model available' }
165
+ let reply
166
+ try {
167
+ reply = await llm(buildDescribePrompt(context))
168
+ } catch (error) {
169
+ return { ...fallback, source: 'fallback', reason: 'the model call failed: ' + text(error && error.message ? error.message : error, 160) }
170
+ }
171
+ if (typeof reply !== 'string' || reply.trim() === '') {
172
+ return { ...fallback, source: 'fallback', reason: 'the model returned nothing' }
173
+ }
174
+ const parsed = parseDescription(reply)
175
+ if (!parsed) {
176
+ return { ...fallback, source: 'fallback', reason: 'the model reply did not contain both sections' }
177
+ }
178
+ return { ...parsed, usedFor: fallback.usedFor, source: 'model' }
179
+ }
180
+
181
+ export default describeAsset
@@ -0,0 +1,169 @@
1
+ // Asset entries: an ordinary Format 2 entry in the `asset` layer, plus Asset-* headers and the
2
+ // documentation sections search relies on.
3
+ //
4
+ // Rendering and parsing are pure, so the round trip is testable without a store. The bytes never
5
+ // enter the entry: it holds metadata and the documentation a later reader searches for.
6
+ import { formatEntry } from '../entry.js'
7
+
8
+ /** Body section titles. Search relies on exactly these headings. */
9
+ export const SECTION_WHAT = 'What it is'
10
+ export const SECTION_WHEN = 'When to read it'
11
+ export const SECTION_NOTES = 'Notes'
12
+
13
+ /**
14
+ * Body sections are H3, not H2: `## ` is Format 2's entry delimiter, so an H2 section would split
15
+ * an asset into an entry plus ignored pseudo-entries. The parser still accepts H2 section titles for
16
+ * entries written before that decision (and for hand-written ones), which is why parseEntries also
17
+ * carries the defensive H2-as-body rule for entries that already hold `Asset-Sha256`.
18
+ */
19
+ const SECTION_PREFIX = '### '
20
+
21
+ /** Collapse a value onto one line: headers are one field per line, and a newline would end it. */
22
+ function oneLine(value) {
23
+ return String(value ?? '').replace(/\r?\n+/g, ' ').trim()
24
+ }
25
+
26
+ function optionalNumber(value) {
27
+ const number = Number(value)
28
+ return Number.isFinite(number) && number >= 0 ? number : 0
29
+ }
30
+
31
+ function assetHeaderLines(asset = {}) {
32
+ const lines = [
33
+ '**Asset-Class:** ' + oneLine(asset.class),
34
+ '**Asset-Mime:** ' + oneLine(asset.mime),
35
+ '**Asset-Bytes:** ' + String(optionalNumber(asset.bytes)),
36
+ '**Asset-Sha256:** ' + oneLine(asset.sha256),
37
+ '**Asset-Name:** ' + oneLine(asset.name),
38
+ '**Asset-Origin:** ' + oneLine(asset.origin ?? 'inline'),
39
+ '**Asset-Used-For:** ' + oneLine(asset.usedFor),
40
+ ]
41
+ const description = oneLine(asset.description)
42
+ if (description !== '') lines.push('**Asset-Description:** ' + description)
43
+ return lines
44
+ }
45
+
46
+ /**
47
+ * Normalize a documentation body: demote any markdown heading inside it. A model-supplied
48
+ * description may contain `## something`; at column zero that would terminate the entry (and, for
49
+ * the three reserved titles, the section), so nested headings are lifted to `###`.
50
+ */
51
+ function sectionText(value) {
52
+ return String(value ?? '')
53
+ .replace(/\r\n/g, '\n')
54
+ .split('\n')
55
+ .map((line) => line.replace(/^(\s*)#{1,6}\s+/, '$1### '))
56
+ .join('\n')
57
+ .trim()
58
+ }
59
+
60
+ function section(title, text) {
61
+ const body = sectionText(text)
62
+ return body === '' ? '' : SECTION_PREFIX + title + '\n\n' + body
63
+ }
64
+
65
+ /**
66
+ * Render one asset entry block, heading included.
67
+ * @param input.id - Format 2 id (`global-ast-0001`).
68
+ * @param input.asset - { class, mime, bytes, sha256, name, origin, usedFor, description? }.
69
+ * @param input.documentation - { whatItIs, whenToRead, notes? }; the two named sections are the
70
+ * search surface and are always written by putAsset (which supplies a deterministic fallback).
71
+ * @returns the markdown block, ready for appendToTopic.
72
+ */
73
+ export function renderAssetEntry({
74
+ id,
75
+ source,
76
+ date,
77
+ importance = 2,
78
+ status = 'active',
79
+ volatility,
80
+ verified,
81
+ until,
82
+ heading,
83
+ asset,
84
+ documentation = {},
85
+ } = {}) {
86
+ const title = oneLine(heading ?? asset?.name) || 'asset'
87
+ const sections = [
88
+ section(SECTION_WHAT, documentation.whatItIs),
89
+ section(SECTION_WHEN, documentation.whenToRead),
90
+ ]
91
+ const notes = section(SECTION_NOTES, documentation.notes)
92
+ if (notes !== '') sections.push(notes)
93
+ const content = [assetHeaderLines(asset).join('\n'), ...sections.filter((part) => part !== '')]
94
+ .filter((part) => part !== '')
95
+ .join('\n\n')
96
+ const metadata = formatEntry({
97
+ id,
98
+ source,
99
+ date,
100
+ importance,
101
+ status,
102
+ volatility,
103
+ verified,
104
+ until,
105
+ content,
106
+ })
107
+ return '## ' + title + '\n\n' + metadata
108
+ }
109
+
110
+ /** Split an entry body into the three documented sections. H3 is the format; H2 is tolerated. */
111
+ function parseSections(content) {
112
+ const sections = { whatItIs: undefined, whenToRead: undefined, notes: undefined }
113
+ const buffers = { whatItIs: [], whenToRead: [], notes: [] }
114
+ let current
115
+ const flush = () => {
116
+ if (current === undefined) return
117
+ const text = buffers[current].join('\n').trim()
118
+ if (text !== '') sections[current] = text
119
+ }
120
+ for (const line of String(content ?? '').split('\n')) {
121
+ const heading = line.match(/^#{2,3}\s+(.+)$/)
122
+ if (heading) {
123
+ flush()
124
+ const title = heading[1].trim().toLowerCase()
125
+ current = title === SECTION_WHAT.toLowerCase()
126
+ ? 'whatItIs'
127
+ : title === SECTION_WHEN.toLowerCase()
128
+ ? 'whenToRead'
129
+ : title === SECTION_NOTES.toLowerCase()
130
+ ? 'notes'
131
+ : undefined
132
+ if (current !== undefined) buffers[current] = []
133
+ continue
134
+ }
135
+ if (current !== undefined) buffers[current].push(line)
136
+ }
137
+ flush()
138
+ return sections
139
+ }
140
+
141
+ /**
142
+ * Parse an asset entry from the projection parseEntries returns (raw `meta` + body).
143
+ * @returns the asset, or undefined when the entry is not an asset entry.
144
+ */
145
+ export function parseAssetEntry(entry) {
146
+ if (entry === null || entry === undefined || entry.meta === null || entry.meta === undefined) return undefined
147
+ const meta = entry.meta
148
+ const sha256 = meta['Asset-Sha256']
149
+ if (!sha256) return undefined
150
+ const sections = parseSections(entry.content)
151
+ return {
152
+ id: entry.id,
153
+ heading: entry.heading,
154
+ date: entry.date,
155
+ updated: entry.updated,
156
+ status: entry.status,
157
+ name: meta['Asset-Name'],
158
+ class: meta['Asset-Class'],
159
+ mime: meta['Asset-Mime'],
160
+ bytes: optionalNumber(meta['Asset-Bytes']),
161
+ sha256,
162
+ origin: meta['Asset-Origin'],
163
+ usedFor: meta['Asset-Used-For'],
164
+ description: meta['Asset-Description'],
165
+ whatItIs: sections.whatItIs,
166
+ whenToRead: sections.whenToRead,
167
+ notes: sections.notes,
168
+ }
169
+ }