@try-works/dsh-memory 0.1.8 → 0.1.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@try-works/dsh-memory",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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",
package/src/config.js CHANGED
@@ -12,6 +12,9 @@ import { DEFAULT_CONFIG, defaultMemoryRoot } from './defaults.js'
12
12
  export const Config = z.object({
13
13
  // --- Settings -> Memory ---
14
14
  memoryRoot: z.string().default(defaultMemoryRoot()).volatile(),
15
+ // Where the schedule plugin keeps its tasks. Overridable so a test (or an unusual DSH_HOME)
16
+ // can point somewhere else; the default resolves DSH_HOME exactly as the harness does.
17
+ schedulePath: z.string().default('').volatile(),
15
18
  // A PREFERENCE, not a requirement: memory work walks a ladder of the models this
16
19
  // deployment can actually route to, so an empty value still works and an unusable model
17
20
  // is skipped rather than losing the memory.
package/src/host.js CHANGED
@@ -16,12 +16,14 @@ import { due, problems, readQueue, reapStalled, REASONS, STATES, summarize as su
16
16
  import { planRecovery, recoverOne } from './recover.js'
17
17
  import { logRun, noteActivity, readMetrics, record } from './telemetry.js'
18
18
  import { GitRunner, SyncScheduler, classifyGitResult, commitMessage } from './git.js'
19
+ import { backupTasks, defaultSchedulePath, planRestore, readScheduleStore } from './tasks.js'
19
20
  import { autoAssociateSession } from './session.js'
20
21
  import { readAssociations } from './workspace.js'
21
22
 
22
23
  const PLUGIN = 'dsh-memory'
23
24
 
24
25
  /** How often the idle ticker checks whether a cadence is due (the cadences self-throttle). */
26
+ const TASKS_BACKUP_MS = 30 * 60 * 1000
25
27
  const IDLE_TICK_MS = 60_000
26
28
 
27
29
  function makeGitRunner(ctx) {
@@ -66,6 +68,8 @@ export function wireHost(ctx, store, cfg) {
66
68
  const injectionThrottle = new Map()
67
69
  let lastConsolidateAttempt = 0
68
70
  let lastRefreshAttempt = 0
71
+ let lastTasksBackupAttempt = 0
72
+ let restoreAttempted = false
69
73
  let lastHistoryAttempt = 0
70
74
  let syncInFlight = false
71
75
 
@@ -595,6 +599,96 @@ export function wireHost(ctx, store, cfg) {
595
599
  return { initialized: false, committed, synced }
596
600
  }
597
601
 
602
+ /**
603
+ * Back up the scheduled tasks on the sync cadence.
604
+ *
605
+ * This rides a cadence rather than an event because the thing it protects against is not an
606
+ * event: the host DELETES a task when its session stops (schedule index.ts:421), so a task can
607
+ * vanish between two turns with nothing to observe. Only a periodic pass notices.
608
+ *
609
+ * Throttled like the other cadences, and it reports through the run log the same way: a backup
610
+ * that silently stopped working would be indistinguishable from a machine with no tasks.
611
+ */
612
+ function maybeBackupTasks() {
613
+ const now = Date.now()
614
+ if (now - lastTasksBackupAttempt < TASKS_BACKUP_MS) return
615
+ lastTasksBackupAttempt = now
616
+ try {
617
+ // `||`, not `??`: the config default is an empty string, and `??` passes that straight
618
+ // through, so the pass would read a path of '' , find no store, and report a clean run that
619
+ // backed up nothing. A silent no-op is the failure this whole layer exists to prevent.
620
+ const result = backupTasks(store.root, { schedulePath: cfg.schedulePath || defaultSchedulePath() })
621
+ logRun(store.root, 'tasks-backup', {
622
+ ok: result.error === null,
623
+ written: result.written,
624
+ refreshed: result.refreshed,
625
+ stopped: result.stopped,
626
+ skipped: result.skipped.length,
627
+ error: result.error ?? '',
628
+ })
629
+ } catch (error) {
630
+ logRun(store.root, 'tasks-backup', { ok: false, error: String((error && error.message) || error).slice(0, 160) })
631
+ }
632
+ }
633
+
634
+ /**
635
+ * Restore backed-up tasks into the live schedule store, once per process.
636
+ *
637
+ * WHY IT WAITS FOR A SESSION. The schedule runtime resolves an agent from the task's sessionId
638
+ * before every delivery and throws when it cannot (schedule runtime.ts:101). At boot there is no
639
+ * session, so a task restored then would be armed against an owner that does not exist and would
640
+ * fail on every due scan. The first real session is therefore the earliest moment a restore can
641
+ * produce a task that actually fires.
642
+ *
643
+ * The OWNER IS CHOSEN, NEVER ASKED FOR. That session becomes the task's owner, and the report
644
+ * says so: the host deletes a task when its owning session stops (schedule index.ts:421), so a
645
+ * reader needs to know which session holds them.
646
+ *
647
+ * ADDITIVE. It only ever creates; nothing here deletes or updates. A task already in the live
648
+ * store is left exactly as it is, matched by CONTENT because create() mints a fresh id
649
+ * (schedule index.ts:256) and ids therefore cannot identify anything across a restore.
650
+ */
651
+ async function maybeRestoreTasks(agent) {
652
+ if (restoreAttempted) return
653
+ if (!agent || isSubagentSession(agent.session)) return
654
+ const sessionId = agent.session && agent.session.id
655
+ if (!sessionId) return
656
+ const schedule = ctx.get('schedule')
657
+ if (!schedule || typeof schedule.create !== 'function') return
658
+ restoreAttempted = true
659
+ try {
660
+ const path = cfg.schedulePath || defaultSchedulePath()
661
+ const live = readScheduleStore(path)
662
+ if (live.error !== null) {
663
+ logRun(store.root, 'tasks-restore', { ok: false, error: live.error })
664
+ return
665
+ }
666
+ const plan = planRestore(store.root, { liveTasks: live.tasks })
667
+ const added = []
668
+ const failed = []
669
+ for (const entry of plan.add) {
670
+ try {
671
+ const record = await schedule.create(sessionId, entry.request)
672
+ added.push({ id: record && record.id, title: entry.request.title })
673
+ } catch (error) {
674
+ failed.push({ title: entry.request.title, error: String((error && error.message) || error).slice(0, 120) })
675
+ }
676
+ }
677
+ logRun(store.root, 'tasks-restore', {
678
+ ok: failed.length === 0,
679
+ owner: sessionId,
680
+ added: added.length,
681
+ present: plan.present.length,
682
+ paused: plan.paused.length,
683
+ refused: plan.refused.length,
684
+ failed: failed.length,
685
+ detail: failed.length ? failed : undefined,
686
+ })
687
+ } catch (error) {
688
+ logRun(store.root, 'tasks-restore', { ok: false, error: String((error && error.message) || error).slice(0, 160) })
689
+ }
690
+ }
691
+
598
692
  function maybeSync() {
599
693
  if (syncInFlight || !git) return
600
694
  if (!scheduler.shouldCommit() && !scheduler.shouldSync()) return
@@ -635,6 +729,9 @@ export function wireHost(ctx, store, cfg) {
635
729
  maybeRefresh(agent)
636
730
  maybeHistory(agent)
637
731
  maybeSync()
732
+ maybeBackupTasks()
733
+ // Only a real session can own a restored task, so the restore waits for this hook.
734
+ void maybeRestoreTasks(agent)
638
735
  const now = Date.now()
639
736
  if (step !== 1 && now - (injectionThrottle.get(agent.id) ?? 0) < cfg.injectionRefreshMs) return decision
640
737
  injectionThrottle.set(agent.id, now)
@@ -668,6 +765,7 @@ export function wireHost(ctx, store, cfg) {
668
765
  maybePrune()
669
766
  // The ticker has no agent, so history ingest waits for the next step in a session.
670
767
  maybeSync()
768
+ maybeBackupTasks()
671
769
  } catch (error) {
672
770
  ctx.logger.warn('dsh-memory: idle tick failed: ' + (error && error.message))
673
771
  }
@@ -693,5 +791,7 @@ export function wireHost(ctx, store, cfg) {
693
791
  // injectionMaxChars travels with the host so memory_map can describe the card that was
694
792
  // ACTUALLY built: the cap is configuration, and a snapshot built with a different cap would
695
793
  // report a size the session never saw.
696
- return { scheduler, syncNow: () => runSync(true), consolidate: consolidateNow, refresh: refreshNow, status: statusNow, recover: recoverNow, rerankSearch, workspaceOf, historyOf, injectionMaxChars: cfg.injectionMaxChars }
794
+ // Resolved once, here, where the config lives: tools.js has no access to cfg and a tool that
795
+ // reached for it would throw at call time (which is how memory_tasks first failed).
796
+ return { scheduler, schedulePath: cfg.schedulePath || defaultSchedulePath(), tasksNow: maybeBackupTasks, tasksRestore: maybeRestoreTasks, syncNow: () => runSync(true), consolidate: consolidateNow, refresh: refreshNow, status: statusNow, recover: recoverNow, rerankSearch, workspaceOf, historyOf, injectionMaxChars: cfg.injectionMaxChars }
697
797
  }
package/src/index.js CHANGED
@@ -41,6 +41,9 @@ export function hostExtras(host) {
41
41
  workspaceOf: host.workspaceOf,
42
42
  historyOf: host.historyOf,
43
43
  injectionMaxChars: host.injectionMaxChars,
44
+ tasksNow: host.tasksNow,
45
+ tasksRestore: host.tasksRestore,
46
+ schedulePath: host.schedulePath,
44
47
  }
45
48
  }
46
49
  export const name = 'dsh-memory'
package/src/tasks.js ADDED
@@ -0,0 +1,536 @@
1
+ // The tasks layer: a durable, portable record of the DSH scheduled tasks this machine had.
2
+ //
3
+ // Why this exists at all. ~/.dsh/storages/schedule.json holds the only copy of every automated
4
+ // task: it is in no git repository and no backup. A task is owned by the session that created
5
+ // it, and that ownership is load-bearing twice - runtime.ts:101 resolves an agent from
6
+ // task.sessionId before every delivery and throws when it cannot, and index.ts:421 DELETES
7
+ // every active task of a session when that session stops. So tasks die with their session and
8
+ // nothing anywhere keeps a second copy.
9
+ //
10
+ // Two rules shape the format.
11
+ //
12
+ // PORTABLE ONLY. The stored value is the task's `record` - id, kind, title, prompt, and the
13
+ // selector that says when it runs. `sessionId`, `status` and `deliveryHistory` are machine-local
14
+ // runtime state and are never written: carrying sessionId across machines is exactly what would
15
+ // make a restored task fail, because the session it names does not exist there.
16
+ //
17
+ // ONE FILE PER TASK, JSON. A record must round-trip EXACTLY - a cron expression, an IANA zone,
18
+ // and prompts that run to thousands of characters. Markdown would need a hand-rolled format and
19
+ // would be one escaping bug away from a corrupted schedule. index.md is the readable overview.
20
+
21
+ import { createHash } from 'node:crypto'
22
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
23
+ import { homedir } from 'node:os'
24
+ import { join } from 'node:path'
25
+ import { slugify } from './workspace-key.js'
26
+
27
+ /** Directory, under a scope root, holding one file per task plus the index. */
28
+ export const TASKS_DIR = 'tasks'
29
+ export const TASK_INDEX = 'index.md'
30
+
31
+ /**
32
+ * The lifecycle a backed-up task can rest in.
33
+ *
34
+ * The host records only two states (schedule types.ts:217, 'active' | 'inactive') and its
35
+ * 'inactive' means DELIVERED or recurrence-exhausted, not failed: a delivery failure throws
36
+ * before any commit (runtime.ts:101-102), so a task failing on every scan stays 'active' and
37
+ * leaves no trace. Everything below that the host cannot express is carried here instead.
38
+ */
39
+ export const TASK_STATES = {
40
+ ACTIVE: 'active',
41
+ PENDING: 'pending',
42
+ PAUSED: 'paused',
43
+ STOPPED: 'stopped',
44
+ FAILED: 'failed',
45
+ ORPHANED: 'orphaned',
46
+ SKIPPED: 'skipped',
47
+ DONE: 'done',
48
+ }
49
+
50
+ /**
51
+ * Every resting state's single exit, meaning 'something changed'.
52
+ *
53
+ * The queue in src/queue.js learned this the hard way: a state with no way out is a state a
54
+ * thing sits in forever, silently. A test walks this table so a new state cannot be added
55
+ * without one.
56
+ */
57
+ export const TASK_STATE_EXITS = {
58
+ active: ['stopped', 'done', 'failed'],
59
+ pending: ['active'],
60
+ paused: ['pending'],
61
+ stopped: ['pending'],
62
+ failed: ['active'],
63
+ orphaned: ['pending'],
64
+ skipped: ['active'],
65
+ done: ['active'],
66
+ }
67
+
68
+ const STATES = Object.values(TASK_STATES)
69
+
70
+ /**
71
+ * A stored state, or null when it is not one we know.
72
+ *
73
+ * Null rather than a throw or a silent default: an index that crashes on one bad file takes the
74
+ * whole layer with it, and quietly treating an unknown state as 'active' would report a task as
75
+ * armed when nothing has established that. The caller decides, and the index shows it verbatim.
76
+ */
77
+ export function readState(value) {
78
+ return STATES.includes(value) ? value : null;
79
+ }
80
+
81
+ /** The directory holding the task files for a scope root. */
82
+ export function tasksDir(root) { return join(root, TASKS_DIR) }
83
+
84
+ /**
85
+ * The fields that say WHEN a task runs. Used to tell one task from another.
86
+ *
87
+ * `id` and `scheduledAt` are deliberately absent. An id is minted fresh by the schedule service
88
+ * on every create (index.ts:256), so a restored task never keeps the id it was backed up with,
89
+ * and scheduledAt is recomputed from now. Keying on either would make restore re-add every task
90
+ * on every boot - the worst possible failure for something that runs automatically.
91
+ */
92
+ const SELECTOR_FIELDS = ['cron', 'daily', 'weekly', 'at', 'after_seconds', 'every_seconds', 'expression', 'timeZone']
93
+
94
+ /**
95
+ * The content identity of a task: what makes it the SAME task across a restore.
96
+ *
97
+ * Title plus kind plus whichever selector fields the record carries. Derived identically on the
98
+ * backup and restore sides, which is why it lives here as one function rather than being
99
+ * inlined twice. Two tasks sharing a title and a schedule are treated as one, which is the
100
+ * correct reading: no reader could tell them apart either.
101
+ */
102
+ export function contentKey(record) {
103
+ if (!record || typeof record !== 'object') return ''
104
+ const parts = [String(record.kind ?? ''), String(record.title ?? '').trim()]
105
+ for (const field of SELECTOR_FIELDS) {
106
+ const value = record[field]
107
+ if (value === undefined || value === null) continue
108
+ parts.push(field + '=' + (typeof value === 'object' ? JSON.stringify(value) : String(value)))
109
+ }
110
+ return parts.join('|')
111
+ }
112
+
113
+ /**
114
+ * The file one task lives in: its title as a slug, plus a short digest of the content key.
115
+ *
116
+ * The digest is what makes the name safe. Two tasks can share a title - the slug alone would
117
+ * have them overwrite each other - and the digest is derived from the content key, so a task's
118
+ * file keeps its name across restarts and across a backup/restore round trip. A title with no
119
+ * usable characters (a fully non-Latin or punctuation-only title) falls back to the digest
120
+ * rather than producing '.json'.
121
+ */
122
+ export function taskFileName(record) {
123
+ const key = contentKey(record)
124
+ const digest = createHash('sha1').update(key).digest('hex').slice(0, 8)
125
+ const slug = slugify(record && record.title)
126
+ return (slug === '' ? digest : slug + '-' + digest) + '.json'
127
+ }
128
+
129
+ export function taskFilePath(root, record) { return join(tasksDir(root), taskFileName(record)) }
130
+
131
+ /**
132
+ * What each state MEANS, in one line.
133
+ *
134
+ * Separate from deriveState's reasons on purpose. A derived reason explains a transition - why the
135
+ * state changed to what was just computed - and cannot be reused to caption a state that was
136
+ * simply read back off disk. Pairing the two is how an armed task briefly acquired the reason a
137
+ * stopped one carries.
138
+ */
139
+ const STATE_MEANING = {
140
+ [TASK_STATES.ACTIVE]: 'armed in the live store and waiting for its next occurrence',
141
+ [TASK_STATES.PENDING]: 'in the backup but not in the live store; a restore re-arms it',
142
+ [TASK_STATES.PAUSED]: 'held back on purpose; every automatic pass leaves it alone',
143
+ [TASK_STATES.STOPPED]: 'was armed, and its owning session stopped, so the host deleted it',
144
+ [TASK_STATES.FAILED]: 'was due and has still not delivered; the host records no failure of its own',
145
+ [TASK_STATES.ORPHANED]: 'armed, but the session that owns it cannot be resolved, so it can never fire',
146
+ [TASK_STATES.SKIPPED]: 'seen in the live store but not captured, because its kind is not one we can read',
147
+ [TASK_STATES.DONE]: 'delivered, or its recurrence is exhausted',
148
+ }
149
+
150
+ /**
151
+ * One index row: the task, its state, and what that state means.
152
+ * Exported so a test can caption a state with the real wording rather than a placeholder - a stub
153
+ * reason would have made the caption assertion pass while proving nothing about the page.
154
+ */
155
+ export function describeTask(task, now) {
156
+ const state = task.stateKnown ? task.state : deriveState({ stored: null, live: null, now }).state
157
+ return {
158
+ ...task,
159
+ fileName: task.file ?? '',
160
+ state,
161
+ reason: STATE_MEANING[state] ?? 'an unrecognised state, shown as found in the file',
162
+ }
163
+ }
164
+
165
+ function ensureTasksDir(root) {
166
+ const dir = tasksDir(root)
167
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
168
+ return dir
169
+ }
170
+
171
+ /**
172
+ * Write one task, replacing any file for the same content key.
173
+ * @returns the file path written.
174
+ */
175
+ export function writeTask(root, record, state, nowIso) {
176
+ ensureTasksDir(root)
177
+ const file = taskFilePath(root, record)
178
+ writeFileSync(file, JSON.stringify({ backedUpAt: nowIso, state, record }, null, 2) + '\n', 'utf8')
179
+ return file;
180
+ }
181
+
182
+ /**
183
+ * Read one task file.
184
+ * @returns { file, backedUpAt, state, raw, record } or null when the file is unreadable.
185
+ *
186
+ * A malformed file yields null rather than throwing: one bad file must not take the layer down,
187
+ * and the index reports what it could not read instead of pretending the task is absent.
188
+ */
189
+ export function readTaskFile(file) {
190
+ try {
191
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
192
+ if (!parsed || typeof parsed !== 'object' || !parsed.record || typeof parsed.record !== 'object') return null
193
+ return {
194
+ file,
195
+ backedUpAt: typeof parsed.backedUpAt === 'string' ? parsed.backedUpAt : null,
196
+ // Kept verbatim, including when unrecognised, so the index can show what it found.
197
+ state: typeof parsed.state === 'string' ? parsed.state : null,
198
+ stateKnown: readState(parsed.state) !== null,
199
+ record: parsed.record,
200
+ }
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Every task file in the layer, ordered by title so the index is stable between runs.
208
+ * @returns { tasks, unreadable } - unreadable names the files that could not be parsed.
209
+ */
210
+ export function listTasks(root) {
211
+ const dir = tasksDir(root)
212
+ if (!existsSync(dir)) return { tasks: [], unreadable: [] }
213
+ const tasks = [];
214
+ const unreadable = [];
215
+ for (const name of readdirSync(dir)) {
216
+ if (!name.endsWith('.json')) continue;
217
+ const task = readTaskFile(join(dir, name));
218
+ if (task === null) unreadable.push(name);
219
+ else tasks.push(task);
220
+ }
221
+ tasks.sort((a, b) => String(a.record.title ?? '').localeCompare(String(b.record.title ?? '')));
222
+ unreadable.sort();
223
+ return { tasks, unreadable };
224
+ }
225
+
226
+ /** How long past its due time an armed task may sit before we call it failed. */
227
+ export const OVERDUE_MS = 6 * 60 * 60 * 1000
228
+
229
+ /**
230
+ * Derive a task's state from what can actually be observed.
231
+ *
232
+ * This is the honest version of the vocabulary, and three entries are narrower than they first
233
+ * look, because the host does not publish what they name:
234
+ *
235
+ * - FAILED is not a status the host records. A delivery failure throws before any commit
236
+ * (runtime.ts:101-102), so a task failing on every scan stays 'active' with no trace. What IS
237
+ * observable is a task still armed long after the moment it was due, which is what this
238
+ * reports - 'due at X, still not delivered' rather than a claim about why.
239
+ * - ORPHANED is a live row whose owning session cannot be resolved, so it can never fire while
240
+ * still occupying the store. Detecting that needs the session lookup, which the caller does;
241
+ * it is passed in rather than guessed here.
242
+ * - SKIPPED is a live task with no backup file: seen, deliberately not captured.
243
+ *
244
+ * PAUSED outranks everything. An automatic restore must never silently re-arm something that was
245
+ * held back on purpose - that surprise is the thing this design exists to prevent.
246
+ *
247
+ * @returns { state, reason } - the reason is recorded so a reader is told why, not just what.
248
+ */
249
+ export function deriveState({ stored, live, now = Date.now(), ownerResolvable = true }) {
250
+ if (stored === TASK_STATES.PAUSED) {
251
+ return { state: TASK_STATES.PAUSED, reason: 'held back on purpose; automatic restore leaves it alone' };
252
+ }
253
+ if (live) {
254
+ if (!ownerResolvable) {
255
+ return { state: TASK_STATES.ORPHANED, reason: 'armed, but the session that owns it cannot be resolved, so it can never fire' };
256
+ }
257
+ if (live.status === 'inactive') {
258
+ return { state: TASK_STATES.DONE, reason: 'delivered, or its recurrence is exhausted' };
259
+ }
260
+ const due = Date.parse(live.record && live.record.scheduledAt ? live.record.scheduledAt : '');
261
+ if (Number.isFinite(due) && due < now - OVERDUE_MS) {
262
+ return { state: TASK_STATES.FAILED, reason: 'was due at ' + new Date(due).toISOString() + ' and has still not delivered' };
263
+ }
264
+ return { state: TASK_STATES.ACTIVE, reason: 'armed in the live store and waiting for its next occurrence' };
265
+ }
266
+ if (stored === TASK_STATES.ACTIVE) {
267
+ return { state: TASK_STATES.STOPPED, reason: 'was armed; the host deletes a task when its session stops' };
268
+ }
269
+ return { state: TASK_STATES.PENDING, reason: 'in the backup, not in the live store; a restore re-arms it' };
270
+ }
271
+
272
+ /**
273
+ * Render the layer index.
274
+ *
275
+ * Readable and diffable, which is the whole reason the tasks are files rather than one blob: a
276
+ * git diff of this page is how you see that a task appeared, changed state, or went missing.
277
+ * Unreadable files are listed rather than omitted - an index that hides what it could not parse
278
+ * reports a smaller world than exists.
279
+ */
280
+ export function renderTaskIndex(rows, { unreadable = [], nowIso, error = null } = {}) {
281
+ const out = ['# Scheduled tasks', ''];
282
+ out.push('Portable backup of the DSH scheduled tasks on this machine. One file per task, plus');
283
+ out.push('this index. Machine-local state (the owning session, delivery history) is never stored.');
284
+ out.push('');
285
+ out.push('Generated ' + nowIso + '. ' + rows.length + ' task(s).');
286
+ // A page that stayed silent while the backup could not read the store would read as a healthy
287
+ // machine with nothing to do. The failure belongs on the page, not only in a return value.
288
+ if (error) {
289
+ out.push('');
290
+ out.push('> **The last backup could not read the schedule store.** ' + String(error));
291
+ out.push('>');
292
+ out.push('> The tasks below are what memory already holds; they are not a fresh reading.');
293
+ }
294
+ out.push('');
295
+ if (rows.length === 0) {
296
+ out.push('No tasks are backed up yet. The backup runs on the memory sync cadence, or on demand');
297
+ out.push('with memory_tasks.');
298
+ out.push('');
299
+ } else {
300
+ out.push('| task | kind | schedule | state | backed up |');
301
+ out.push('| --- | --- | --- | --- | --- |');
302
+ for (const row of rows) {
303
+ out.push('| ' + cell(row.record.title) + ' | ' + cell(row.record.kind) + ' | ' + cell(scheduleOf(row.record)) + ' | ' + cell(stateCell(row)) + ' | ' + cell(row.backedUpAt) + ' |');
304
+ }
305
+ out.push('');
306
+ for (const row of rows) {
307
+ out.push('## ' + String(row.record.title ?? '(untitled)'));
308
+ out.push('');
309
+ out.push('- file: `' + row.fileName + '`');
310
+ out.push('- id: `' + String(row.record.id ?? '') + '` (diagnostic only; a restore mints a new one)');
311
+ out.push('- state: **' + row.state + '** - ' + row.reason);
312
+ out.push('');
313
+ }
314
+ }
315
+ if (unreadable.length > 0) {
316
+ out.push('## Could not be read');
317
+ out.push('');
318
+ for (const name of unreadable) out.push('- `' + name + '` - not valid task JSON, so it is neither counted nor drawn');
319
+ out.push('');
320
+ }
321
+ return out.join('\n');
322
+ }
323
+
324
+ /** A markdown table cell: a pipe or a newline in a title would otherwise break the row. */
325
+ function cell(value) {
326
+ return String(value ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim();
327
+ }
328
+
329
+ /** The schedule in words, so the index is readable without decoding the record. */
330
+ function scheduleOf(record) {
331
+ const kind = String(record.kind ?? '');
332
+ const zone = record.timeZone ? ' ' + record.timeZone : '';
333
+ if (kind === 'cron') return 'cron ' + String(record.expression ?? '') + zone;
334
+ if (kind === 'weekly') return 'weekly ' + JSON.stringify(record.weekly ?? {}) + zone;
335
+ if (kind === 'daily') return 'daily ' + JSON.stringify(record.daily ?? {}) + zone;
336
+ if (kind === 'every') return 'every ' + String(record.every_seconds ?? '') + 's';
337
+ if (kind === 'at') return 'at ' + String(record.scheduledAt ?? '');
338
+ if (kind === 'after') return 'after ' + String(record.after_seconds ?? '') + 's';
339
+ return kind;
340
+ }
341
+
342
+ /** The state cell: an unrecognised stored state is shown as found, never silently defaulted. */
343
+ function stateCell(row) {
344
+ return row.stateKnown === false ? row.state + ' (unknown state in file)' : row.state;
345
+ }
346
+
347
+ /** The kinds the schedule plugin can store (types.ts ScheduleRecord union). */
348
+ export const KNOWN_KINDS = ['after', 'at', 'every', 'daily', 'weekly', 'cron']
349
+
350
+ /**
351
+ * Where the schedule plugin keeps its tasks: a storage unit under the DSH home.
352
+ *
353
+ * DSH_HOME is the harness's own override (boot/app-boot reads it), defaulting to ~/.dsh - the
354
+ * same resolution the harness uses, so the plugin finds the store without being told.
355
+ */
356
+ export function defaultSchedulePath(home = homedir()) {
357
+ const configured = process.env.DSH_HOME
358
+ const dshHome = configured && configured !== '' ? configured : join(home, '.dsh')
359
+ return join(dshHome, 'storages', 'schedule.json')
360
+ }
361
+
362
+ /**
363
+ * Read the live schedule store.
364
+ * @returns { tasks, error, missing } where tasks are the rows as stored.
365
+ *
366
+ * A missing file is not an error: a machine that has never scheduled anything has no store, and
367
+ * the backup pass must read that as 'nothing to do' rather than as a failure. A file that exists
368
+ * but cannot be read IS reported, because writing an index over an unreadable store would quietly
369
+ * claim the machine has no tasks.
370
+ */
371
+ export function readScheduleStore(path) {
372
+ if (!existsSync(path)) return { tasks: [], error: null, missing: true }
373
+ let parsed
374
+ try {
375
+ parsed = JSON.parse(readFileSync(path, 'utf8'))
376
+ } catch (error) {
377
+ return { tasks: [], error: 'unreadable: ' + (error && error.message ? error.message : String(error)), missing: false }
378
+ }
379
+ const table = parsed && parsed.tables && parsed.tables.tasks
380
+ if (!table || typeof table !== 'object') return { tasks: [], error: 'no tables.tasks in the schedule store', missing: false }
381
+ return { tasks: Object.keys(table).map((id) => ({ id, ...table[id] })), error: null, missing: false }
382
+ }
383
+
384
+ /**
385
+ * Back up every live task, and refresh the state of every task already backed up.
386
+ *
387
+ * ADDITIVE, ALWAYS. Nothing here deletes: a task gone from the live store keeps its file and has
388
+ * its state rewritten to say so. A stale backup must never be able to wipe a schedule, and a task
389
+ * deleted on purpose must stay visible as gone rather than vanish from the record of what this
390
+ * machine used to run.
391
+ *
392
+ * A task whose kind we do not recognise is SKIPPED, not written: storing it would mean restoring
393
+ * it later without understanding it. It is reported, so the omission is visible.
394
+ */
395
+ export function backupTasks(root, { schedulePath, now = Date.now(), nowIso } = {}) {
396
+ const at = nowIso ?? new Date(now).toISOString()
397
+ const live = readScheduleStore(schedulePath ?? defaultSchedulePath())
398
+ const before = listTasks(root)
399
+ if (live.error !== null) {
400
+ // The index is still rewritten, so a page from an earlier pass cannot sit on disk describing a
401
+ // store this pass could not read.
402
+ writeTaskIndex(root, before.tasks.map((t) => describeTask(t, now)), { unreadable: before.unreadable, nowIso: at, error: live.error })
403
+ return { written: 0, refreshed: 0, skipped: [], stopped: 0, unreadable: before.unreadable, error: live.error }
404
+ }
405
+ const byKey = new Map()
406
+ for (const task of before.tasks) byKey.set(contentKey(task.record), task)
407
+ let written = 0
408
+ let refreshed = 0
409
+ const skipped = []
410
+ const seen = new Set()
411
+ for (const task of live.tasks) {
412
+ const record = task.record
413
+ if (!record || typeof record !== 'object') { skipped.push({ id: task.id, reason: 'no record' }); continue }
414
+ if (!KNOWN_KINDS.includes(String(record.kind))) {
415
+ skipped.push({ id: task.id, reason: 'unrecognised kind: ' + String(record.kind) })
416
+ continue
417
+ }
418
+ const key = contentKey(record)
419
+ seen.add(key)
420
+ const previous = byKey.get(key)
421
+ // A held-back task stays held back across every backup: that state is the user's decision, and
422
+ // a routine pass must not overwrite it with a derived one.
423
+ const held = previous && previous.stateKnown && previous.state === TASK_STATES.PAUSED
424
+ const derived = deriveState({ stored: held ? TASK_STATES.PAUSED : null, live: task, now })
425
+ writeTask(root, record, derived.state, previous ? previous.backedUpAt : at)
426
+ if (previous) refreshed += 1
427
+ else written += 1
428
+ }
429
+ let stopped = 0
430
+ const rows = []
431
+ for (const task of listTasks(root).tasks) {
432
+ if (seen.has(contentKey(task.record))) { rows.push(task); continue }
433
+ const derived = deriveState({ stored: task.stateKnown ? task.state : null, live: null, now })
434
+ if (derived.state !== task.state) {
435
+ writeTask(root, task.record, derived.state, task.backedUpAt ?? at)
436
+ stopped += 1
437
+ }
438
+ rows.push({ ...task, state: derived.state })
439
+ }
440
+ writeTaskIndex(root, rows.map((t) => describeTask(t, now)), { unreadable: before.unreadable, nowIso: at })
441
+ return { written, refreshed, skipped, stopped, unreadable: before.unreadable, error: null, rows, nowIso: at }
442
+ }
443
+
444
+ /**
445
+ * Write the layer index.
446
+ *
447
+ * Regenerated on every pass rather than appended: an index that only grows would report tasks the
448
+ * machine no longer runs as though they were still there. The per-task files are the truth; this
449
+ * page is a view of them, which is why losing it costs nothing.
450
+ * @returns the index path.
451
+ */
452
+ export function writeTaskIndex(root, rows, { unreadable = [], nowIso, error = null } = {}) {
453
+ ensureTasksDir(root)
454
+ const file = join(tasksDir(root), TASK_INDEX)
455
+ writeFileSync(file, renderTaskIndex(rows, { unreadable, nowIso, error }), 'utf8')
456
+ return file
457
+ }
458
+
459
+ /**
460
+ * Translate a stored record back into the request the schedule service accepts.
461
+ *
462
+ * The two shapes do NOT share field names, and the differences are the kind that fail silently:
463
+ * a record stores `timeZone` and `everySeconds` (types.ts ScheduleRecord union), while
464
+ * ScheduleCreateRequest takes `time_zone` and `every_seconds`. Passing the record straight
465
+ * through would drop the zone - a task would be re-armed in the wrong time zone, or refused, and
466
+ * nothing in the record would look wrong afterwards.
467
+ *
468
+ * One-shot kinds are REFUSED rather than approximated. An `at` record keeps only its resolved
469
+ * instant, which for any task that already ran is in the past, and an `after` record keeps a
470
+ * relative delay whose clock would restart from now. Re-arming either would fabricate a schedule
471
+ * the user never set, so the caller is told instead.
472
+ *
473
+ * @returns { request } or { error } naming exactly what could not be translated.
474
+ */
475
+ export function toCreateRequest(record) {
476
+ if (!record || typeof record !== 'object') return { error: 'no record' }
477
+ const title = String(record.title ?? '').trim()
478
+ const prompt = String(record.prompt ?? '')
479
+ if (title === '') return { error: 'no title' }
480
+ if (prompt === '') return { error: 'no prompt' }
481
+ const kind = String(record.kind ?? '')
482
+ const zone = record.timeZone
483
+ if (kind === 'cron') {
484
+ if (typeof record.expression !== 'string' || record.expression === '') return { error: 'cron with no expression' }
485
+ if (typeof zone !== 'string' || zone === '') return { error: 'cron with no time zone' }
486
+ return { request: { title, prompt, cron: { expression: record.expression, time_zone: zone } } }
487
+ }
488
+ if (kind === 'daily') {
489
+ if (typeof record.time !== 'string' || record.time === '') return { error: 'daily with no time' }
490
+ if (typeof zone !== 'string' || zone === '') return { error: 'daily with no time zone' }
491
+ return { request: { title, prompt, daily: { time: record.time, time_zone: zone } } }
492
+ }
493
+ if (kind === 'weekly') {
494
+ if (typeof record.time !== 'string' || record.time === '') return { error: 'weekly with no time' }
495
+ if (typeof zone !== 'string' || zone === '') return { error: 'weekly with no time zone' }
496
+ if (!Array.isArray(record.weekdays) || record.weekdays.length === 0) return { error: 'weekly with no weekdays' }
497
+ return { request: { title, prompt, weekly: { time: record.time, time_zone: zone, weekdays: [...record.weekdays] } } }
498
+ }
499
+ if (kind === 'every') {
500
+ if (!Number.isFinite(record.everySeconds) || record.everySeconds <= 0) return { error: 'every with no interval' }
501
+ return { request: { title, prompt, every_seconds: record.everySeconds } }
502
+ }
503
+ if (kind === 'at' || kind === 'after') {
504
+ return { error: 'a one-shot ' + kind + ' task is not restorable: it either already fired or its target is in the past, and re-arming it would invent a schedule that was never set' }
505
+ }
506
+ return { error: 'unrecognised kind: ' + kind }
507
+ }
508
+
509
+ /**
510
+ * Decide what a restore would do, without doing any of it.
511
+ *
512
+ * Pure, so the decision can be tested and shown before anything is written. Additive and
513
+ * de-duplicating by CONTENT: the service mints a fresh id on every create (schedule index.ts:256),
514
+ * so a restored task never keeps the id it was backed up with, and matching on ids would find
515
+ * nothing on the second boot and re-add everything.
516
+ *
517
+ * @returns { add, present, paused, refused } - one entry per backed-up task, each saying why.
518
+ */
519
+ export function planRestore(root, { liveTasks = [], now = Date.now() } = {}) {
520
+ const live = new Set(liveTasks.map((task) => contentKey(task.record)))
521
+ const { tasks, unreadable } = listTasks(root)
522
+ const add = []
523
+ const present = []
524
+ const paused = []
525
+ const refused = []
526
+ for (const task of tasks) {
527
+ const key = contentKey(task.record)
528
+ if (live.has(key)) { present.push({ task, reason: 'already in the live store, under whatever id it now carries' }); continue }
529
+ if (task.stateKnown && task.state === TASK_STATES.PAUSED) { paused.push({ task, reason: 'held back on purpose; automatic restore leaves it alone' }); continue }
530
+ if (task.stateKnown && task.state === TASK_STATES.SKIPPED) { refused.push({ task, reason: 'recorded as skipped' }); continue }
531
+ const translated = toCreateRequest(task.record)
532
+ if (translated.error) refused.push({ task, reason: translated.error })
533
+ else add.push({ task, request: translated.request })
534
+ }
535
+ return { add, present, paused, refused, unreadable }
536
+ }
package/src/tools.js CHANGED
@@ -12,6 +12,7 @@ import { appendActivity, clearActivity, readWorking, workingSummary, writeWorkin
12
12
  import { associate, listWorkspaces } from './workspace.js'
13
13
  import { forget, forgetUndo } from './forget.js'
14
14
  import { buildMap } from './map.js'
15
+ import { backupTasks, defaultSchedulePath, describeTask, listTasks, planRestore, readScheduleStore, TASK_STATES, writeTask } from './tasks.js'
15
16
 
16
17
  function renderHits(hits) {
17
18
  if (!hits || hits.length === 0) return [{ type: 'text', text: '(no memory matches)' }]
@@ -58,6 +59,25 @@ function renderMap(value) {
58
59
  }
59
60
  return parts.join('; ') + '.'
60
61
  }
62
+ /** The text face of a tasks report: what a caller without a UI still needs to know. */
63
+ function renderTasks(value) {
64
+ const parts = []
65
+ parts.push('Scheduled tasks: ' + value.live + ' live, ' + value.backedUp + ' backed up')
66
+ const p = value.pending ?? {}
67
+ if (p.add) parts.push(p.add + ' would be restored')
68
+ if (p.present) parts.push(p.present + ' already present')
69
+ if (p.paused) parts.push(p.paused + ' held back')
70
+ if (p.refused) parts.push(p.refused + ' refused')
71
+ if (value.changed) parts.push('this call: ' + JSON.stringify(value.changed))
72
+ const states = {}
73
+ for (const task of value.tasks ?? []) states[task.state] = (states[task.state] ?? 0) + 1
74
+ const summary = Object.keys(states).map((s) => s + ' ' + states[s]).join(', ')
75
+ if (summary) parts.push('states: ' + summary)
76
+ if (value.unreadable && value.unreadable.length) parts.push(value.unreadable.length + ' file(s) unreadable: ' + value.unreadable.join(', '))
77
+ if (value.error) parts.push('COULD NOT READ THE SCHEDULE STORE: ' + value.error)
78
+ return parts.join('; ') + '.'
79
+ }
80
+
61
81
  export function registerTools(ctx, store, extras = {}) {
62
82
  /**
63
83
  * Refuse a WRITE from a subagent session.
@@ -80,6 +100,9 @@ export function registerTools(ctx, store, extras = {}) {
80
100
  const statusNow = extras.status
81
101
  const recoverNow = extras.recover
82
102
  const injectionMaxChars = extras.injectionMaxChars
103
+ const tasksNow = extras.tasksNow
104
+ const tasksRestore = extras.tasksRestore
105
+ const schedulePathOf = extras.schedulePath
83
106
 
84
107
  ctx.tools.register(defineTool({
85
108
  name: 'memory_search',
@@ -305,6 +328,90 @@ export function registerTools(ctx, store, extras = {}) {
305
328
  },
306
329
  }))
307
330
 
331
+
332
+ ctx.tools.register(defineTool({
333
+ name: 'memory_tasks',
334
+ description: 'Show and manage the backup of this machine\'s DSH scheduled tasks. The schedule store is the only copy of them: it is in no git repository, and the host DELETES a task when its owning session stops, so a task can vanish with nothing to observe. Memory keeps one file per task under global/tasks/ and restores them automatically into a session on boot. Actions: report (default), backup, restore, pause, resume.',
335
+ parameters: {
336
+ action: { type: 'string', description: 'report (default), backup, restore, pause or resume.' },
337
+ title: { type: 'string', description: 'Task title, required for pause and resume.' },
338
+ },
339
+ output: {
340
+ schema: {
341
+ type: 'object', additionalProperties: false,
342
+ properties: {
343
+ action: { type: 'string', required: true },
344
+ schedulePath: { type: 'string', required: true },
345
+ live: { type: 'integer', required: true },
346
+ backedUp: { type: 'integer', required: true },
347
+ tasks: { type: 'array', items: { type: 'object', additionalProperties: true }, required: true },
348
+ unreadable: { type: 'array', items: { type: 'string' } },
349
+ changed: { type: 'object', additionalProperties: true },
350
+ // What a restore would do, which is the question a reader of this report is asking:
351
+ // how many tasks would be re-armed, how many are already there, how many are held.
352
+ pending: { type: 'object', additionalProperties: true },
353
+ error: { type: 'string' },
354
+ },
355
+ },
356
+ render: (_args, value) => [{ type: 'text', text: renderTasks(value) }],
357
+ // The structured face. Without presentationMeta a tool result reaches its session event with
358
+ // no meta at all (core/tools/src/index.ts:1845-1859), which is the only channel a UI has.
359
+ presentationMeta: (_args, value) => value,
360
+ },
361
+ async execute(args, exec) {
362
+ const action = ['report', 'backup', 'restore', 'pause', 'resume'].includes(args.action) ? args.action : 'report'
363
+ const schedulePath = schedulePathOf || defaultSchedulePath()
364
+ let changed = null
365
+ if (action === 'backup') {
366
+ if (!tasksNow) throw new Error('memory_tasks: the backup is unavailable in this context')
367
+ const r = backupTasks(store.root, { schedulePath })
368
+ changed = { written: r.written, refreshed: r.refreshed, stopped: r.stopped, skipped: r.skipped.length, error: r.error }
369
+ } else if (action === 'restore') {
370
+ if (!tasksRestore) throw new Error('memory_tasks: the restore is unavailable in this context')
371
+ await tasksRestore(exec.agent)
372
+ changed = { restored: true }
373
+ } else if (action === 'pause' || action === 'resume') {
374
+ if (!args.title) throw new Error('memory_tasks: ' + action + ' needs a title')
375
+ const { tasks } = listTasks(store.root)
376
+ const match = tasks.find((task) => String(task.record.title ?? '') === args.title)
377
+ if (!match) throw new Error('memory_tasks: no backed-up task titled "' + args.title + '"')
378
+ // Pausing writes the STATE only. It does not touch the live store: a held task is not
379
+ // disarmed here, it is simply never re-armed by an automatic pass, which is the decision a
380
+ // pause expresses. Resuming returns it to the queue, and the next pass derives it afresh.
381
+ const nextState = action === 'pause' ? TASK_STATES.PAUSED : TASK_STATES.PENDING
382
+ writeTask(store.root, match.record, nextState, match.backedUpAt ?? new Date().toISOString())
383
+ changed = { title: args.title, state: nextState }
384
+ }
385
+ // The report is read AFTER the action, so what the caller sees is the state they produced.
386
+ const live = readScheduleStore(schedulePath)
387
+ const { tasks, unreadable } = listTasks(store.root)
388
+ const plan = planRestore(store.root, { liveTasks: live.tasks })
389
+ const rows = tasks.map((task) => {
390
+ const described = describeTask(task, Date.now())
391
+ return {
392
+ title: String(task.record.title ?? ''),
393
+ kind: String(task.record.kind ?? ''),
394
+ state: described.state,
395
+ reason: described.reason,
396
+ file: task.file,
397
+ }
398
+ })
399
+ return {
400
+ action,
401
+ schedulePath,
402
+ live: live.tasks.length,
403
+ backedUp: tasks.length,
404
+ tasks: rows,
405
+ unreadable,
406
+ ...(changed ? { changed } : {}),
407
+ ...(live.error ? { error: live.error } : {}),
408
+ // Carried for the tool's own use, never shown: the planner's own view of what a restore
409
+ // would do, which is the question a reader of this report is actually asking.
410
+ pending: { add: plan.add.length, present: plan.present.length, paused: plan.paused.length, refused: plan.refused.length },
411
+ }
412
+ },
413
+ }))
414
+
308
415
  ctx.tools.register(defineTool({
309
416
  name: 'memory_map',
310
417
  description: 'Show what memory holds, and what THIS session has done with it. scope "store" reports the whole store - entries per layer, freshness, workspaces, and the ingest queue. scope "session" reports what memory injected into this conversation and what this conversation wrote back. "all" returns both. Read-only, and the data behind the Memory view.',
@@ -321,7 +428,17 @@ export function registerTools(ctx, store, extras = {}) {
321
428
  session: { type: 'object', additionalProperties: true },
322
429
  },
323
430
  },
431
+ // The text face, for the agent and for any caller without a UI.
324
432
  render: (_args, value) => [{ type: 'text', text: renderMap(value) }],
433
+ // The STRUCTURED face, and the only reason the Memory tab and the inline card can draw
434
+ // anything. A tool result reaches the session event with a `meta` field ONLY when the tool
435
+ // declares presentationMeta (core/tools/src/index.ts:1845-1859), and that field is what
436
+ // ui-chat forwards to a view (conversation-nodes/tool.ts:77: meta: match.event.data.meta).
437
+ // Without this, memory_map rendered its snapshot to prose and threw the value away: the tab
438
+ // showed 'the result carried no snapshot' while the same call displayed 2,197 entries in
439
+ // the conversation. The rendered text is not a substitute - rebuilding panels by parsing it
440
+ // would be a second source of truth.
441
+ presentationMeta: (_args, value) => value,
325
442
  },
326
443
  async execute(args, exec) {
327
444
  const scope = args.scope === 'store' || args.scope === 'session' ? args.scope : 'all'