@particle-academy/prism-harness 0.5.0 → 0.7.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/README.md CHANGED
@@ -3,9 +3,11 @@
3
3
  Durable agent sessions — threads, session state and store drivers. The
4
4
  TypeScript port of [`particle-academy/prism-harness`](https://github.com/Particle-Academy/prism-harness).
5
5
 
6
- Zero runtime dependencies. Node 20+.
6
+ Zero runtime dependencies. Node 22+.
7
7
 
8
- This package is private while coordinated parity work is in progress.
8
+ ```
9
+ npm install @particle-academy/prism-harness
10
+ ```
9
11
 
10
12
  ```ts
11
13
  import { FileSessionStore, MemorySessionStore, PrismHarness } from '@particle-academy/prism-harness';
@@ -107,6 +109,8 @@ as `request.providerOptions`, on every step of every run in that mode. Pass them
107
109
  to your provider call (with `prism-ts`, `withProviderOptions()`):
108
110
 
109
111
  ```ts
112
+ import { ModeRegistry } from '@particle-academy/prism-harness';
113
+
110
114
  new ModeRegistry({
111
115
  modes: { overseer: { system_prompt: '...', provider_options: { thinking: { type: 'adaptive' } } } },
112
116
  });
@@ -166,6 +170,8 @@ the text, so a thread that contains a structured answer reads like any other.
166
170
  **A document that misses the schema is refused, not repaired.**
167
171
 
168
172
  ```ts
173
+ import { HarnessError } from '@particle-academy/prism-harness';
174
+
169
175
  try {
170
176
  const plan = (await runtime.sendStructured(session, brief, schema)).structured;
171
177
  } catch (error) {
@@ -201,7 +207,11 @@ in the other for the same stated reason.
201
207
  A mode names the tools a person must approve:
202
208
 
203
209
  ```ts
204
- guarded: { system_prompt: '...', tools: ['read', 'delete'], requires_approval: ['delete'] },
210
+ new ModeRegistry({
211
+ modes: {
212
+ guarded: { system_prompt: '...', tools: ['read', 'delete'], requires_approval: ['delete'] },
213
+ },
214
+ });
205
215
  ```
206
216
 
207
217
  A step that calls one stops with `finishReason: 'awaiting_approval'`. The calls
@@ -209,6 +219,8 @@ that need nobody have already run. Record a decision for every pending approval,
209
219
  then resume with an empty prompt:
210
220
 
211
221
  ```ts
222
+ import { recordApproval } from '@particle-academy/prism-harness';
223
+
212
224
  const response = await runtime.send(session, 'Clean up the failed run');
213
225
 
214
226
  if (response.finishReason === 'awaiting_approval') {
@@ -329,6 +341,18 @@ badly.
329
341
  as held, bounded by the file's own age so a process that died mid-write cannot
330
342
  wedge the key either.
331
343
 
344
+ **Threads are append-only logs** (`<digest>.jsonl` beside the payload file):
345
+ one line per message, one write and an `fsync` per `record()`, so recording
346
+ costs the size of the new messages however long the thread already is.
347
+ Before 0.7.0, every message re-read and rewrote the whole conversation. A
348
+ thread stored that way is read as before and moved into the log the first
349
+ time it grows. A line cut off by a crash is dropped, and the next append
350
+ starts after the last complete line. A damaged line anywhere else is refused
351
+ with `unmappable_content` rather than silently skipped.
352
+
353
+ The thread log is **not yet shared with the PHP and Python ports**: a Python
354
+ worker pointed at the same directory does not read it.
355
+
332
356
  Implement `SessionStore` for anything else. Declare your own `durability()`:
333
357
  only you know whether your Redis is persistent or a disposable cache, and that
334
358
  declaration is an assertion about your infrastructure, not a preference.
package/dist/index.d.ts CHANGED
@@ -2,8 +2,8 @@ export { HarnessError } from './errors.js';
2
2
  export type { HarnessErrorCode, HarnessErrorOptions } from './errors.js';
3
3
  export type { JsonObject, JsonValue } from './json.js';
4
4
  export { isJsonObject } from './json.js';
5
- export type { Durability, SessionStore } from './stores/session-store.js';
6
- export { isDurable } from './stores/session-store.js';
5
+ export type { AppendLogStore, Durability, SessionStore } from './stores/session-store.js';
6
+ export { isDurable, supportsAppendLog } from './stores/session-store.js';
7
7
  export { MemorySessionStore } from './stores/memory-session-store.js';
8
8
  export { FileSessionStore } from './stores/file-session-store.js';
9
9
  export { SessionStoreManager, SLOT_DURABLE, SLOT_EPHEMERAL } from './stores/store-manager.js';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export { HarnessError } from './errors.js';
2
2
  export { isJsonObject } from './json.js';
3
- export { isDurable } from './stores/session-store.js';
3
+ export { isDurable, supportsAppendLog } from './stores/session-store.js';
4
4
  export { MemorySessionStore } from './stores/memory-session-store.js';
5
5
  export { FileSessionStore } from './stores/file-session-store.js';
6
6
  export { SessionStoreManager, SLOT_DURABLE, SLOT_EPHEMERAL } from './stores/store-manager.js';
@@ -1,5 +1,5 @@
1
1
  import type { JsonObject } from '../json.js';
2
- import type { Durability, SessionStore } from './session-store.js';
2
+ import type { AppendLogStore, Durability, SessionStore } from './session-store.js';
3
3
  /**
4
4
  * State on disk, as one JSON file per key. DURABLE, and it says so.
5
5
  *
@@ -23,12 +23,23 @@ import type { Durability, SessionStore } from './session-store.js';
23
23
  * other. Two workers on different machines sharing a network filesystem do NOT
24
24
  * reliably, and no file lock can promise that; use a database or Redis there.
25
25
  */
26
- export declare class FileSessionStore implements SessionStore {
26
+ export declare class FileSessionStore implements SessionStore, AppendLogStore {
27
27
  #private;
28
28
  constructor(directory: string);
29
29
  durability(): Durability;
30
30
  get(key: string): Promise<JsonObject | null>;
31
31
  put(key: string, payload: JsonObject, ttlSeconds?: number | null): Promise<void>;
32
32
  forget(key: string): Promise<void>;
33
+ /**
34
+ * One JSON document per line, appended through a single write and then
35
+ * synced, so a message that `record()` returned is on disk. Before writing,
36
+ * a TORN tail (a previous write cut off by a crash) is truncated back to the
37
+ * last complete line; appending after it would glue the new line onto the
38
+ * fragment and corrupt both.
39
+ */
40
+ appendLog(key: string, entries: readonly JsonObject[]): Promise<void>;
41
+ readLog(key: string): Promise<JsonObject[] | null>;
42
+ logLength(key: string): Promise<number | null>;
43
+ clearLog(key: string): Promise<void>;
33
44
  withLock<T>(key: string, callback: () => T | Promise<T>, ttlSeconds?: number, waitSeconds?: number): Promise<T>;
34
45
  }
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { mkdirSync } from 'node:fs';
3
- import { mkdir, open, readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
3
+ import { mkdir, open, readFile, rename, rm, stat, truncate, unlink, writeFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { isJsonObject } from '../json.js';
6
6
  import { HarnessError } from '../errors.js';
@@ -29,6 +29,14 @@ import { HarnessError } from '../errors.js';
29
29
  */
30
30
  export class FileSessionStore {
31
31
  #directory;
32
+ /**
33
+ * What this instance last saw of each log: its size on disk and how many
34
+ * complete entries that size holds. Lets `logLength()` answer without reading
35
+ * the log, and stays honest across processes because it is only trusted while
36
+ * the file is still exactly that size. Another writer changes the size, and
37
+ * the next call recounts.
38
+ */
39
+ #logs = new Map();
32
40
  constructor(directory) {
33
41
  this.#directory = directory;
34
42
  mkdirSync(directory, { recursive: true });
@@ -87,6 +95,130 @@ export class FileSessionStore {
87
95
  }
88
96
  async forget(key) {
89
97
  await rm(this.#pathFor(key), { force: true });
98
+ await rm(this.#logPathFor(key), { force: true });
99
+ this.#logs.delete(key);
100
+ }
101
+ /**
102
+ * One JSON document per line, appended through a single write and then
103
+ * synced, so a message that `record()` returned is on disk. Before writing,
104
+ * a TORN tail (a previous write cut off by a crash) is truncated back to the
105
+ * last complete line; appending after it would glue the new line onto the
106
+ * fragment and corrupt both.
107
+ */
108
+ async appendLog(key, entries) {
109
+ const path = this.#logPathFor(key);
110
+ await mkdir(this.#directory, { recursive: true });
111
+ const before = await this.#repairTail(path);
112
+ const data = entries.map((entry) => `${JSON.stringify(entry)}\n`).join('');
113
+ const handle = await open(path, 'a');
114
+ try {
115
+ if (data !== '')
116
+ await handle.write(data);
117
+ await handle.sync();
118
+ }
119
+ finally {
120
+ await handle.close();
121
+ }
122
+ const known = this.#logs.get(key);
123
+ if (known !== undefined && known.size === before) {
124
+ this.#logs.set(key, { size: before + Buffer.byteLength(data), count: known.count + entries.length });
125
+ }
126
+ else {
127
+ this.#logs.delete(key);
128
+ }
129
+ }
130
+ async readLog(key) {
131
+ let raw;
132
+ try {
133
+ raw = await readFile(this.#logPathFor(key), 'utf8');
134
+ }
135
+ catch (error) {
136
+ if (isMissing(error))
137
+ return null;
138
+ throw error;
139
+ }
140
+ const lines = raw.split('\n');
141
+ // Everything after the last newline is a line that never finished. Empty
142
+ // when the file ends cleanly, a fragment when a crash cut a write short.
143
+ lines.pop();
144
+ const entries = lines.map((line, index) => {
145
+ let entry;
146
+ try {
147
+ entry = JSON.parse(line);
148
+ }
149
+ catch (error) {
150
+ throw HarnessError.unmappableContent(`line ${index + 1} of the thread log for [${key}] is not valid JSON`, {
151
+ cause: error,
152
+ });
153
+ }
154
+ if (!isJsonObject(entry)) {
155
+ throw HarnessError.unmappableContent(`line ${index + 1} of the thread log for [${key}] is not an object`);
156
+ }
157
+ return entry;
158
+ });
159
+ this.#logs.set(key, { size: Buffer.byteLength(raw), count: entries.length });
160
+ return entries;
161
+ }
162
+ async logLength(key) {
163
+ let size;
164
+ try {
165
+ size = (await stat(this.#logPathFor(key))).size;
166
+ }
167
+ catch (error) {
168
+ if (isMissing(error))
169
+ return null;
170
+ throw error;
171
+ }
172
+ const known = this.#logs.get(key);
173
+ if (known !== undefined && known.size === size)
174
+ return known.count;
175
+ return (await this.readLog(key))?.length ?? null;
176
+ }
177
+ async clearLog(key) {
178
+ await mkdir(this.#directory, { recursive: true });
179
+ await writeFile(this.#logPathFor(key), '', 'utf8');
180
+ this.#logs.set(key, { size: 0, count: 0 });
181
+ }
182
+ /** Cut a torn tail back to the last newline. Returns the file's size afterwards. */
183
+ async #repairTail(path) {
184
+ let size;
185
+ try {
186
+ size = (await stat(path)).size;
187
+ }
188
+ catch (error) {
189
+ if (isMissing(error))
190
+ return 0;
191
+ throw error;
192
+ }
193
+ if (size === 0)
194
+ return 0;
195
+ const handle = await open(path, 'r');
196
+ try {
197
+ const last = Buffer.alloc(1);
198
+ await handle.read(last, 0, 1, size - 1);
199
+ if (last[0] === 0x0a)
200
+ return size;
201
+ // Walk back a chunk at a time: one message can be larger than any chunk.
202
+ const chunk = Buffer.alloc(64 * 1024);
203
+ let end = size;
204
+ while (end > 0) {
205
+ const start = Math.max(0, end - chunk.length);
206
+ const { bytesRead } = await handle.read(chunk, 0, end - start, start);
207
+ const newline = chunk.subarray(0, bytesRead).lastIndexOf(0x0a);
208
+ if (newline !== -1) {
209
+ await handle.close();
210
+ await truncate(path, start + newline + 1);
211
+ return start + newline + 1;
212
+ }
213
+ end = start;
214
+ }
215
+ await handle.close();
216
+ await truncate(path, 0);
217
+ return 0;
218
+ }
219
+ finally {
220
+ await handle.close().catch(() => undefined);
221
+ }
90
222
  }
91
223
  async withLock(key, callback, ttlSeconds = 10, waitSeconds = 5) {
92
224
  const lockPath = `${this.#pathFor(key)}.lock`;
@@ -237,7 +369,11 @@ export class FileSessionStore {
237
369
  * the mapping stays inspectable.
238
370
  */
239
371
  #pathFor(key) {
240
- return join(this.#directory, `${createHash('sha256').update(key).digest('hex').slice(0, 32)}.json`);
372
+ return join(this.#directory, `${digestOf(key)}.json`);
373
+ }
374
+ /** The key's append-only log, beside its payload: same digest, `.jsonl`. */
375
+ #logPathFor(key) {
376
+ return join(this.#directory, `${digestOf(key)}.jsonl`);
241
377
  }
242
378
  }
243
379
  /**
@@ -251,6 +387,10 @@ export class FileSessionStore {
251
387
  * else.
252
388
  */
253
389
  const STAMP = /^\d+\n$/;
390
+ /** A key's file name. Colons are illegal on Windows and keys run long, so it is digested. */
391
+ function digestOf(key) {
392
+ return createHash('sha256').update(key).digest('hex').slice(0, 32);
393
+ }
254
394
  function stamp(expiresAt) {
255
395
  return `${String(Math.trunc(expiresAt))}\n`;
256
396
  }
@@ -63,3 +63,31 @@ export interface SessionStore {
63
63
  */
64
64
  durability(): Durability;
65
65
  }
66
+ /**
67
+ * OPTIONAL: an append-only log per key, which a store may offer for threads.
68
+ *
69
+ * A thread only ever grows at the end, and through `get`/`put` every new
70
+ * message costs a read, parse, serialise and rewrite of the WHOLE conversation.
71
+ * That is quadratic over a session: Genie 2 measured a 9 MB thread being
72
+ * rewritten for every chat row. A store that can append says so by
73
+ * implementing this, and `Thread` then writes one line per message. A store
74
+ * that does not keeps working through `get`/`put` exactly as before.
75
+ *
76
+ * Every method is called with the key's `withLock` held by the caller.
77
+ */
78
+ export interface AppendLogStore {
79
+ /** Durably append entries in order. One line each; nothing earlier is rewritten. */
80
+ appendLog(key: string, entries: readonly JsonObject[]): Promise<void>;
81
+ /**
82
+ * Every complete entry, or null when no log exists for the key.
83
+ *
84
+ * A torn LAST line (a write cut off by a crash) is dropped. A damaged line
85
+ * anywhere else is history that cannot be read, and is refused loudly.
86
+ */
87
+ readLog(key: string): Promise<JsonObject[] | null>;
88
+ /** How many complete entries, or null when no log exists. Need not read them all. */
89
+ logLength(key: string): Promise<number | null>;
90
+ /** Drop every entry; the log then exists and is empty. */
91
+ clearLog(key: string): Promise<void>;
92
+ }
93
+ export declare function supportsAppendLog(store: SessionStore): store is SessionStore & AppendLogStore;
@@ -1,3 +1,8 @@
1
1
  export function isDurable(durability) {
2
2
  return durability === 'durable';
3
3
  }
4
+ export function supportsAppendLog(store) {
5
+ const candidate = store;
6
+ return typeof candidate.appendLog === 'function' && typeof candidate.readLog === 'function'
7
+ && typeof candidate.logLength === 'function' && typeof candidate.clearLog === 'function';
8
+ }
package/dist/thread.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { isJsonObject } from './json.js';
2
2
  import { HarnessError } from './errors.js';
3
+ import { supportsAppendLog } from './stores/session-store.js';
3
4
  /**
4
5
  * The stored conversation a session is bound to.
5
6
  *
@@ -23,17 +24,29 @@ export class Thread {
23
24
  this.key = key;
24
25
  }
25
26
  async messages() {
27
+ if (supportsAppendLog(this.store)) {
28
+ const log = await this.store.readLog(this.key);
29
+ if (log !== null)
30
+ return log.map((entry) => this.#toMessage(entry));
31
+ }
32
+ return (await this.#stored()).map((entry) => this.#toMessage(entry));
33
+ }
34
+ /** The whole-payload form: every store before the log, and any store without one. */
35
+ async #stored() {
26
36
  const stored = await this.store.get(this.key);
27
37
  if (stored === null)
28
38
  return [];
29
39
  const messages = stored.messages;
30
- if (!Array.isArray(messages))
31
- return [];
32
- return messages.filter(isJsonObject).map((entry) => this.#toMessage(entry));
40
+ return Array.isArray(messages) ? messages.filter(isJsonObject) : [];
33
41
  }
34
42
  /** How many messages the thread holds. */
35
43
  async count() {
36
- return (await this.messages()).length;
44
+ if (supportsAppendLog(this.store)) {
45
+ const length = await this.store.logLength(this.key);
46
+ if (length !== null)
47
+ return length;
48
+ }
49
+ return (await this.#stored()).length;
37
50
  }
38
51
  /**
39
52
  * Append messages, in order, and return them with their assigned positions.
@@ -43,17 +56,20 @@ export class Thread {
43
56
  async record(messages, runId = null) {
44
57
  if (messages.length === 0)
45
58
  return [];
46
- return this.store.withLock(this.key, async () => {
47
- const stored = (await this.store.get(this.key)) ?? {};
59
+ const store = this.store;
60
+ if (supportsAppendLog(store)) {
61
+ return store.withLock(this.key, async () => {
62
+ const length = (await store.logLength(this.key)) ?? (await this.#migrate(store));
63
+ const appended = this.#entries(messages, length, runId);
64
+ await store.appendLog(this.key, appended);
65
+ return appended.map((entry) => this.#toMessage(entry));
66
+ });
67
+ }
68
+ return store.withLock(this.key, async () => {
69
+ const stored = (await store.get(this.key)) ?? {};
48
70
  const existing = Array.isArray(stored.messages) ? stored.messages.filter(isJsonObject) : [];
49
- const recordedAt = new Date().toISOString();
50
- const appended = messages.map((message, index) => ({
51
- position: existing.length + index + 1,
52
- message,
53
- run_id: runId,
54
- recorded_at: recordedAt,
55
- }));
56
- await this.store.put(this.key, {
71
+ const appended = this.#entries(messages, existing.length, runId);
72
+ await store.put(this.key, {
57
73
  ...stored,
58
74
  messages: [...existing, ...appended],
59
75
  });
@@ -68,11 +84,40 @@ export class Thread {
68
84
  * side effect of clearing session state.
69
85
  */
70
86
  async clear() {
71
- await this.store.withLock(this.key, async () => {
72
- const stored = (await this.store.get(this.key)) ?? {};
73
- await this.store.put(this.key, { ...stored, messages: [] });
87
+ const store = this.store;
88
+ await store.withLock(this.key, async () => {
89
+ if (supportsAppendLog(store))
90
+ await store.clearLog(this.key);
91
+ const stored = await store.get(this.key);
92
+ if (stored !== null)
93
+ await store.put(this.key, { ...stored, messages: [] });
74
94
  });
75
95
  }
96
+ /**
97
+ * Move a whole-payload thread into the log, once, the first time it grows.
98
+ *
99
+ * The log is written FIRST and the payload emptied after it. A crash between
100
+ * the two leaves both, and the log wins on every read, so nothing is lost
101
+ * and nothing is counted twice. Returns how many entries the log now holds.
102
+ */
103
+ async #migrate(store) {
104
+ const existing = await this.#stored();
105
+ await store.appendLog(this.key, existing);
106
+ if (existing.length > 0) {
107
+ const stored = (await store.get(this.key)) ?? {};
108
+ await store.put(this.key, { ...stored, messages: [] });
109
+ }
110
+ return existing.length;
111
+ }
112
+ #entries(messages, length, runId) {
113
+ const recordedAt = new Date().toISOString();
114
+ return messages.map((message, index) => ({
115
+ position: length + index + 1,
116
+ message,
117
+ run_id: runId,
118
+ recorded_at: recordedAt,
119
+ }));
120
+ }
76
121
  #toMessage(entry) {
77
122
  const message = entry.message;
78
123
  if (!isJsonObject(message)) {
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@particle-academy/prism-harness",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Durable agent sessions for TypeScript — threads, session state and store drivers.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "engines": {
8
- "node": ">=20"
8
+ "node": ">=22"
9
9
  },
10
10
  "exports": {
11
11
  ".": {