dsh-hypatia-auto-memory 0.1.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/cascade.js ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Hierarchical summary cascade — the log₁₆(n) archive the protocol calls for.
3
+ *
4
+ * A span summary (`summary 1`) condenses messages. Sixteen unarchived `summary 1`
5
+ * entries condense into one `summary 2`, sixteen of those into a `summary 3`,
6
+ * and so on. `docs/memory-nolinear.md` argues the point: the alternative — one
7
+ * model call reading ten thousand words and emitting two hundred — loses
8
+ * information in a way nobody can bound. Each tier here compresses only material
9
+ * that a previous tier already distilled, so loss accumulates at the edges of
10
+ * each step instead of in a single collapse. Nothing is deleted; the lower tiers
11
+ * simply leave the hot path, and a reader drills down through `summary` edges
12
+ * when they need the detail.
13
+ *
14
+ * Membership comes from hypatia's own `$not-summaried`, which anti-joins on
15
+ * `statement.tail` for `relation = 'summary'` and returns `created_at ASC` — so
16
+ * "the oldest sixteen entries at this tier that nothing has archived yet" is one
17
+ * query, and the FIFO batch order the protocol specifies falls out of it.
18
+ *
19
+ * @module dsh-hypatia-auto-memory/cascade
20
+ */
21
+
22
+ import { createHash } from 'node:crypto'
23
+
24
+ import { PLUGIN_NAME } from './consolidator.js'
25
+
26
+ /** Tag marking the tier an entry belongs to. */
27
+ export function levelTag(level) {
28
+ return `summary ${level}`
29
+ }
30
+
31
+ /**
32
+ * Name of a tier-N archive entry.
33
+ *
34
+ * Derived from its members so a replay of the same batch reproduces the same
35
+ * name. It keeps the `sum` prefix every downstream exclusion list already knows
36
+ * (`hypatia-dream`'s among them). The protocol asks for descriptive names; a
37
+ * model-authored title cannot be used as the key because the model may word it
38
+ * differently on a retry and silently fork the entry — so the title goes in the
39
+ * body's first heading instead, and the key stays derivable.
40
+ */
41
+ export function archiveName(level, members) {
42
+ const digest = createHash('sha256').update([...members].sort().join('\n')).digest('hex').slice(0, 12)
43
+ return `sum${level}-${digest}`
44
+ }
45
+
46
+ /** JSE selecting the oldest unarchived entries of one tier within a project. */
47
+ export function notSummarisedQuery(tag, project, limit) {
48
+ // The OBJECT form is the only one that carries `limit`: appending a metadata
49
+ // object to the array form is rejected as "unexpected node in condition
50
+ // context" (asserted in test/integration/cli-contract.test.mjs).
51
+ return JSON.stringify({
52
+ '$not-summaried': [tag, ['$contains', 'scopes', project]],
53
+ limit,
54
+ })
55
+ }
56
+
57
+ /** Directive for one archive step. */
58
+ function archiveInstruction(level, count) {
59
+ return [
60
+ `You are the memory archivist of an AI coding assistant, building tier ${level} of a`,
61
+ 'hierarchical archive. Below are',
62
+ `${count} summaries from tier ${level - 1}, oldest first.`,
63
+ '',
64
+ 'Output EXACTLY one JSON object — no prose, no markdown fences:',
65
+ '{"title": "<short descriptive noun phrase naming what this stretch of work was about>",',
66
+ ' "summary": "<markdown: 5-10 terse bullets>"}',
67
+ '',
68
+ 'Compress the narrative, not the conclusions. Keep decisions and their reasons, exact',
69
+ 'identifiers, and anything still unresolved. Drop chronology and restatement. You are',
70
+ 'reading material that was already distilled once, so do not distil it to vagueness —',
71
+ 'a bullet that could describe any project is worse than no bullet.',
72
+ 'Write in the same language the summaries use.',
73
+ ].join('\n')
74
+ }
75
+
76
+ /**
77
+ * @param {{
78
+ * cli: ReturnType<import('./hypatia-client.js').createHypatiaClient>,
79
+ * llm: any,
80
+ * selectRoute: (routes: any) => any,
81
+ * getConfig: () => any,
82
+ * status: import('./status.js').StatusLog,
83
+ * }} deps
84
+ */
85
+ export function createCascade({ cli, llm, selectRoute, getConfig, status }) {
86
+ /**
87
+ * Ask the model to archive one batch.
88
+ * @returns {Promise<{title: string, summary: string} | undefined>}
89
+ */
90
+ async function archiveBatch(level, rows, route, timeoutMs) {
91
+ const body = rows.map((row, i) => {
92
+ const data = typeof row?.content?.data === 'string' ? row.content.data : ''
93
+ return `### ${i + 1}. ${row.name}\n${data}`
94
+ }).join('\n\n')
95
+
96
+ const controller = new AbortController()
97
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
98
+ try {
99
+ const { createUserMessage, BlockAssembler } = await import('@deepseek-ai/dsh-llm')
100
+ const assembler = new BlockAssembler()
101
+ for await (const chunk of llm.stream({
102
+ provider: route.provider,
103
+ model: route.model,
104
+ system: 'You produce strict JSON only. You never add prose around it.',
105
+ messages: [createUserMessage({
106
+ content: [{ type: 'text', text: `${archiveInstruction(level, rows.length)}\n\n${body}` }],
107
+ source: { kind: 'plugin', plugin: PLUGIN_NAME },
108
+ })],
109
+ maxTokens: getConfig().consolidation.maxOutputTokens,
110
+ purpose: 'memory-cascade',
111
+ signal: controller.signal,
112
+ })) {
113
+ assembler.push(chunk)
114
+ }
115
+ if (assembler.finish.kind !== 'stop') return undefined
116
+ const text = assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim()
117
+ const fence = /^```(?:json)?\s*([\s\S]*?)```\s*$/.exec(text)
118
+ const parsed = JSON.parse(fence ? fence[1].trim() : text)
119
+ const summary = typeof parsed?.summary === 'string' ? parsed.summary.trim() : ''
120
+ if (summary === '') return undefined
121
+ const title = typeof parsed?.title === 'string' && parsed.title.trim() !== ''
122
+ ? parsed.title.trim()
123
+ : `Archive tier ${level}`
124
+ return { title, summary }
125
+ } finally {
126
+ clearTimeout(timer)
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Run the cascade for one project, from tier 2 upward, until a tier has fewer
132
+ * than `batchSize` unarchived entries.
133
+ *
134
+ * @param {{project: string}} task
135
+ */
136
+ async function execute(task) {
137
+ const config = getConfig()
138
+ const consolidation = config.consolidation
139
+ if (consolidation.cascade?.enabled === false) return
140
+ const batchSize = consolidation.cascade?.batchSize ?? 16
141
+ const route = selectRoute(consolidation.models)
142
+ if (route === undefined) return
143
+
144
+ // A ceiling on tiers, not an expected depth: reaching tier 8 at 16:1 would
145
+ // mean ~4 billion source entries. It exists so a malformed shelf cannot spin.
146
+ for (let level = 2; level <= 8; level += 1) {
147
+ const sourceTag = levelTag(level - 1)
148
+ let rows
149
+ try {
150
+ rows = await cli.query(notSummarisedQuery(sourceTag, task.project, batchSize))
151
+ } catch (error) {
152
+ status.warn(`cascade query failed at tier ${level}: ${String(error)}`)
153
+ return
154
+ }
155
+ if (rows.length < batchSize) return
156
+
157
+ const members = rows.map((row) => row?.name).filter((name) => typeof name === 'string')
158
+ if (members.length < batchSize) return
159
+ const name = archiveName(level, members)
160
+
161
+ const existing = await cli.knowledgeGet(name)
162
+ if (existing.found === false) {
163
+ const archived = await archiveBatch(level, rows, route, consolidation.timeoutMs)
164
+ if (archived === undefined) {
165
+ status.warn(`cascade archive produced nothing usable at tier ${level}`)
166
+ return
167
+ }
168
+ await cli.knowledgeCreate(name, {
169
+ data: `# ${archived.title}\n\n${archived.summary}`,
170
+ tags: ['summary', levelTag(level)],
171
+ scopes: [task.project],
172
+ })
173
+ status.count('written')
174
+ }
175
+ // Edges are asserted whether or not the entry is new, so a crash between
176
+ // the two leaves something a replay can finish. Note the consequence when
177
+ // it does crash mid-batch: the members that DID get linked drop out of the
178
+ // next `$not-summaried` result, so the retry archives a different batch
179
+ // under a different name and the first entry keeps only its partial set.
180
+ // Nothing is lost — every member still ends up archived exactly once — but
181
+ // the grouping boundary is not the one the first attempt intended.
182
+ for (const member of members) {
183
+ await cli.statementCreate(name, 'summary', member, { scopes: [task.project] })
184
+ }
185
+ status.info(`cascade: tier ${level} archived ${members.length} entries as ${name}`)
186
+ }
187
+ }
188
+
189
+ return { execute, archiveBatch }
190
+ }
@@ -0,0 +1,420 @@
1
+ /* Bundle-local implementation of the DSH plugin-settings card treatment. */
2
+
3
+ .card {
4
+ list-style: none;
5
+ border: 0.5px solid var(--dsw-alias-border-l4);
6
+ border-radius: 16px;
7
+ background: var(--dsw-alias-bg-layer-3);
8
+ transition: border-color .16s, background .16s;
9
+ }
10
+
11
+ .card:hover {
12
+ border-color: var(--dsw-alias-label-dimmed);
13
+ }
14
+
15
+ .cardOpen {
16
+ border-color: var(--dsw-alias-label-dimmed);
17
+ background: var(--dsw-alias-bg-layer-2);
18
+ }
19
+
20
+ .header {
21
+ display: flex;
22
+ width: 100%;
23
+ align-items: center;
24
+ gap: 12px;
25
+ appearance: none;
26
+ border: 0;
27
+ border-radius: 12px;
28
+ background: none;
29
+ color: inherit;
30
+ cursor: pointer;
31
+ font: inherit;
32
+ padding: 14px 16px;
33
+ text-align: left;
34
+ }
35
+
36
+ .header:focus-visible {
37
+ outline: 2px solid var(--dsw-alias-brand-primary);
38
+ outline-offset: -2px;
39
+ }
40
+
41
+ .headText {
42
+ display: flex;
43
+ flex: 1;
44
+ min-width: 0;
45
+ flex-direction: column;
46
+ gap: 4px;
47
+ }
48
+
49
+ .name {
50
+ color: var(--dsw-alias-label-primary);
51
+ font-size: 15px;
52
+ font-weight: 600;
53
+ line-height: 1.4;
54
+ }
55
+
56
+ .description {
57
+ color: var(--dsw-alias-label-tertiary);
58
+ font-size: 13px;
59
+ line-height: 1.5;
60
+ }
61
+
62
+ .pending {
63
+ flex: none;
64
+ border-radius: 999px;
65
+ corner-shape: round;
66
+ background: var(--dsw-alias-bg-module-platform);
67
+ color: var(--dsw-alias-label-secondary);
68
+ font-size: 11px;
69
+ font-weight: 500;
70
+ line-height: 17px;
71
+ padding: 1px 8px;
72
+ white-space: nowrap;
73
+ }
74
+
75
+ .chevron {
76
+ flex: none;
77
+ color: var(--dsw-alias-label-tertiary);
78
+ transition: transform .16s;
79
+ }
80
+
81
+ .chevronOpen {
82
+ transform: rotate(180deg);
83
+ }
84
+
85
+ .body {
86
+ margin: 0 16px;
87
+ border-top: 0.5px solid var(--dsw-alias-border-l2);
88
+ padding-bottom: 8px;
89
+ }
90
+
91
+ .status,
92
+ .readOnly,
93
+ .hint,
94
+ .advancedHint,
95
+ .error {
96
+ margin: 0;
97
+ font-size: 12px;
98
+ line-height: 1.5;
99
+ }
100
+
101
+ .status,
102
+ .readOnly,
103
+ .hint,
104
+ .advancedHint {
105
+ color: var(--dsw-alias-label-tertiary);
106
+ }
107
+
108
+ .status {
109
+ list-style: none;
110
+ padding: 12px 16px;
111
+ }
112
+
113
+ .readOnly {
114
+ padding-top: 12px;
115
+ }
116
+
117
+ .field {
118
+ display: grid;
119
+ gap: 6px;
120
+ padding: 12px 0;
121
+ }
122
+
123
+ .body > .field + .group,
124
+ .body > .group + .field {
125
+ border-top: 0.5px solid var(--dsw-alias-border-l2);
126
+ }
127
+
128
+ .group {
129
+ padding: 12px 0;
130
+ }
131
+
132
+ .groupTitle {
133
+ margin: 0;
134
+ color: var(--dsw-alias-label-primary);
135
+ font-size: 13px;
136
+ font-weight: 500;
137
+ line-height: 1.5;
138
+ }
139
+
140
+ .limitsTitle {
141
+ margin: 16px 0 0;
142
+ padding-top: 12px;
143
+ border-top: 0.5px solid var(--dsw-alias-border-l2);
144
+ color: var(--dsw-alias-label-primary);
145
+ font-size: 13px;
146
+ font-weight: 500;
147
+ line-height: 1.5;
148
+ }
149
+
150
+ .limitsTitle + .hint {
151
+ margin-top: 4px;
152
+ }
153
+
154
+ .group > .field:first-of-type {
155
+ padding-top: 12px;
156
+ }
157
+
158
+ .group > .field + .field,
159
+ .pairedFields {
160
+ border-top: 0.5px solid var(--dsw-alias-border-l2);
161
+ }
162
+
163
+ .fieldHead {
164
+ display: flex;
165
+ align-items: center;
166
+ gap: 8px;
167
+ }
168
+
169
+ .label {
170
+ flex: 1;
171
+ min-width: 0;
172
+ color: var(--dsw-alias-label-primary);
173
+ font-size: 13px;
174
+ font-weight: 500;
175
+ line-height: 1.5;
176
+ }
177
+
178
+ .reset {
179
+ border: 0;
180
+ background: none;
181
+ color: var(--dsw-alias-label-secondary);
182
+ cursor: pointer;
183
+ font: inherit;
184
+ font-size: 12px;
185
+ line-height: 1.5;
186
+ padding: 0;
187
+ }
188
+
189
+ .reset:hover:not(:disabled) {
190
+ color: var(--dsw-alias-label-primary);
191
+ }
192
+
193
+ .reset:disabled {
194
+ cursor: default;
195
+ }
196
+
197
+ .input {
198
+ display: flex;
199
+ box-sizing: border-box;
200
+ min-width: 0;
201
+ width: 100%;
202
+ }
203
+
204
+ .select {
205
+ box-sizing: border-box;
206
+ width: 100%;
207
+ min-width: 0;
208
+ height: 32px;
209
+ border: 0.5px solid var(--dsw-alias-border-l3);
210
+ border-radius: 8px;
211
+ background: transparent;
212
+ color: var(--dsw-alias-label-primary);
213
+ font: inherit;
214
+ font-size: 13px;
215
+ padding: 0 8px;
216
+ }
217
+
218
+ .select:disabled {
219
+ opacity: 0.5;
220
+ }
221
+
222
+ .select:focus-visible {
223
+ outline: 2px solid var(--dsw-alias-brand-primary);
224
+ outline-offset: 2px;
225
+ }
226
+
227
+ .warning {
228
+ margin: 0;
229
+ color: var(--dsw-alias-label-error);
230
+ font-size: 12px;
231
+ line-height: 1.5;
232
+ }
233
+
234
+ .pairedFields {
235
+ display: grid;
236
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
237
+ gap: 16px;
238
+ margin: 4px 0;
239
+ padding: 4px 0;
240
+ }
241
+
242
+ .pairedFields .field {
243
+ min-width: 0;
244
+ }
245
+
246
+ .switch {
247
+ box-sizing: border-box;
248
+ position: relative;
249
+ width: 36px;
250
+ height: 20px;
251
+ border: 0;
252
+ border-radius: 10px;
253
+ background: var(--dsw-alias-border-l3);
254
+ cursor: pointer;
255
+ padding: 2px;
256
+ }
257
+
258
+ .switchOn {
259
+ background: var(--dsw-alias-brand-primary);
260
+ }
261
+
262
+ .switch:disabled {
263
+ cursor: default;
264
+ opacity: 0.5;
265
+ }
266
+
267
+ .switch:focus-visible {
268
+ outline: 2px solid var(--dsw-alias-brand-primary);
269
+ outline-offset: 2px;
270
+ }
271
+
272
+ .switchThumb {
273
+ display: block;
274
+ width: 16px;
275
+ height: 16px;
276
+ border-radius: 50%;
277
+ corner-shape: round;
278
+ background: var(--dsw-alias-label-primary-foreground);
279
+ transition: transform 120ms ease;
280
+ }
281
+
282
+ .switchOn .switchThumb {
283
+ transform: translateX(16px);
284
+ }
285
+
286
+ .modelSelection {
287
+ display: grid;
288
+ gap: 10px;
289
+ padding: 12px 0;
290
+ }
291
+
292
+ .notice {
293
+ margin: 0;
294
+ color: var(--dsw-alias-label-tertiary);
295
+ font-size: 12px;
296
+ line-height: 1.5;
297
+ }
298
+
299
+ .catalogError {
300
+ display: flex;
301
+ align-items: center;
302
+ justify-content: space-between;
303
+ gap: 12px;
304
+ color: var(--dsw-alias-label-error);
305
+ font-size: 12px;
306
+ line-height: 1.5;
307
+ }
308
+
309
+ .catalogError button {
310
+ border: 0;
311
+ background: transparent;
312
+ color: var(--dsw-alias-brand-primary);
313
+ cursor: pointer;
314
+ font: inherit;
315
+ padding: 0;
316
+ }
317
+
318
+ .models {
319
+ display: grid;
320
+ min-width: 0;
321
+ max-height: 280px;
322
+ gap: 6px;
323
+ margin: 0;
324
+ padding: 10px;
325
+ overflow: auto;
326
+ border: 0.5px solid var(--dsw-alias-border-l4);
327
+ border-radius: 8px;
328
+ }
329
+
330
+ .models legend {
331
+ padding: 0 4px;
332
+ color: var(--dsw-alias-label-secondary);
333
+ font-size: 12px;
334
+ }
335
+
336
+ .modelGroup {
337
+ display: grid;
338
+ gap: 6px;
339
+ }
340
+
341
+ .modelGroup + .modelGroup {
342
+ margin-top: 4px;
343
+ border-top: 0.5px solid var(--dsw-alias-border-l3);
344
+ padding-top: 10px;
345
+ }
346
+
347
+ .providerName {
348
+ padding: 0 6px;
349
+ color: var(--dsw-alias-label-tertiary);
350
+ font-size: 11px;
351
+ font-weight: 500;
352
+ }
353
+
354
+ .model {
355
+ display: grid;
356
+ grid-template-columns: auto minmax(0, 1fr) auto;
357
+ align-items: center;
358
+ gap: 8px;
359
+ min-width: 0;
360
+ border-radius: 6px;
361
+ cursor: pointer;
362
+ padding: 6px;
363
+ }
364
+
365
+ .model:hover {
366
+ background: var(--dsw-alias-bg-layer-4);
367
+ }
368
+
369
+ .modelName,
370
+ .route {
371
+ display: block;
372
+ overflow: hidden;
373
+ text-overflow: ellipsis;
374
+ white-space: nowrap;
375
+ }
376
+
377
+ .modelName {
378
+ color: var(--dsw-alias-label-primary);
379
+ font-size: 13px;
380
+ }
381
+
382
+ .route {
383
+ margin-top: 2px;
384
+ color: var(--dsw-alias-label-tertiary);
385
+ font-size: 11px;
386
+ }
387
+
388
+ .unavailable {
389
+ color: var(--dsw-alias-label-tertiary);
390
+ font-size: 11px;
391
+ }
392
+
393
+ .error {
394
+ color: var(--dsw-alias-label-error);
395
+ }
396
+
397
+ .advancedHint {
398
+ border-top: 0.5px solid var(--dsw-alias-border-l2);
399
+ padding-top: 12px;
400
+ }
401
+
402
+ .footer {
403
+ display: flex;
404
+ align-items: center;
405
+ justify-content: flex-end;
406
+ gap: 8px;
407
+ border-top: 0.5px solid var(--dsw-alias-border-l2);
408
+ padding: 12px 0 4px;
409
+ }
410
+
411
+ @media (max-width: 480px) {
412
+ .pairedFields {
413
+ grid-template-columns: minmax(0, 1fr);
414
+ gap: 0;
415
+ }
416
+
417
+ .pairedFields .field + .field {
418
+ border-top: 0.5px solid var(--dsw-alias-border-l2);
419
+ }
420
+ }