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/LICENSE +21 -0
- package/README.md +454 -0
- package/cordis.patch.yml +20 -0
- package/lib/client.js +1081 -0
- package/lib/client.js.map +1 -0
- package/package.json +90 -0
- package/skills/hypatia/SKILL.md +585 -0
- package/skills/hypatia-dream/SKILL.md +246 -0
- package/skills/hypatia-dream/evals/evals.json +92 -0
- package/skills/hypatia-memory/SKILL.md +175 -0
- package/src/auto-approve.js +133 -0
- package/src/cascade.js +190 -0
- package/src/client/SettingsCard.module.css +420 -0
- package/src/client/SettingsCard.tsx +700 -0
- package/src/client/card-form.ts +450 -0
- package/src/client/consolidation-models.ts +69 -0
- package/src/client/css-modules.d.ts +4 -0
- package/src/client/index.tsx +106 -0
- package/src/client/locales.ts +111 -0
- package/src/client/shelves.ts +88 -0
- package/src/client/slot-contract.ts +17 -0
- package/src/collector.js +596 -0
- package/src/config.js +225 -0
- package/src/consolidator.js +592 -0
- package/src/content-policy.js +301 -0
- package/src/housekeeping.js +197 -0
- package/src/hypatia-cli.js +294 -0
- package/src/hypatia-client.js +72 -0
- package/src/hypatia-mcp.js +477 -0
- package/src/index.js +418 -0
- package/src/persisted-session.js +99 -0
- package/src/progress.js +56 -0
- package/src/queue.js +466 -0
- package/src/recall.js +148 -0
- package/src/shelf.js +180 -0
- package/src/skills.js +166 -0
- package/src/state.js +87 -0
- package/src/status.js +53 -0
- package/src/writer.js +320 -0
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
|
+
}
|