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/config.js ADDED
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Settings namespace and live configuration for dsh-hypatia-auto-memory.
3
+ *
4
+ * All user-facing behavior knobs live in the `hypatia-auto-memory` settings
5
+ * namespace so they are editable through settings.yaml / Web settings and
6
+ * hot-updatable through the settings provider. The cordis `config` block on
7
+ * the bundle row only carries skill-packaging knobs (see index.js).
8
+ *
9
+ * @module dsh-hypatia-auto-memory/config
10
+ */
11
+
12
+ import z from '@deepseek-ai/schemastery'
13
+
14
+ /** Settings namespace carrying the auto-memory policy. */
15
+ export const SETTINGS_NAMESPACE = 'hypatia-auto-memory'
16
+
17
+ /** Composition defaults; every schema field mirrors one of these. */
18
+ export const DEFAULTS = {
19
+ enabled: true,
20
+ binaries: ['hypatia'],
21
+ // How the plugin itself talks to hypatia: a private `hypatia mcp` process,
22
+ // or one CLI command per call. A binary without `mcp` falls back to the CLI
23
+ // on its own (see hypatia-client.js). Applies from the next call.
24
+ transport: 'mcp',
25
+ // Where every entry this plugin writes — and every lookup it makes — goes.
26
+ // Read at startup, like `enabled`: see shelf.js for what a switch does.
27
+ shelf: 'default',
28
+ // Approve the AGENT's own `hypatia …` bash calls (retrieval, explicit
29
+ // remember/forget). This plugin's own writes never need it — they go over
30
+ // its own connection, never through a tool call. Read at startup, like `enabled`.
31
+ autoApprove: true,
32
+ collector: {
33
+ enabled: true,
34
+ maxAssistantChars: 8000,
35
+ // User messages were never capped, so a pasted log went into hypatia whole.
36
+ // Generous, because what the user typed is the part most worth keeping.
37
+ maxUserChars: 32000,
38
+ toolLedger: true,
39
+ },
40
+ consolidation: {
41
+ enabled: true,
42
+ models: [],
43
+ maxInputTokens: 16000,
44
+ maxOutputTokens: 2000,
45
+ timeoutMs: 120_000,
46
+ checkEveryTurns: 5,
47
+ minNewTokens: 3000,
48
+ maxWorkUnitsPerRun: 3,
49
+ // Work-unit relationship adjudication. `dedupMaxDistance` is a cosine
50
+ // distance ceiling on candidates worth judging at all; anything further
51
+ // away is not a relative, and asking about it only invites a wrong edge.
52
+ adjudicate: true,
53
+ dedupMaxDistance: 0.45,
54
+ dedupCandidates: 5,
55
+ // Hierarchical archive. 16:1 per tier is the protocol's constant; it is what
56
+ // makes the summary layer log₁₆(n) deep instead of growing with the
57
+ // conversation.
58
+ cascade: {
59
+ enabled: true,
60
+ batchSize: 16,
61
+ },
62
+ },
63
+ queue: {
64
+ concurrency: 1,
65
+ maxAttempts: 3,
66
+ retryDelayMs: 5000,
67
+ flushWindowMs: 120_000,
68
+ },
69
+ // Recall is a session seed, not a per-turn injector: the agent pulls the rest
70
+ // through the bundled skill. `maxEntries` / `maxCharsPerEntry` belonged to the
71
+ // removed per-turn path and would now be knobs that control nothing.
72
+ recall: {
73
+ enabled: true,
74
+ preloadRulesTaboos: true,
75
+ },
76
+ // Startup housekeeping of the progress table (see housekeeping.js). Both act
77
+ // only on positive evidence and skip on any doubt.
78
+ housekeeping: {
79
+ // Reset a row whose session has no msg-* entry left in the shelf (a wiped
80
+ // or replaced shelf). A session whose messages were all deleted on purpose
81
+ // looks the same and would be logged again — switch off if that matters.
82
+ reconcileOnStartup: true,
83
+ // Drop the row and tasks of a session DSH no longer has.
84
+ pruneVanishedSessions: true,
85
+ // Consolidate a logged tail the in-session trigger never reached. A process
86
+ // restart takes the session-end path with it, so a short session would
87
+ // otherwise stay logged-but-never-distilled. Gated by the same
88
+ // `consolidation.minNewTokens` floor, read from the progress row.
89
+ backfillConsolidation: true,
90
+ },
91
+ }
92
+
93
+ /** Exact LLM route eligible for one consolidation attempt. */
94
+ const ConsolidationModelSchema = z.object({
95
+ provider: z.string(),
96
+ model: z.string(),
97
+ })
98
+
99
+ /** Schema of the auto-memory settings section. */
100
+ export const SettingsSchema = z.object({
101
+ enabled: z.boolean().default(DEFAULTS.enabled),
102
+ binaries: z.array(z.string()).default(DEFAULTS.binaries),
103
+ transport: z.union(['mcp', 'cli']).default(DEFAULTS.transport),
104
+ shelf: z.string().default(DEFAULTS.shelf),
105
+ autoApprove: z.boolean().default(DEFAULTS.autoApprove),
106
+ collector: z.object({
107
+ enabled: z.boolean().default(DEFAULTS.collector.enabled),
108
+ maxAssistantChars: z.number().step(1).min(500).default(DEFAULTS.collector.maxAssistantChars),
109
+ maxUserChars: z.number().step(1).min(500).default(DEFAULTS.collector.maxUserChars),
110
+ toolLedger: z.boolean().default(DEFAULTS.collector.toolLedger),
111
+ }).default(DEFAULTS.collector),
112
+ consolidation: z.object({
113
+ enabled: z.boolean().default(DEFAULTS.consolidation.enabled),
114
+ models: z.array(ConsolidationModelSchema).default(DEFAULTS.consolidation.models),
115
+ maxInputTokens: z.number().step(1).min(1000).default(DEFAULTS.consolidation.maxInputTokens),
116
+ maxOutputTokens: z.number().step(1).min(200).default(DEFAULTS.consolidation.maxOutputTokens),
117
+ timeoutMs: z.number().step(1).min(1000).max(3_600_000).default(DEFAULTS.consolidation.timeoutMs),
118
+ checkEveryTurns: z.number().step(1).min(1).default(DEFAULTS.consolidation.checkEveryTurns),
119
+ minNewTokens: z.number().step(1).min(500).default(DEFAULTS.consolidation.minNewTokens),
120
+ maxWorkUnitsPerRun: z.number().step(1).min(1).max(10).default(DEFAULTS.consolidation.maxWorkUnitsPerRun),
121
+ adjudicate: z.boolean().default(DEFAULTS.consolidation.adjudicate),
122
+ dedupMaxDistance: z.number().min(0).max(2).default(DEFAULTS.consolidation.dedupMaxDistance),
123
+ dedupCandidates: z.number().step(1).min(1).max(20).default(DEFAULTS.consolidation.dedupCandidates),
124
+ cascade: z.object({
125
+ enabled: z.boolean().default(DEFAULTS.consolidation.cascade.enabled),
126
+ batchSize: z.number().step(1).min(2).max(64).default(DEFAULTS.consolidation.cascade.batchSize),
127
+ }).default(DEFAULTS.consolidation.cascade),
128
+ }).default(DEFAULTS.consolidation),
129
+ queue: z.object({
130
+ concurrency: z.number().step(1).min(1).max(8).default(DEFAULTS.queue.concurrency),
131
+ maxAttempts: z.number().step(1).min(1).max(10).default(DEFAULTS.queue.maxAttempts),
132
+ retryDelayMs: z.number().step(1).min(100).default(DEFAULTS.queue.retryDelayMs),
133
+ flushWindowMs: z.number().step(1).min(0).max(600_000).default(DEFAULTS.queue.flushWindowMs),
134
+ }).default(DEFAULTS.queue),
135
+ recall: z.object({
136
+ enabled: z.boolean().default(DEFAULTS.recall.enabled),
137
+ preloadRulesTaboos: z.boolean().default(DEFAULTS.recall.preloadRulesTaboos),
138
+ }).default(DEFAULTS.recall),
139
+ housekeeping: z.object({
140
+ reconcileOnStartup: z.boolean().default(DEFAULTS.housekeeping.reconcileOnStartup),
141
+ pruneVanishedSessions: z.boolean().default(DEFAULTS.housekeeping.pruneVanishedSessions),
142
+ backfillConsolidation: z.boolean().default(DEFAULTS.housekeeping.backfillConsolidation),
143
+ }).default(DEFAULTS.housekeeping),
144
+ })
145
+
146
+ /**
147
+ * Read-only namespace publishing the shelves `hypatia list` reports, so the
148
+ * settings card can offer them as choices. The browser cannot run the CLI, and
149
+ * a settings descriptor is the one Host-owned value this plugin can put on the
150
+ * wire. Nothing is ever written to it: the Host re-registers it with a fresh
151
+ * composition layer (`base`) whenever the listing changes, and no card claims
152
+ * the namespace, so it renders nowhere.
153
+ */
154
+ export const INVENTORY_NAMESPACE = 'hypatia-auto-memory-shelves'
155
+
156
+ /** Schema of the shelf inventory; `error` is set when the listing failed. */
157
+ export const InventorySchema = z.object({
158
+ shelves: z.array(z.object({
159
+ name: z.string(),
160
+ path: z.string(),
161
+ connected: z.boolean(),
162
+ })).default([]),
163
+ error: z.string().default(''),
164
+ listedAt: z.number().default(0),
165
+ })
166
+
167
+ /**
168
+ * Validate the route list before its consumer sees it. An empty list is valid:
169
+ * logging continues while consolidation stays idle until the user selects one
170
+ * or more catalog routes.
171
+ */
172
+ function validateSettings(value) {
173
+ // One argv element to hypatia, and the name `hypatia list` prints: a blank
174
+ // or padded name would silently address a shelf that does not exist.
175
+ if (value.shelf === '' || /\s/.test(value.shelf)) {
176
+ throw new Error('hypatia-auto-memory: shelf must be a non-empty name without whitespace')
177
+ }
178
+ const seen = new Set()
179
+ for (const route of value.consolidation.models) {
180
+ if (route.provider.trim() === '' || route.model.trim() === '') {
181
+ throw new Error('hypatia-auto-memory: every consolidation model route needs a provider and model')
182
+ }
183
+ const key = `${route.provider}\0${route.model}`
184
+ if (seen.has(key)) {
185
+ throw new Error(`hypatia-auto-memory: duplicate consolidation model route ${route.provider}/${route.model}`)
186
+ }
187
+ seen.add(key)
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Attach this plugin's settings section to the provider (when present) and
193
+ * return a live config handle. Reads through the returned `get()` always see
194
+ * the currently authoritative value; `onChange` fires on attach, detach, and
195
+ * every committed change.
196
+ *
197
+ * @param {import('@deepseek-ai/cordis').Context} ctx - settings-consuming context.
198
+ * @returns {{get: () => any, onChange: (cb: (value: any) => void) => () => void}}
199
+ */
200
+ export function installConfig(ctx) {
201
+ let source = () => DEFAULTS
202
+ const listeners = new Set()
203
+ ctx.settings.installSection(ctx, SETTINGS_NAMESPACE, SettingsSchema, DEFAULTS, {
204
+ validate: validateSettings,
205
+ setSource: (current) => {
206
+ source = current
207
+ },
208
+ onChange: () => {
209
+ for (const cb of listeners) {
210
+ try {
211
+ cb(source())
212
+ } catch {
213
+ // A status listener must never break settings application.
214
+ }
215
+ }
216
+ },
217
+ })
218
+ return {
219
+ get: () => source(),
220
+ onChange(cb) {
221
+ listeners.add(cb)
222
+ return () => listeners.delete(cb)
223
+ },
224
+ }
225
+ }