dsh-checklists 0.0.0-stage → 0.3.3

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/lib/store.js ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Durable per-session checklist storage.
3
+ *
4
+ * Layout under the DSH home directory:
5
+ *
6
+ * $DSH_HOME/storages/dsh-checklist/
7
+ * ├─ sessions/<sessionId>.json one document per session
8
+ * └─ templates.json reusable, cross-session templates
9
+ *
10
+ * The store keeps an in-process cache because the system-prompt digest is
11
+ * rendered synchronously, while disk access is not. Writes are serialized per
12
+ * session and land atomically (temp file + rename), so two concurrent tool
13
+ * calls cannot interleave into a torn document.
14
+ */
15
+ import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
16
+ import { homedir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { emptyDoc } from './model.js';
19
+ /** Conservative allowlist: a session id becomes a file name. */
20
+ const SAFE_ID = /^[A-Za-z0-9._-]{1,200}$/;
21
+ /** Resolve the DSH home directory (env override wins). */
22
+ export function resolveDshHome(env = process.env) {
23
+ const fromEnv = env.DSH_HOME?.trim();
24
+ return fromEnv && fromEnv.length > 0 ? fromEnv : join(homedir(), '.dsh');
25
+ }
26
+ function isStringArray(value) {
27
+ return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
28
+ }
29
+ export class ChecklistStore {
30
+ root;
31
+ cache = new Map();
32
+ chains = new Map();
33
+ inflight = new Map();
34
+ constructor(root) {
35
+ this.root = root ?? join(resolveDshHome(), 'storages', 'dsh-checklist');
36
+ }
37
+ /** The synchronous view used by the prompt digest; `undefined` until primed. */
38
+ peek(sessionId) {
39
+ return this.cache.get(sessionId);
40
+ }
41
+ /** Load one session (cache first, then disk). */
42
+ async prime(sessionId) {
43
+ const cached = this.cache.get(sessionId);
44
+ if (cached)
45
+ return cached;
46
+ const running = this.inflight.get(sessionId);
47
+ if (running)
48
+ return running;
49
+ const load = this.read(sessionId).then((doc) => {
50
+ this.cache.set(sessionId, doc);
51
+ this.inflight.delete(sessionId);
52
+ return doc;
53
+ }, (error) => {
54
+ this.inflight.delete(sessionId);
55
+ throw error;
56
+ });
57
+ this.inflight.set(sessionId, load);
58
+ return load;
59
+ }
60
+ /**
61
+ * Apply one mutation to a session and persist it.
62
+ *
63
+ * The mutator receives a fresh clone, so a throw leaves the cached document
64
+ * untouched. Calls for one session are serialized.
65
+ */
66
+ async update(sessionId, mutate) {
67
+ const previous = this.chains.get(sessionId) ?? Promise.resolve();
68
+ const run = previous.then(async () => {
69
+ const current = await this.prime(sessionId);
70
+ const draft = structuredClone(current);
71
+ const result = await mutate(draft);
72
+ await this.write(draft);
73
+ return result;
74
+ });
75
+ this.chains.set(sessionId, run.then(() => undefined, () => undefined));
76
+ return run;
77
+ }
78
+ async readTemplates() {
79
+ try {
80
+ const raw = await readFile(this.templatesFile(), 'utf8');
81
+ const parsed = JSON.parse(raw);
82
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
83
+ return {};
84
+ const out = {};
85
+ for (const [name, value] of Object.entries(parsed)) {
86
+ if (isStringArray(value))
87
+ out[name] = value;
88
+ }
89
+ return out;
90
+ }
91
+ catch {
92
+ return {};
93
+ }
94
+ }
95
+ async writeTemplates(templates) {
96
+ await mkdir(this.root, { recursive: true });
97
+ const file = this.templatesFile();
98
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
99
+ await writeFile(tmp, `${JSON.stringify(templates, null, 2)}\n`, 'utf8');
100
+ await rename(tmp, file);
101
+ }
102
+ /** Delete session files untouched for `maxAgeDays`. Returns the count removed. */
103
+ async prune(maxAgeDays) {
104
+ const dir = join(this.root, 'sessions');
105
+ let names;
106
+ try {
107
+ names = await readdir(dir);
108
+ }
109
+ catch {
110
+ return 0;
111
+ }
112
+ const cutoff = Date.now() - maxAgeDays * 86_400_000;
113
+ let removed = 0;
114
+ for (const name of names) {
115
+ if (!name.endsWith('.json'))
116
+ continue;
117
+ const path = join(dir, name);
118
+ try {
119
+ const info = await stat(path);
120
+ if (info.mtimeMs >= cutoff)
121
+ continue;
122
+ await unlink(path);
123
+ removed += 1;
124
+ }
125
+ catch {
126
+ // Raced with another process; nothing to do.
127
+ }
128
+ }
129
+ return removed;
130
+ }
131
+ templatesFile() {
132
+ return join(this.root, 'templates.json');
133
+ }
134
+ fileFor(sessionId) {
135
+ if (!SAFE_ID.test(sessionId)) {
136
+ throw new Error(`checklist: refusing unsafe session id ${JSON.stringify(sessionId)}`);
137
+ }
138
+ return join(this.root, 'sessions', `${sessionId}.json`);
139
+ }
140
+ async read(sessionId) {
141
+ const file = this.fileFor(sessionId);
142
+ let raw;
143
+ try {
144
+ raw = await readFile(file, 'utf8');
145
+ }
146
+ catch (error) {
147
+ if (error.code === 'ENOENT')
148
+ return emptyDoc(sessionId);
149
+ throw error;
150
+ }
151
+ try {
152
+ const parsed = JSON.parse(raw);
153
+ if (parsed !== null &&
154
+ typeof parsed === 'object' &&
155
+ parsed.version === 1 &&
156
+ parsed.sessionId === sessionId &&
157
+ Array.isArray(parsed.lists)) {
158
+ return parsed;
159
+ }
160
+ }
161
+ catch {
162
+ // Corrupt document: start clean rather than failing every turn.
163
+ }
164
+ return emptyDoc(sessionId);
165
+ }
166
+ async write(doc) {
167
+ const file = this.fileFor(doc.sessionId);
168
+ await mkdir(join(this.root, 'sessions'), { recursive: true });
169
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
170
+ await writeFile(tmp, `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
171
+ await rename(tmp, file);
172
+ this.cache.set(doc.sessionId, doc);
173
+ }
174
+ }