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/shelf.js ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The shelf auto-memory writes to, and what follows from being able to change it.
3
+ *
4
+ * Every hypatia call the plugin makes targets one shelf, read from
5
+ * `hypatia-auto-memory.shelf` at startup. It is a startup setting — like
6
+ * `enabled` — because a queue already holding work for one shelf must not
7
+ * carry it into another halfway through.
8
+ *
9
+ * Changing it is a switch, not a migration. The progress and task tables are
10
+ * this plugin's record of how far each session was written *into a shelf*, so
11
+ * they are kept per shelf: switching to a new shelf starts its sessions from
12
+ * zero there, and switching back resumes exactly where the old shelf's
13
+ * watermarks stopped — nothing is re-logged or re-consolidated into a shelf
14
+ * that already has it. Rows of the `default` shelf keep their original,
15
+ * unprefixed keys, so a profile that never changes the setting reads the
16
+ * tables exactly as before.
17
+ *
18
+ * @module dsh-hypatia-auto-memory/shelf
19
+ */
20
+
21
+ /** Shelf hypatia itself falls back to when no `--shelf` is given. */
22
+ export const DEFAULT_SHELF = 'default'
23
+
24
+ /**
25
+ * Key prefix of a non-default shelf's rows. Legacy keys are session ids and
26
+ * `<kind>:<sessionId>` task ids, neither of which can start with `@`.
27
+ */
28
+ const PREFIX = '@shelf/'
29
+
30
+ function prefixFor(shelf) {
31
+ return shelf === DEFAULT_SHELF ? '' : `${PREFIX}${encodeURIComponent(shelf)}/`
32
+ }
33
+
34
+ /**
35
+ * A storage-domain table narrowed to one shelf's rows.
36
+ *
37
+ * Exposes the subset of the table API this plugin uses (`get`, `put`,
38
+ * `update`, `delete`, `entries`) with keys as callers know them. Create ONE
39
+ * view per table and share it: `advanceProgress` chains writes per table
40
+ * object, and two views of one table would not see each other's chain.
41
+ *
42
+ * @param {any} table - storage-domain table.
43
+ * @param {string} shelf - shelf whose rows the view reads and writes.
44
+ */
45
+ export function shelfTable(table, shelf) {
46
+ const prefix = prefixFor(shelf)
47
+ const own = (key) => (prefix === '' ? !key.startsWith(PREFIX) : key.startsWith(prefix))
48
+ return {
49
+ get: (key) => table.get(prefix + key),
50
+ put: (key, value) => table.put(prefix + key, value),
51
+ update: (key, fn) => table.update(prefix + key, fn),
52
+ delete: (key) => table.delete(prefix + key),
53
+ * entries() {
54
+ for (const [key, value] of table.entries()) {
55
+ if (own(key)) yield [key.slice(prefix.length), value]
56
+ }
57
+ },
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Parse `hypatia list`.
63
+ *
64
+ * The CLI prints one aligned row per registered shelf —
65
+ * ` <name> <path> [connected]` — or `No shelves registered.`. A name has no
66
+ * spaces (it is one clap argument), while a path may, so the name is the first
67
+ * token, the status the last, and the path everything between.
68
+ *
69
+ * @param {string} stdout
70
+ * @returns {{name: string, path: string, connected: boolean}[]}
71
+ */
72
+ export function parseShelfList(stdout) {
73
+ const shelves = []
74
+ for (const line of stdout.split('\n')) {
75
+ const match = /^\s*(\S+)\s+(.*?)\s+\[(connected|disconnected)\]\s*$/.exec(line)
76
+ if (match === null) continue
77
+ shelves.push({ name: match[1], path: match[2], connected: match[3] === 'connected' })
78
+ }
79
+ return shelves
80
+ }
81
+
82
+ /** How often the Host re-reads `hypatia list` for the settings card. */
83
+ const INVENTORY_INTERVAL_MS = 60_000
84
+
85
+ /**
86
+ * Publish `hypatia list` to the settings card through the read-only
87
+ * inventory namespace (see `INVENTORY_NAMESPACE` in config.js).
88
+ *
89
+ * The namespace is owned by a child fiber whose composition layer IS the
90
+ * listing, so publishing a new listing means disposing that fiber and
91
+ * registering again. That only happens when the listing changed; an unchanged
92
+ * poll spawns one CLI call and nothing else. A failed listing keeps the last
93
+ * shelves it had and says why, so the card can tell "no shelves" from "could
94
+ * not ask".
95
+ *
96
+ * @param {{
97
+ * ctx: import('@deepseek-ai/cordis').Context,
98
+ * cli: { listShelves: () => Promise<{name: string, path: string, connected: boolean}[]> },
99
+ * status: import('./status.js').StatusLog,
100
+ * namespace: string,
101
+ * schema: any,
102
+ * label: string,
103
+ * intervalMs?: number,
104
+ * now?: () => number,
105
+ * }} deps
106
+ * @returns {{refresh: () => Promise<{shelves: any[], error: string}>}}
107
+ */
108
+ export function publishShelfInventory({ ctx, cli, status, namespace, schema, label, intervalMs = INVENTORY_INTERVAL_MS, now = Date.now }) {
109
+ let fiber
110
+ let published = ''
111
+ let listing = { shelves: [], error: '' }
112
+ let chain = Promise.resolve()
113
+ let stopped = false
114
+ // A failure that repeats every poll is logged once, not once a minute.
115
+ let publishError = ''
116
+ const publishFailed = (error) => {
117
+ const message = String(error)
118
+ if (message !== publishError) status.warn(`shelf inventory not published: ${message}`)
119
+ publishError = message
120
+ }
121
+
122
+ async function publish() {
123
+ let next
124
+ try {
125
+ next = { shelves: await cli.listShelves(), error: '' }
126
+ } catch (error) {
127
+ next = { shelves: listing.shelves, error: error instanceof Error ? error.message : String(error) }
128
+ }
129
+ if (stopped) return listing
130
+ if (next.error !== '' && next.error !== listing.error) status.warn(`shelf listing failed: ${next.error}`)
131
+ listing = next
132
+ const key = JSON.stringify(next)
133
+ if (key === published) return listing
134
+ // Claimed up front so an overlapping poll does not publish twice; given
135
+ // back on any failure below, so the next poll retries the same listing.
136
+ published = key
137
+ const base = { ...next, listedAt: now() }
138
+ try {
139
+ const previous = fiber
140
+ fiber = undefined
141
+ await previous?.dispose()
142
+ // The profile may be unloading: the parent fiber refuses new children
143
+ // before the effect that sets `stopped` has run.
144
+ if (stopped) return listing
145
+ fiber = ctx.plugin({
146
+ name: `${label}/shelf-inventory`,
147
+ inject: ['settings'],
148
+ apply: (c) => {
149
+ try {
150
+ c.settings.register(namespace, schema, { base })
151
+ publishError = ''
152
+ } catch (error) {
153
+ published = ''
154
+ publishFailed(error)
155
+ }
156
+ },
157
+ })
158
+ } catch (error) {
159
+ published = ''
160
+ if (!stopped) publishFailed(error)
161
+ }
162
+ return listing
163
+ }
164
+
165
+ /** Serialized; never rejects, so callers may drop the promise. */
166
+ function refresh() {
167
+ const next = chain.then(publish, publish).catch(() => listing)
168
+ chain = next
169
+ return next
170
+ }
171
+
172
+ const timer = setInterval(() => { void refresh() }, intervalMs)
173
+ timer.unref?.()
174
+ ctx.effect(() => () => {
175
+ stopped = true
176
+ clearInterval(timer)
177
+ }, `${label}: stop shelf inventory`)
178
+
179
+ return { refresh }
180
+ }
package/src/skills.js ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Bundled skill packaging.
3
+ *
4
+ * Three skills ship with the plugin:
5
+ *
6
+ * hypatia-memory this plugin's own variant — the automatic layer does the
7
+ * writing, so the skill is about retrieval and explicit
8
+ * remember/forget, not about logging messages by hand.
9
+ * hypatia verbatim copy of hypatia's CLI reference.
10
+ * hypatia-dream verbatim copy of hypatia's consolidation pass.
11
+ *
12
+ * The last two used to arrive with `dsh-hypatia`. This plugin replaces it, so
13
+ * it carries them itself — otherwise removing `dsh-hypatia` from a profile
14
+ * would take the CLI reference away with it. Both are vendored by hand from
15
+ * the hypatia repository's `skills/`; nothing refreshes them automatically.
16
+ *
17
+ * @module dsh-hypatia-auto-memory/skills
18
+ */
19
+
20
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
21
+ import { dirname, join } from 'node:path'
22
+
23
+ /**
24
+ * Minimal YAML-frontmatter reader for flat `key: value` pairs — the grammar
25
+ * every shipped SKILL.md uses.
26
+ * @param {string} source
27
+ * @returns {{attributes: Record<string, string>, body: string}}
28
+ */
29
+ export function parseFrontmatter(source) {
30
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(source)
31
+ if (match === null) return { attributes: {}, body: source }
32
+ const attributes = {}
33
+ for (const line of match[1].split('\n')) {
34
+ const pair = /^([A-Za-z-]+):\s*(.*)$/.exec(line)
35
+ if (pair !== null) attributes[pair[1]] = pair[2].trim()
36
+ }
37
+ return { attributes, body: source.slice(match[0].length) }
38
+ }
39
+
40
+ /** Provider name DSH's filesystem skill provider registers under by default. */
41
+ const FILESYSTEM_PROVIDER = 'filesystem'
42
+
43
+ /**
44
+ * What kind of holder an already-registered copy of a skill has.
45
+ *
46
+ * - `disk`: a SKILL.md the filesystem provider found — in a project, user,
47
+ * custom or bundled root.
48
+ * - `runtime`: another plugin's registration (dsh-hypatia), or a provider this
49
+ * code does not know.
50
+ *
51
+ * The distinction matters because the two lose to this plugin differently.
52
+ * Runtime registrations in one layer are first-come: the registry keeps the
53
+ * first and ignores a second. A disk copy is subject to DSH's layering: the
54
+ * skill registry is layered per scope, the nearest layer's same-name entry
55
+ * wins outright, and rank (project 100/200 < runtime 250 < custom 300 < user
56
+ * 400/500 < bundled 600) only breaks ties inside one layer. Agent presets —
57
+ * `st`, `standard`, `cordis`, … — mount their own `skill-filesystem` into the
58
+ * preset's layer, nearer to the agent than this plugin's global registration.
59
+ * So in an agent session any disk copy wins, whatever its rank. Seen live:
60
+ * with `~/.agents/skills/hypatia-dream` present, a session loaded that copy
61
+ * while the global catalog (what the skill center shows) listed this plugin.
62
+ *
63
+ * @param {{provider?: string}} existing
64
+ * @returns {'disk' | 'runtime'}
65
+ */
66
+ export function holderKind(existing) {
67
+ return existing?.provider === FILESYSTEM_PROVIDER ? 'disk' : 'runtime'
68
+ }
69
+
70
+ /** Directory of an already-registered copy, when it lives on disk. */
71
+ function directoryOf(existing) {
72
+ if (existing?.resourceBase?.kind === 'directory' && typeof existing.resourceBase.path === 'string') {
73
+ return existing.resourceBase.path
74
+ }
75
+ return typeof existing?.path === 'string' ? dirname(existing.path) : undefined
76
+ }
77
+
78
+ /**
79
+ * Register every packaged skill, and say so loudly whenever another copy of
80
+ * the same name will reach the agent instead.
81
+ *
82
+ * Another plugin's registration is yielded to: the registry would ignore this
83
+ * one anyway. A disk copy is registered over — harmless, and it makes this
84
+ * plugin's copy the one any global view sees — but it still wins in agent
85
+ * sessions (see `holderKind`), so the warning names its directory. That
86
+ * matters most for `hypatia-memory`: `hypatia skill install --agent codex`
87
+ * writes the canonical one to `~/.agents/skills`, and it is the agent-driven
88
+ * protocol, which needs host hooks DSH does not have.
89
+ *
90
+ * @param {import('@deepseek-ai/cordis').Context} ctx - context injecting `skills`.
91
+ * @param {string} skillsDir - directory of `<name>/SKILL.md` folders.
92
+ * @param {import('./status.js').StatusLog} status
93
+ * @param {string} provider - provider tag recorded on each registration.
94
+ * @returns {Promise<string[]>} names actually registered.
95
+ */
96
+ export async function registerSkills(ctx, skillsDir, status, provider) {
97
+ let entries
98
+ try {
99
+ entries = readdirSync(skillsDir, { withFileTypes: true })
100
+ } catch {
101
+ status.warn(`skills dir unreadable: ${skillsDir}`)
102
+ return []
103
+ }
104
+ const registered = []
105
+ for (const entry of entries) {
106
+ if (entry.isDirectory() === false) continue
107
+ const skillFile = join(skillsDir, entry.name, 'SKILL.md')
108
+ if (existsSync(skillFile) === false) continue
109
+ // Guarded per skill: one unreadable file or one registry rejection used to
110
+ // throw out of the loop, so every skill after it — a set decided by readdir
111
+ // order, not by anything the user could reason about — was silently never
112
+ // registered, with one warning naming only the first casualty.
113
+ try {
114
+ const { attributes, body } = parseFrontmatter(readFileSync(skillFile, 'utf8'))
115
+ const skillName = attributes.name ?? entry.name
116
+ const existing = await ctx.skills.get(skillName).catch(() => undefined)
117
+ if (existing !== undefined && existing.provider !== provider) {
118
+ const why = skillName === 'hypatia-memory'
119
+ ? ' — the agent-driven memory protocol, which needs host hooks DSH does not have'
120
+ : ''
121
+ if (holderKind(existing) === 'runtime') {
122
+ const holder = existing.source ? `${existing.provider} (${existing.source})` : existing.provider
123
+ const advice = existing.provider === 'dsh-hypatia'
124
+ ? 'remove dsh-hypatia from the profile (`dsh plugin --profile <name> remove dsh-hypatia`) '
125
+ + 'or set `skills: false` on it'
126
+ : `disable the copy ${existing.provider} registers`
127
+ status.warn(
128
+ `skill "${skillName}" is already registered by ${holder}, and DSH keeps the first runtime `
129
+ + `registration, so the agent gets that copy${why}. To use this plugin's copy, ${advice}`,
130
+ )
131
+ continue
132
+ }
133
+ const where = directoryOf(existing) ?? 'a skill directory'
134
+ status.warn(
135
+ `skill "${skillName}": the ${existing.source ?? 'disk'} copy at ${where} will be used in agent `
136
+ + `sessions instead of this plugin's${why} — agent presets load skills from disk in a layer `
137
+ + 'nearer to the agent than plugin registrations. Delete it, or move it out of that skills '
138
+ + 'directory, to use this plugin\'s copy — renaming it in place is not enough, because DSH '
139
+ + 'names a skill by its frontmatter `name`, not its directory',
140
+ )
141
+ }
142
+ ctx.skills.register({
143
+ name: skillName,
144
+ description: attributes.description ?? '',
145
+ content: body,
146
+ path: skillFile,
147
+ source: 'bundled',
148
+ provider,
149
+ // A TAGGED UNION, not a path: DSH validates the loaded skill against
150
+ // `{kind:'directory',path} | {kind:'url',url} | {kind:'opaque'}` and a
151
+ // bare string fails that check at LOAD time, not registration — the
152
+ // skill appears in the registry and every attempt to read it comes back
153
+ // as `"value.resourceBase" must match exactly one oneOf branch
154
+ // (matched 0)` — what the agent hit on its first two calls once this
155
+ // plugin took over the skill names from dsh-hypatia.
156
+ resourceBase: { kind: 'directory', path: dirname(skillFile) },
157
+ invocation: { modelInvocable: true, userInvocable: attributes['user-invocable'] !== 'false' },
158
+ })
159
+ registered.push(skillName)
160
+ } catch (error) {
161
+ status.warn(`skill "${entry.name}" not registered: ${String(error)}`)
162
+ }
163
+ }
164
+ if (registered.length > 0) status.info(`registered bundled skills: ${registered.join(', ')}`)
165
+ return registered
166
+ }
package/src/state.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Durable state for dsh-hypatia-auto-memory: one storageDomain domain with a
3
+ * per-session progress watermark table and a task table. Reads are synchronous
4
+ * from in-memory state, writes resolve only after the backend acknowledges
5
+ * durability, so the queue never acknowledges work that a crash could lose.
6
+ *
7
+ * @module dsh-hypatia-auto-memory/state
8
+ */
9
+
10
+ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
11
+ import { z } from 'zod'
12
+
13
+ export { EMPTY_PROGRESS, advanceProgress } from './progress.js'
14
+
15
+ /** Per-session watermark: how far logging and consolidation have advanced. */
16
+ export const progressTable = domainTable(z.object({
17
+ /** Last session-log seq written to hypatia as a msg-* entry. */
18
+ lastLoggedSeq: z.number().step(1).min(0).default(0),
19
+ /** Last session-log seq covered by a consolidation run. */
20
+ lastConsolidatedSeq: z.number().step(1).min(0).default(0),
21
+ /** Turn when consolidation work was last durably accepted by the queue. */
22
+ lastCheckTurn: z.number().step(1).min(0).default(0),
23
+ /** Estimated unconsolidated tokens accumulated since that accepted task. */
24
+ pendingTokens: z.number().min(0).default(0),
25
+ /**
26
+ * 1 once a `session-<id>` entry exists. The protocol builds that node from a
27
+ * host-supplied session summary (a title, or a compaction summary) and
28
+ * forbids inventing one, so most sessions never grow it — the flag is what
29
+ * lets the log path skip the `belongTo` work rather than probing hypatia for
30
+ * an entry that will not be there.
31
+ */
32
+ hasSessionNode: z.number().step(1).min(0).max(1).default(0),
33
+ /** Message ordinal up to which `belongTo` edges have been asserted. */
34
+ lastBelongToIndex: z.number().step(1).min(0).default(0),
35
+ }))
36
+
37
+ /**
38
+ * One queued unit of work. `kind` is code-validated ('log-message' |
39
+ * 'consolidate' | 'cascade' | 'session-node'); keeping it a plain string here
40
+ * avoids schema-enum drift with the storage format. Tasks store seq ranges
41
+ * only — content is always re-read from the authoritative session log at
42
+ * execution time, never duplicated here.
43
+ */
44
+ export const taskTable = domainTable(z.object({
45
+ kind: z.string(),
46
+ sessionId: z.string(),
47
+ /** Half-open [fromSeq, toSeq) session-log range this task covers. */
48
+ fromSeq: z.number().step(1).min(0),
49
+ toSeq: z.number().step(1).min(0),
50
+ /** Project scope the entries are written under. */
51
+ project: z.string().default(''),
52
+ /** pending | running | deferred | failed (done tasks are deleted). */
53
+ status: z.string().default('pending'),
54
+ attempts: z.number().step(1).min(0).default(0),
55
+ // Nullable for legacy rows holding null, and DEFAULTED for rows that lack the
56
+ // key: `persistTask` did not write it, and the storage service validates on
57
+ // read rather than on write. One such row still in the table at a restart made
58
+ // the whole domain refuse to open, which silently disabled the plugin for
59
+ // that run — nothing collected, nothing resumed, one line in a log.
60
+ error: z.string().nullable().default(null),
61
+ enqueuedAt: z.number().default(0),
62
+ }))
63
+
64
+ /** The plugin's whole durable surface: two tables, versioned together.
65
+ * Domain names must match /^[a-z][a-z0-9_]*$/ — underscores, not hyphens. */
66
+ export const domainSpec = defineDomain({
67
+ name: 'hypatia_auto_memory',
68
+ version: 1,
69
+ tables: { progress: progressTable, tasks: taskTable },
70
+ })
71
+
72
+ /**
73
+ * Open the plugin domain through the storageDomain service and wire its
74
+ * lifecycle to the calling fiber.
75
+ *
76
+ * @param {import('@deepseek-ai/cordis').Context} ctx - context injecting `storageDomain`.
77
+ * @returns {Promise<{progress: any, tasks: any, close: () => Promise<void>}>}
78
+ */
79
+ export async function openState(ctx) {
80
+ const domain = await ctx.storageDomain.open(domainSpec)
81
+ ctx.effect(() => () => {
82
+ void domain.close().catch(() => {
83
+ // Closing during teardown must never take the fiber down.
84
+ })
85
+ }, 'hypatia-auto-memory: close state domain')
86
+ return { progress: domain.table('progress'), tasks: domain.table('tasks'), domain }
87
+ }
package/src/status.js ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Aggregated operational status: structured log lines plus counters that the
3
+ * README's ops checklist (and any future settings card) can read. Deliberately
4
+ * in-memory — durable facts (watermarks, failed tasks) live in the state
5
+ * domain; this is the live dashboard layer.
6
+ *
7
+ * @module dsh-hypatia-auto-memory/status
8
+ */
9
+
10
+ export function createStatus(ctx, label = 'hypatia-auto-memory') {
11
+ let log
12
+ const counters = new Map()
13
+ let lastError = ''
14
+ let lastConsolidatedAt = 0
15
+
16
+ const logger = () => {
17
+ if (log === undefined) {
18
+ try {
19
+ log = ctx.logger(label)
20
+ } catch {
21
+ log = { info: () => {}, warn: () => {}, error: () => {} }
22
+ }
23
+ }
24
+ return log
25
+ }
26
+
27
+ return {
28
+ info(message) {
29
+ logger().info(message)
30
+ },
31
+ warn(message) {
32
+ logger().warn(message)
33
+ },
34
+ error(message, error) {
35
+ lastError = error instanceof Error ? `${message}: ${error.message}` : `${message}: ${String(error)}`
36
+ logger().error(lastError)
37
+ },
38
+ /** Bump one counter ('written' | 'duplicate' | 'failed' | ...). */
39
+ count(name, by = 1) {
40
+ counters.set(name, (counters.get(name) ?? 0) + by)
41
+ },
42
+ markConsolidated() {
43
+ lastConsolidatedAt = Date.now()
44
+ },
45
+ snapshot() {
46
+ return {
47
+ counters: Object.fromEntries(counters),
48
+ lastError,
49
+ lastConsolidatedAt,
50
+ }
51
+ },
52
+ }
53
+ }