loopforge 2.7.0 → 3.0.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 +136 -182
- package/dist/canonical-event-writer.d.ts +20 -0
- package/dist/canonical-event-writer.d.ts.map +1 -0
- package/dist/canonical-event-writer.js +131 -0
- package/dist/canonical-event-writer.js.map +1 -0
- package/dist/canonical-state.d.ts +20 -16
- package/dist/canonical-state.d.ts.map +1 -1
- package/dist/canonical-state.js +211 -64
- package/dist/canonical-state.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +11 -10
- package/dist/cli.js.map +1 -1
- package/dist/cognitive-governance.d.ts +48 -0
- package/dist/cognitive-governance.d.ts.map +1 -0
- package/dist/cognitive-governance.js +226 -0
- package/dist/cognitive-governance.js.map +1 -0
- package/dist/compiler-subgoals.d.ts +7 -0
- package/dist/compiler-subgoals.d.ts.map +1 -0
- package/dist/compiler-subgoals.js +93 -0
- package/dist/compiler-subgoals.js.map +1 -0
- package/dist/derive-id.d.ts +12 -0
- package/dist/derive-id.d.ts.map +1 -0
- package/dist/derive-id.js +31 -0
- package/dist/derive-id.js.map +1 -0
- package/dist/evidence-provider.d.ts +26 -26
- package/dist/evidence-provider.d.ts.map +1 -1
- package/dist/evidence-provider.js +45 -71
- package/dist/evidence-provider.js.map +1 -1
- package/dist/generate-schema.js +90 -2
- package/dist/generate-schema.js.map +1 -1
- package/dist/index.d.ts +23 -29
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -26
- package/dist/index.js.map +1 -1
- package/dist/loop-compiler.d.ts +2 -2
- package/dist/loop-compiler.d.ts.map +1 -1
- package/dist/loop-compiler.js +321 -315
- package/dist/loop-compiler.js.map +1 -1
- package/dist/loop-extras-parser.d.ts +2 -2
- package/dist/loop-extras-parser.d.ts.map +1 -1
- package/dist/loop-extras-parser.js +2 -2
- package/dist/loop-extras-parser.js.map +1 -1
- package/dist/loop-store.d.ts +20 -58
- package/dist/loop-store.d.ts.map +1 -1
- package/dist/loop-store.js +232 -311
- package/dist/loop-store.js.map +1 -1
- package/dist/mcp/server.d.ts +3 -3
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +123 -19
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/session-projection.d.ts +5 -0
- package/dist/mcp/session-projection.d.ts.map +1 -0
- package/dist/mcp/session-projection.js +18 -0
- package/dist/mcp/session-projection.js.map +1 -0
- package/dist/mcp/session-queue.d.ts +3 -0
- package/dist/mcp/session-queue.d.ts.map +1 -0
- package/dist/mcp/session-queue.js +18 -0
- package/dist/mcp/session-queue.js.map +1 -0
- package/dist/mcp/session-types.d.ts +72 -0
- package/dist/mcp/session-types.d.ts.map +1 -0
- package/dist/mcp/session-types.js +2 -0
- package/dist/mcp/session-types.js.map +1 -0
- package/dist/mcp/session.d.ts +65 -84
- package/dist/mcp/session.d.ts.map +1 -1
- package/dist/mcp/session.js +667 -224
- package/dist/mcp/session.js.map +1 -1
- package/dist/mcp/tools.d.ts +26 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +401 -254
- package/dist/mcp/tools.js.map +1 -1
- package/dist/policy-metrics.d.ts +24 -0
- package/dist/policy-metrics.d.ts.map +1 -1
- package/dist/policy-metrics.js +174 -0
- package/dist/policy-metrics.js.map +1 -1
- package/dist/policy.d.ts +74 -12
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +124 -19
- package/dist/policy.js.map +1 -1
- package/dist/prompt-assembler.d.ts +8 -4
- package/dist/prompt-assembler.d.ts.map +1 -1
- package/dist/prompt-assembler.js +533 -86
- package/dist/prompt-assembler.js.map +1 -1
- package/dist/prompt-policy.d.ts +8 -1
- package/dist/prompt-policy.d.ts.map +1 -1
- package/dist/prompt-policy.js +26 -8
- package/dist/prompt-policy.js.map +1 -1
- package/dist/protocol.d.ts +259 -173
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +17 -96
- package/dist/protocol.js.map +1 -1
- package/dist/replay.d.ts +11 -3
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +51 -14
- package/dist/replay.js.map +1 -1
- package/dist/round-commit-store.d.ts +12 -0
- package/dist/round-commit-store.d.ts.map +1 -0
- package/dist/round-commit-store.js +13 -0
- package/dist/round-commit-store.js.map +1 -0
- package/dist/round-coordinator.d.ts +53 -22
- package/dist/round-coordinator.d.ts.map +1 -1
- package/dist/round-coordinator.js +140 -44
- package/dist/round-coordinator.js.map +1 -1
- package/dist/round-driver.d.ts +18 -10
- package/dist/round-driver.d.ts.map +1 -1
- package/dist/round-driver.js +11 -13
- package/dist/round-driver.js.map +1 -1
- package/dist/round-gate.d.ts +31 -0
- package/dist/round-gate.d.ts.map +1 -0
- package/dist/round-gate.js +259 -0
- package/dist/round-gate.js.map +1 -0
- package/dist/round-recovery.d.ts +17 -0
- package/dist/round-recovery.d.ts.map +1 -0
- package/dist/round-recovery.js +117 -0
- package/dist/round-recovery.js.map +1 -0
- package/dist/round-report.d.ts +16 -0
- package/dist/round-report.d.ts.map +1 -0
- package/dist/round-report.js +401 -0
- package/dist/round-report.js.map +1 -0
- package/dist/round-transaction.d.ts +23 -12
- package/dist/round-transaction.d.ts.map +1 -1
- package/dist/round-transaction.js +104 -91
- package/dist/round-transaction.js.map +1 -1
- package/dist/runtime-services.d.ts +59 -0
- package/dist/runtime-services.d.ts.map +1 -0
- package/dist/{engine.js → runtime-services.js} +157 -275
- package/dist/runtime-services.js.map +1 -0
- package/dist/storage.d.ts +3 -9
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +6 -17
- package/dist/storage.js.map +1 -1
- package/dist/token-utils.d.ts +12 -8
- package/dist/token-utils.d.ts.map +1 -1
- package/dist/token-utils.js +56 -10
- package/dist/token-utils.js.map +1 -1
- package/dist/vault-format.d.ts +4 -0
- package/dist/vault-format.d.ts.map +1 -0
- package/dist/vault-format.js +4 -0
- package/dist/vault-format.js.map +1 -0
- package/dist/vault-types.d.ts +24 -0
- package/dist/vault-types.d.ts.map +1 -0
- package/dist/vault-types.js +2 -0
- package/dist/vault-types.js.map +1 -0
- package/loop_policy.json +10 -12
- package/loopforge-policy.json +340 -0
- package/package.json +13 -7
- package/skills/perception/SKILL.md +200 -90
- package/dist/backends/interface.d.ts +0 -39
- package/dist/backends/interface.d.ts.map +0 -1
- package/dist/backends/interface.js +0 -6
- package/dist/backends/interface.js.map +0 -1
- package/dist/enforcement-gate.d.ts +0 -50
- package/dist/enforcement-gate.d.ts.map +0 -1
- package/dist/enforcement-gate.js +0 -501
- package/dist/enforcement-gate.js.map +0 -1
- package/dist/engine.d.ts +0 -59
- package/dist/engine.d.ts.map +0 -1
- package/dist/engine.js.map +0 -1
- package/dist/interop.d.ts +0 -35
- package/dist/interop.d.ts.map +0 -1
- package/dist/interop.js +0 -34
- package/dist/interop.js.map +0 -1
- package/dist/run.d.ts +0 -51
- package/dist/run.d.ts.map +0 -1
- package/dist/run.js +0 -200
- package/dist/run.js.map +0 -1
- package/dist/self-eval.d.ts +0 -23
- package/dist/self-eval.d.ts.map +0 -1
- package/dist/self-eval.js +0 -151
- package/dist/self-eval.js.map +0 -1
- package/dist/verification-gate.d.ts +0 -52
- package/dist/verification-gate.d.ts.map +0 -1
- package/dist/verification-gate.js +0 -527
- package/dist/verification-gate.js.map +0 -1
package/dist/loop-store.js
CHANGED
|
@@ -1,27 +1,39 @@
|
|
|
1
|
-
/** Typed per-loop persistence.
|
|
2
|
-
*
|
|
3
|
-
* Layout:
|
|
4
|
-
* .loopforge/loops/<sha256(loopId)>/session.json
|
|
5
|
-
* .loopforge/loops/<sha256(loopId)>/rounds/<round>.json
|
|
6
|
-
*
|
|
7
|
-
* Markdown state files are derived views. These JSON documents are the only
|
|
8
|
-
* durable transaction truth.
|
|
9
|
-
*/
|
|
1
|
+
/** Typed per-loop persistence. Markdown and prompts are derived views. */
|
|
10
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
11
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
12
4
|
import { dirname, join, resolve } from "node:path";
|
|
13
5
|
import { parseRoundTransactionSnapshot } from "./round-transaction.js";
|
|
14
6
|
import { validateLoopId } from "./policy.js";
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
7
|
+
import { isRecord } from "./token-utils.js";
|
|
8
|
+
import { LOOP_EVENT_FORMAT, LOOP_STORE_FORMAT } from "./vault-format.js";
|
|
9
|
+
export { LOOP_STORE_FORMAT } from "./vault-format.js";
|
|
10
|
+
/** Synchronous sleep for lock backoff. Atomics.wait on a SharedArrayBuffer
|
|
11
|
+
* is a zero-dependency sleep that works on all supported platforms
|
|
12
|
+
* (including Windows); an ordinary ArrayBuffer would throw a TypeError. */
|
|
13
|
+
const BACKOFF_SLOT = new Int32Array(new SharedArrayBuffer(4));
|
|
14
|
+
function sleepSync(ms) {
|
|
15
|
+
Atomics.wait(BACKOFF_SLOT, 0, 0, ms);
|
|
16
|
+
}
|
|
17
|
+
export class StorageCorruptionError extends Error {
|
|
18
|
+
kind;
|
|
19
|
+
path;
|
|
20
|
+
loopId;
|
|
21
|
+
sequence;
|
|
22
|
+
code = "storage_corruption";
|
|
23
|
+
recoverable = false;
|
|
24
|
+
constructor(kind, path, loopId, sequence, message) {
|
|
25
|
+
super(message ?? `Corrupt LoopForge storage (${kind}) at ${path}`);
|
|
26
|
+
this.kind = kind;
|
|
27
|
+
this.path = path;
|
|
28
|
+
this.loopId = loopId;
|
|
29
|
+
this.sequence = sequence;
|
|
30
|
+
this.name = "StorageCorruptionError";
|
|
31
|
+
}
|
|
18
32
|
}
|
|
19
33
|
function loopIdFromEntry(entry) {
|
|
20
34
|
if (typeof entry.loop_id === "string" && entry.loop_id)
|
|
21
35
|
return entry.loop_id;
|
|
22
36
|
const taskId = String(entry.task_id ?? "");
|
|
23
|
-
if (!taskId.startsWith("loop:"))
|
|
24
|
-
return null;
|
|
25
37
|
const session = taskId.match(/^loop:(.+):session$/);
|
|
26
38
|
if (session)
|
|
27
39
|
return session[1];
|
|
@@ -29,22 +41,19 @@ function loopIdFromEntry(entry) {
|
|
|
29
41
|
return round?.[1] ?? null;
|
|
30
42
|
}
|
|
31
43
|
function roundFromEntry(entry) {
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
34
|
-
return
|
|
35
|
-
}
|
|
44
|
+
const round = entry.loop_lineage?.round;
|
|
45
|
+
if (typeof round === "number" && Number.isInteger(round))
|
|
46
|
+
return round;
|
|
36
47
|
const match = String(entry.task_id ?? "").match(/:r(\d+)(?::|$)/);
|
|
37
48
|
return match ? Number(match[1]) : null;
|
|
38
49
|
}
|
|
39
|
-
function
|
|
40
|
-
return
|
|
50
|
+
function isEvent(value) {
|
|
51
|
+
return isRecord(value) && value.format === LOOP_EVENT_FORMAT;
|
|
41
52
|
}
|
|
42
53
|
export class FileLoopStore {
|
|
43
54
|
root;
|
|
44
55
|
lockDepth = 0;
|
|
45
|
-
constructor(root = ".loopforge") {
|
|
46
|
-
this.root = resolve(root);
|
|
47
|
-
}
|
|
56
|
+
constructor(root = ".loopforge") { this.root = resolve(root); }
|
|
48
57
|
withLock(fn) {
|
|
49
58
|
if (this.lockDepth > 0) {
|
|
50
59
|
this.lockDepth++;
|
|
@@ -56,46 +65,64 @@ export class FileLoopStore {
|
|
|
56
65
|
}
|
|
57
66
|
}
|
|
58
67
|
mkdirSync(this.root, { recursive: true });
|
|
59
|
-
const
|
|
60
|
-
const
|
|
68
|
+
const lock = join(this.root, ".store.lock");
|
|
69
|
+
const owner = join(lock, "owner.json");
|
|
61
70
|
const token = randomUUID();
|
|
62
71
|
const deadline = Date.now() + 1000;
|
|
72
|
+
let spinCount = 0;
|
|
63
73
|
for (;;) {
|
|
64
74
|
try {
|
|
65
|
-
mkdirSync(
|
|
66
|
-
writeFileSync(
|
|
75
|
+
mkdirSync(lock);
|
|
76
|
+
writeFileSync(owner, JSON.stringify({ token, pid: process.pid }));
|
|
67
77
|
break;
|
|
68
78
|
}
|
|
69
79
|
catch {
|
|
70
80
|
let stale = false;
|
|
71
81
|
try {
|
|
72
|
-
const
|
|
73
|
-
const age = Date.now() - statSync(
|
|
74
|
-
if (age > 5000
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
const info = JSON.parse(readFileSync(owner, "utf8"));
|
|
83
|
+
const age = Date.now() - statSync(lock).mtimeMs;
|
|
84
|
+
if (age > 5000) {
|
|
85
|
+
// Unreadable owner (orphaned lock) or a self-owned lock (crashed
|
|
86
|
+
// during release) is reclaimable; otherwise probe the pid. The
|
|
87
|
+
// 5s age gate keeps the mkdir→owner.json race window out of the
|
|
88
|
+
// reclaim path.
|
|
89
|
+
if (typeof info.pid !== "number" || info.pid === process.pid)
|
|
90
|
+
stale = true;
|
|
91
|
+
else
|
|
92
|
+
try {
|
|
93
|
+
process.kill(info.pid, 0);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
const code = error.code;
|
|
97
|
+
stale = code === "ESRCH" || (code === "EPERM" && age > 10000);
|
|
98
|
+
}
|
|
85
99
|
}
|
|
86
100
|
}
|
|
87
101
|
catch {
|
|
88
|
-
|
|
102
|
+
// owner.json unreadable: an orphan left by a crash between mkdir
|
|
103
|
+
// and owner.json. The age gate keeps the mkdir→owner.json race
|
|
104
|
+
// window out of the reclaim path (that write completes in ms).
|
|
105
|
+
let age = -1;
|
|
106
|
+
try {
|
|
107
|
+
age = Date.now() - statSync(lock).mtimeMs;
|
|
108
|
+
}
|
|
109
|
+
catch { /* lock vanished; next spin retries */ }
|
|
110
|
+
if (age > 5000)
|
|
111
|
+
stale = true;
|
|
89
112
|
}
|
|
90
113
|
if (stale) {
|
|
91
114
|
try {
|
|
92
|
-
rmSync(
|
|
115
|
+
rmSync(lock, { recursive: true });
|
|
93
116
|
}
|
|
94
117
|
catch { /* race */ }
|
|
95
118
|
continue;
|
|
96
119
|
}
|
|
97
120
|
if (Date.now() >= deadline)
|
|
98
121
|
throw new Error("LoopStore lock timeout (1000ms)");
|
|
122
|
+
// Exponential backoff (2→4→8→16→32ms) instead of a hot spin; the
|
|
123
|
+
// deadline above still bounds the total wait.
|
|
124
|
+
sleepSync(Math.min(2 << Math.min(spinCount, 4), 32));
|
|
125
|
+
spinCount++;
|
|
99
126
|
}
|
|
100
127
|
}
|
|
101
128
|
this.lockDepth = 1;
|
|
@@ -105,338 +132,232 @@ export class FileLoopStore {
|
|
|
105
132
|
finally {
|
|
106
133
|
this.lockDepth = 0;
|
|
107
134
|
try {
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
110
|
-
rmSync(
|
|
135
|
+
const current = JSON.parse(readFileSync(owner, "utf8"));
|
|
136
|
+
if (current.token === token)
|
|
137
|
+
rmSync(lock, { recursive: true });
|
|
111
138
|
}
|
|
112
|
-
catch { /* never
|
|
139
|
+
catch { /* never remove another owner's lock */ }
|
|
113
140
|
}
|
|
114
141
|
}
|
|
115
142
|
listLoopIds() {
|
|
116
|
-
const
|
|
117
|
-
if (!existsSync(
|
|
143
|
+
const root = join(this.root, "loops");
|
|
144
|
+
if (!existsSync(root))
|
|
118
145
|
return [];
|
|
119
|
-
const
|
|
120
|
-
for (const name of readdirSync(
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
146
|
+
const ids = [];
|
|
147
|
+
for (const name of readdirSync(root)) {
|
|
148
|
+
const metadataPath = join(root, name, "metadata.json");
|
|
149
|
+
const metadata = this.readJson(metadataPath);
|
|
150
|
+
if (metadata === null)
|
|
151
|
+
continue;
|
|
152
|
+
if (!isRecord(metadata) || metadata.format !== LOOP_STORE_FORMAT ||
|
|
153
|
+
typeof metadata.loopId !== "string" || !metadata.loopId) {
|
|
154
|
+
throw new StorageCorruptionError("invalid_format", metadataPath);
|
|
124
155
|
}
|
|
156
|
+
ids.push(metadata.loopId);
|
|
125
157
|
}
|
|
126
|
-
return
|
|
158
|
+
return ids.sort();
|
|
127
159
|
}
|
|
128
160
|
readSession(loopId) {
|
|
129
161
|
validateLoopId(loopId);
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
162
|
+
const path = join(this.loopDir(loopId), "session.json");
|
|
163
|
+
const value = this.readJson(path);
|
|
164
|
+
if (value === null)
|
|
133
165
|
return null;
|
|
166
|
+
if (!isRecord(value) || value.format !== LOOP_STORE_FORMAT || value.loopId !== loopId || !isEvent(value.entry)) {
|
|
167
|
+
throw new StorageCorruptionError("invalid_format", path, loopId);
|
|
168
|
+
}
|
|
169
|
+
if (!Number.isInteger(value.entry.event_sequence) || value.entry.event_sequence < 1) {
|
|
170
|
+
throw new StorageCorruptionError("sequence_invalid", path, loopId, value.entry.event_sequence);
|
|
171
|
+
}
|
|
134
172
|
return value;
|
|
135
173
|
}
|
|
136
174
|
writeSession(loopId, document) {
|
|
137
175
|
validateLoopId(loopId);
|
|
138
|
-
this.
|
|
176
|
+
const entry = this.withEventSequence(loopId, { format: LOOP_EVENT_FORMAT, ...document.entry });
|
|
177
|
+
this.atomicWrite(join(this.loopDir(loopId), "session.json"), { ...document, format: LOOP_STORE_FORMAT, entry });
|
|
139
178
|
}
|
|
140
179
|
readRound(loopId, round) {
|
|
141
180
|
validateLoopId(loopId);
|
|
142
181
|
if (!Number.isInteger(round) || round < 1)
|
|
143
182
|
return null;
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
183
|
+
const path = join(this.loopDir(loopId), "rounds", `${round}.json`);
|
|
184
|
+
const value = this.readJson(path);
|
|
185
|
+
if (value === null)
|
|
147
186
|
return null;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
187
|
+
if (!isRecord(value) || value.format !== LOOP_STORE_FORMAT || value.loopId !== loopId || value.round !== round) {
|
|
188
|
+
throw new StorageCorruptionError("invalid_format", path, loopId);
|
|
189
|
+
}
|
|
190
|
+
const tx = parseRoundTransactionSnapshot(value.transaction);
|
|
191
|
+
if (value.transaction !== undefined && !tx) {
|
|
192
|
+
throw new StorageCorruptionError("invalid_format", path, loopId);
|
|
193
|
+
}
|
|
194
|
+
const invalidEvent = [value.lineage, value.roundEvent]
|
|
195
|
+
.filter((entry) => entry !== undefined && !isEvent(entry)).length > 0;
|
|
196
|
+
if (invalidEvent || (value.events !== undefined &&
|
|
197
|
+
(!Array.isArray(value.events) || value.events.some((entry) => !isEvent(entry))))) {
|
|
198
|
+
throw new StorageCorruptionError("invalid_format", path, loopId);
|
|
199
|
+
}
|
|
200
|
+
for (const event of [value.lineage, value.roundEvent, ...(Array.isArray(value.events) ? value.events : [])]) {
|
|
201
|
+
if (event !== undefined && (!Number.isInteger(event.event_sequence) || event.event_sequence < 1)) {
|
|
202
|
+
throw new StorageCorruptionError("sequence_invalid", path, loopId, event.event_sequence);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { format: LOOP_STORE_FORMAT, loopId, round, updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : "", lineage: isEvent(value.lineage) ? value.lineage : undefined, roundEvent: isEvent(value.roundEvent) ? value.roundEvent : undefined, transaction: tx ?? undefined, promptArtifact: tx?.promptArtifact, events: Array.isArray(value.events) ? value.events : [] };
|
|
163
206
|
}
|
|
164
207
|
listEntries(loopId) {
|
|
165
|
-
const ids = loopId ? [loopId] : this.listLoopIds();
|
|
166
208
|
const result = [];
|
|
167
|
-
for (const id of
|
|
209
|
+
for (const id of loopId ? [loopId] : this.listLoopIds()) {
|
|
168
210
|
const session = this.readSession(id);
|
|
169
211
|
if (session)
|
|
170
212
|
result.push(session.entry);
|
|
171
|
-
const
|
|
172
|
-
if (!existsSync(
|
|
213
|
+
const dir = join(this.loopDir(id), "rounds");
|
|
214
|
+
if (!existsSync(dir))
|
|
173
215
|
continue;
|
|
174
|
-
for (const file of readdirSync(
|
|
216
|
+
for (const file of readdirSync(dir).filter((name) => /^\d+\.json$/.test(name))) {
|
|
175
217
|
const round = this.readRound(id, Number(file.slice(0, -5)));
|
|
176
218
|
if (!round)
|
|
177
219
|
continue;
|
|
178
|
-
if (round.lineage)
|
|
179
|
-
result.push({
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
if (round.feedback)
|
|
185
|
-
result.push(round.feedback);
|
|
220
|
+
if (round.lineage)
|
|
221
|
+
result.push({ ...round.lineage, full_prompt: round.promptArtifact?.renderedPrompt ?? round.lineage.full_prompt });
|
|
222
|
+
if (round.roundEvent)
|
|
223
|
+
result.push(round.roundEvent);
|
|
186
224
|
result.push(...round.events);
|
|
187
225
|
}
|
|
188
226
|
}
|
|
189
227
|
return result;
|
|
190
228
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
this.writeEntry(entry);
|
|
198
|
-
return entries.length;
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
replaceEntries(entries) {
|
|
202
|
-
this.withLock(() => {
|
|
203
|
-
for (const entry of entries)
|
|
204
|
-
this.writeEntry(entry);
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
migrateLegacyVault(path = ".promptcraft/prompt_vault.json") {
|
|
208
|
-
const source = resolve(path);
|
|
209
|
-
const marker = join(this.root, "migrations", "promptcraft-v1.json");
|
|
210
|
-
if (existsSync(marker)) {
|
|
211
|
-
return { source, imported: 0, skipped: 0, alreadyMigrated: true };
|
|
212
|
-
}
|
|
213
|
-
let imported = 0;
|
|
214
|
-
let skipped = 0;
|
|
215
|
-
const raw = this.readJson(source);
|
|
216
|
-
const entries = isRecord(raw) && Array.isArray(raw.entries)
|
|
217
|
-
? raw.entries.filter(isRecord)
|
|
218
|
-
: [];
|
|
219
|
-
this.withLock(() => {
|
|
220
|
-
const existing = new Set(this.listEntries().map((entry) => String(entry.task_id ?? "")));
|
|
221
|
-
for (const entry of entries) {
|
|
222
|
-
if (!loopIdFromEntry(entry) || existing.has(String(entry.task_id ?? ""))) {
|
|
223
|
-
skipped++;
|
|
224
|
-
continue;
|
|
225
|
-
}
|
|
226
|
-
this.writeEntry(entry);
|
|
227
|
-
imported++;
|
|
229
|
+
queryLoopEvents(loopId) {
|
|
230
|
+
const seen = new Set();
|
|
231
|
+
const entries = this.listEntries(loopId).filter((entry) => {
|
|
232
|
+
const derived = loopIdFromEntry(entry);
|
|
233
|
+
if (entry.loop_id !== undefined && entry.loop_id !== loopId) {
|
|
234
|
+
throw new StorageCorruptionError("sequence_cross_loop", this.loopDir(loopId), loopId);
|
|
228
235
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
+
if (derived !== loopId) {
|
|
237
|
+
throw new StorageCorruptionError("sequence_cross_loop", this.loopDir(loopId), loopId);
|
|
238
|
+
}
|
|
239
|
+
return true;
|
|
240
|
+
}).filter((entry) => {
|
|
241
|
+
const key = `${entry.task_id ?? entry.id ?? ""}:${entry.event_sequence ?? ""}`;
|
|
242
|
+
if (seen.has(key)) {
|
|
243
|
+
throw new StorageCorruptionError("sequence_duplicate", this.loopDir(loopId), loopId, entry.event_sequence);
|
|
244
|
+
}
|
|
245
|
+
seen.add(key);
|
|
246
|
+
return true;
|
|
247
|
+
}).sort((a, b) => {
|
|
248
|
+
const sa = a.event_sequence;
|
|
249
|
+
const sb = b.event_sequence;
|
|
250
|
+
if (!Number.isInteger(sa) || !Number.isInteger(sb) || sa < 1 || sb < 1) {
|
|
251
|
+
throw new StorageCorruptionError("sequence_invalid", this.loopDir(loopId), loopId);
|
|
252
|
+
}
|
|
253
|
+
return sa - sb;
|
|
236
254
|
});
|
|
237
|
-
|
|
255
|
+
this.assertSequence(entries, loopId);
|
|
256
|
+
return entries;
|
|
238
257
|
}
|
|
239
|
-
writeEntry(entry)
|
|
240
|
-
|
|
258
|
+
appendEntry(entry) { this.withLock(() => this.writeEntry(entry)); }
|
|
259
|
+
appendEntries(entries) { return this.withLock(() => { for (const entry of entries)
|
|
260
|
+
this.writeEntry(entry); return entries.length; }); }
|
|
261
|
+
replaceEntries(entries) { this.withLock(() => { for (const entry of entries)
|
|
262
|
+
this.writeEntry(entry); }); }
|
|
263
|
+
writeEntry(input) {
|
|
264
|
+
const loopId = loopIdFromEntry(input);
|
|
241
265
|
if (!loopId)
|
|
242
266
|
throw new Error("LoopStore only accepts loop-scoped entries");
|
|
243
267
|
validateLoopId(loopId);
|
|
268
|
+
if (input.loop_id !== undefined && input.loop_id !== loopId) {
|
|
269
|
+
throw new StorageCorruptionError("sequence_cross_loop", this.loopDir(loopId), loopId);
|
|
270
|
+
}
|
|
271
|
+
if (input.format !== undefined && input.format !== LOOP_EVENT_FORMAT)
|
|
272
|
+
throw new Error("LoopStore rejected an event with an unknown format");
|
|
273
|
+
const entry = this.withEventSequence(loopId, { format: LOOP_EVENT_FORMAT, ...input });
|
|
244
274
|
const now = new Date().toISOString();
|
|
245
275
|
const dir = this.loopDir(loopId);
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
276
|
+
// metadata.json holds only {format, loopId} and changes never: rewrite it
|
|
277
|
+
// only when missing (crash-consistent self-healing — a deleted metadata
|
|
278
|
+
// file is rebuilt on the next write, and listLoopIds recovers in turn).
|
|
279
|
+
const metadataPath = join(dir, "metadata.json");
|
|
280
|
+
if (!existsSync(metadataPath))
|
|
281
|
+
this.atomicWrite(metadataPath, { format: LOOP_STORE_FORMAT, loopId });
|
|
250
282
|
if (entry.task_type === "session_state" || entry.task_id === `loop:${loopId}:session`) {
|
|
251
|
-
this.atomicWrite(join(dir, "session.json"), {
|
|
252
|
-
schemaVersion: LOOP_STORE_SCHEMA_VERSION,
|
|
253
|
-
loopId,
|
|
254
|
-
updatedAt: now,
|
|
255
|
-
entry,
|
|
256
|
-
});
|
|
283
|
+
this.atomicWrite(join(dir, "session.json"), { format: LOOP_STORE_FORMAT, loopId, updatedAt: now, entry });
|
|
257
284
|
return;
|
|
258
285
|
}
|
|
259
286
|
const round = roundFromEntry(entry);
|
|
260
287
|
if (!round)
|
|
261
288
|
throw new Error(`Loop entry has no round: ${entry.task_id ?? "unknown"}`);
|
|
262
|
-
const current = this.readRound(loopId, round) ?? {
|
|
263
|
-
schemaVersion: LOOP_STORE_SCHEMA_VERSION,
|
|
264
|
-
loopId,
|
|
265
|
-
round,
|
|
266
|
-
updatedAt: now,
|
|
267
|
-
events: [],
|
|
268
|
-
};
|
|
289
|
+
const current = this.readRound(loopId, round) ?? { format: LOOP_STORE_FORMAT, loopId, round, updatedAt: now, events: [] };
|
|
269
290
|
const taskId = String(entry.task_id ?? "");
|
|
270
|
-
if (taskId.endsWith(":
|
|
271
|
-
current.
|
|
272
|
-
else if (entry.task_type === "loop_lineage" || taskId === `loop:${loopId}:r${round}`)
|
|
291
|
+
if (taskId.endsWith(":event"))
|
|
292
|
+
current.roundEvent = entry;
|
|
293
|
+
else if (entry.task_type === "loop_lineage" || taskId === `loop:${loopId}:r${round}`)
|
|
273
294
|
current.lineage = entry;
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const transactionRaw = current.feedback?.loop_lineage?.round_transaction ??
|
|
282
|
-
entry.loop_lineage?.round_transaction;
|
|
283
|
-
const transaction = parseRoundTransactionSnapshot(isRecord(transactionRaw) ? transactionRaw.snapshot : undefined);
|
|
284
|
-
if (transaction) {
|
|
285
|
-
current.transaction = transaction;
|
|
286
|
-
current.promptArtifact = promptFromSnapshot(transaction);
|
|
295
|
+
else
|
|
296
|
+
current.events = [...current.events.filter((event) => event.task_id !== entry.task_id), entry];
|
|
297
|
+
const raw = current.roundEvent?.loop_lineage?.round_transaction ?? entry.loop_lineage?.round_transaction;
|
|
298
|
+
const tx = parseRoundTransactionSnapshot(isRecord(raw) ? raw.snapshot : undefined);
|
|
299
|
+
if (tx) {
|
|
300
|
+
current.transaction = tx;
|
|
301
|
+
current.promptArtifact = tx.promptArtifact;
|
|
287
302
|
}
|
|
288
303
|
current.updatedAt = now;
|
|
289
304
|
this.atomicWrite(join(dir, "rounds", `${round}.json`), current);
|
|
290
305
|
}
|
|
291
|
-
|
|
292
|
-
const
|
|
293
|
-
|
|
306
|
+
withEventSequence(loopId, entry) {
|
|
307
|
+
const existing = this.listEntries(loopId);
|
|
308
|
+
this.assertSequence(existing, loopId);
|
|
309
|
+
const prior = existing.find((candidate) => candidate.task_id === entry.task_id);
|
|
310
|
+
if (prior && Number.isInteger(prior.event_sequence)) {
|
|
311
|
+
if (entry.event_sequence === undefined)
|
|
312
|
+
return { ...entry, event_sequence: prior.event_sequence };
|
|
313
|
+
if (entry.event_sequence === prior.event_sequence)
|
|
314
|
+
return entry;
|
|
315
|
+
throw new StorageCorruptionError("sequence_invalid", this.loopDir(loopId), loopId, entry.event_sequence, "An existing event cannot change its event_sequence");
|
|
316
|
+
}
|
|
317
|
+
const used = new Set(existing.map((candidate) => candidate.event_sequence).filter((value) => typeof value === "number" && Number.isInteger(value) && value > 0));
|
|
318
|
+
const next = used.size ? Math.max(...used) + 1 : 1;
|
|
319
|
+
if (entry.event_sequence !== undefined) {
|
|
320
|
+
if (!Number.isInteger(entry.event_sequence) || entry.event_sequence < 1) {
|
|
321
|
+
throw new StorageCorruptionError("sequence_invalid", this.loopDir(loopId), loopId, entry.event_sequence);
|
|
322
|
+
}
|
|
323
|
+
if (used.has(entry.event_sequence)) {
|
|
324
|
+
throw new StorageCorruptionError("sequence_duplicate", this.loopDir(loopId), loopId, entry.event_sequence);
|
|
325
|
+
}
|
|
326
|
+
if (entry.event_sequence !== next) {
|
|
327
|
+
throw new StorageCorruptionError("sequence_gap", this.loopDir(loopId), loopId, entry.event_sequence);
|
|
328
|
+
}
|
|
329
|
+
return entry;
|
|
330
|
+
}
|
|
331
|
+
return { ...entry, event_sequence: next };
|
|
294
332
|
}
|
|
333
|
+
loopDir(loopId) { return join(this.root, "loops", createHash("sha256").update(loopId).digest("hex")); }
|
|
295
334
|
readJson(path) {
|
|
296
335
|
try {
|
|
297
336
|
return JSON.parse(readFileSync(path, "utf8"));
|
|
298
337
|
}
|
|
299
|
-
catch {
|
|
300
|
-
|
|
338
|
+
catch (error) {
|
|
339
|
+
if (error.code === "ENOENT")
|
|
340
|
+
return null;
|
|
341
|
+
throw new StorageCorruptionError("invalid_json", path, undefined, undefined, `Invalid JSON in ${path}`);
|
|
301
342
|
}
|
|
302
343
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
this.store = store;
|
|
319
|
-
}
|
|
320
|
-
withLock(fn) { return this.store.withLock(fn); }
|
|
321
|
-
queryEntries(opts) {
|
|
322
|
-
return this.store.listEntries().filter((entry) => {
|
|
323
|
-
const taskId = String(entry.task_id ?? "");
|
|
324
|
-
if (opts?.feedbackOnly && !taskId.endsWith(":feedback"))
|
|
325
|
-
return false;
|
|
326
|
-
if (!opts?.feedbackOnly && taskId.endsWith(":feedback"))
|
|
327
|
-
return false;
|
|
328
|
-
if (opts?.prefix && !taskId.startsWith(opts.prefix))
|
|
329
|
-
return false;
|
|
330
|
-
if (opts?.taskIdPattern && !taskId.includes(opts.taskIdPattern))
|
|
331
|
-
return false;
|
|
332
|
-
return true;
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
appendEntry(entry) { this.store.appendEntry(entry); }
|
|
336
|
-
appendEntries(entries) { return this.store.appendEntries(entries); }
|
|
337
|
-
}
|
|
338
|
-
/** Adapter that wraps a VaultBackend as a LoopStore for backward compat.
|
|
339
|
-
*
|
|
340
|
-
* SessionManager uses this when a VaultBackend is provided directly so
|
|
341
|
-
* VaultSessionStateStore can operate on typed session documents while
|
|
342
|
-
* the underlying storage remains VaultBackend entries. */
|
|
343
|
-
export class VaultBackendLoopStore {
|
|
344
|
-
backend;
|
|
345
|
-
constructor(backend) {
|
|
346
|
-
this.backend = backend;
|
|
347
|
-
}
|
|
348
|
-
withLock(fn) {
|
|
349
|
-
return typeof this.backend.withLock === "function"
|
|
350
|
-
? this.backend.withLock(fn)
|
|
351
|
-
: fn();
|
|
352
|
-
}
|
|
353
|
-
listLoopIds() {
|
|
354
|
-
return [...new Set(this.backend.queryEntries()
|
|
355
|
-
.filter((e) => typeof e.loop_id === "string")
|
|
356
|
-
.map((e) => e.loop_id))].sort();
|
|
357
|
-
}
|
|
358
|
-
listEntries(loopId) {
|
|
359
|
-
if (loopId)
|
|
360
|
-
return this.backend.queryEntries({ prefix: `loop:${loopId}` });
|
|
361
|
-
return this.backend.queryEntries();
|
|
362
|
-
}
|
|
363
|
-
appendEntry(entry) { this.backend.appendEntry(entry); }
|
|
364
|
-
appendEntries(entries) { return this.backend.appendEntries(entries); }
|
|
365
|
-
replaceEntries(_entries) {
|
|
366
|
-
// Best-effort: not all VaultBackend implementations support bulk replace.
|
|
367
|
-
// The typed session path bypasses this method.
|
|
368
|
-
}
|
|
369
|
-
readSession(loopId) {
|
|
370
|
-
const entry = this.backend.queryEntries({ prefix: `loop:${loopId}:session` })
|
|
371
|
-
.find((e) => e.task_type === "session_state" && e.loop_id === loopId);
|
|
372
|
-
if (!entry)
|
|
373
|
-
return null;
|
|
374
|
-
return {
|
|
375
|
-
schemaVersion: LOOP_STORE_SCHEMA_VERSION,
|
|
376
|
-
loopId,
|
|
377
|
-
updatedAt: typeof entry.timestamp === "string"
|
|
378
|
-
? entry.timestamp
|
|
379
|
-
: new Date().toISOString(),
|
|
380
|
-
entry,
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
writeSession(loopId, document) {
|
|
384
|
-
const entry = document.entry;
|
|
385
|
-
// Ensure the entry carries identifying fields expected by queryEntries.
|
|
386
|
-
entry.task_type = "session_state";
|
|
387
|
-
entry.loop_id = loopId;
|
|
388
|
-
entry.task_id = `loop:${loopId}:session`;
|
|
389
|
-
// Session state lives in the vault entries list. The backend may
|
|
390
|
-
// provide readVault/writeVault as optional compat methods; if not,
|
|
391
|
-
// we fall back to appendEntry (which may create duplicates, but
|
|
392
|
-
// VaultSessionStateStore.load returns the first match).
|
|
393
|
-
const be = this.backend;
|
|
394
|
-
if (typeof be.readVault === "function" &&
|
|
395
|
-
typeof be.writeVault === "function") {
|
|
396
|
-
// Call via .call(be) so methods that reference `this` (e.g.
|
|
397
|
-
// MemoryBackend.readVault) keep their receiver.
|
|
398
|
-
const data = be.readVault.call(be);
|
|
399
|
-
const entries = Array.isArray(data.entries)
|
|
400
|
-
? data.entries
|
|
401
|
-
: [];
|
|
402
|
-
const filtered = entries.filter((item) => !(item.task_type === "session_state" && item.loop_id === loopId));
|
|
403
|
-
be.writeVault.call(be, { entries: [...filtered, entry] });
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
this.backend.appendEntry(entry);
|
|
407
|
-
}
|
|
408
|
-
readRound(loopId, round) {
|
|
409
|
-
const prefix = `loop:${loopId}:r${round}:`;
|
|
410
|
-
const entries = this.backend.queryEntries({ prefix });
|
|
411
|
-
if (entries.length === 0)
|
|
412
|
-
return null;
|
|
413
|
-
// Separate lineage, feedback, and raw events — same structure as
|
|
414
|
-
// FileLoopStore.readRound so callers get consistent round documents
|
|
415
|
-
// regardless of backend.
|
|
416
|
-
const lineage = entries.find((e) => e.task_type === "loop_lineage" || e.task_id === `loop:${loopId}:r${round}`);
|
|
417
|
-
const feedback = entries.find((e) => String(e.task_id ?? "").endsWith(":feedback"));
|
|
418
|
-
const events = entries.filter((e) => e !== lineage && e !== feedback);
|
|
419
|
-
let transaction;
|
|
420
|
-
const transactionRaw = feedback?.loop_lineage?.round_transaction ??
|
|
421
|
-
lineage?.loop_lineage?.round_transaction;
|
|
422
|
-
const transactionParsed = parseRoundTransactionSnapshot(isRecord(transactionRaw) ? transactionRaw.snapshot : undefined);
|
|
423
|
-
if (transactionParsed) {
|
|
424
|
-
transaction = transactionParsed;
|
|
344
|
+
assertSequence(entries, loopId) {
|
|
345
|
+
const seen = new Set();
|
|
346
|
+
const sorted = [...entries].sort((a, b) => (a.event_sequence ?? 0) - (b.event_sequence ?? 0));
|
|
347
|
+
for (let index = 0; index < sorted.length; index++) {
|
|
348
|
+
const sequence = sorted[index].event_sequence;
|
|
349
|
+
if (!Number.isInteger(sequence) || sequence < 1) {
|
|
350
|
+
throw new StorageCorruptionError("sequence_invalid", this.loopDir(loopId), loopId, sequence);
|
|
351
|
+
}
|
|
352
|
+
if (seen.has(sequence)) {
|
|
353
|
+
throw new StorageCorruptionError("sequence_duplicate", this.loopDir(loopId), loopId, sequence);
|
|
354
|
+
}
|
|
355
|
+
seen.add(sequence);
|
|
356
|
+
if (sequence !== index + 1) {
|
|
357
|
+
throw new StorageCorruptionError("sequence_gap", this.loopDir(loopId), loopId, sequence);
|
|
358
|
+
}
|
|
425
359
|
}
|
|
426
|
-
return {
|
|
427
|
-
schemaVersion: LOOP_STORE_SCHEMA_VERSION,
|
|
428
|
-
loopId,
|
|
429
|
-
round,
|
|
430
|
-
updatedAt: entries[0].timestamp ?? new Date().toISOString(),
|
|
431
|
-
lineage: lineage,
|
|
432
|
-
feedback: feedback,
|
|
433
|
-
transaction,
|
|
434
|
-
promptArtifact: transaction?.promptArtifact,
|
|
435
|
-
events,
|
|
436
|
-
};
|
|
437
|
-
}
|
|
438
|
-
migrateLegacyVault(_path) {
|
|
439
|
-
return { source: _path ?? "vault", imported: 0, skipped: 0, alreadyMigrated: true };
|
|
440
360
|
}
|
|
361
|
+
atomicWrite(path, value) { mkdirSync(dirname(path), { recursive: true }); const temporary = `${path}.tmp.${randomUUID().slice(0, 8)}`; writeFileSync(temporary, JSON.stringify(value, null, 2), "utf8"); renameSync(temporary, path); }
|
|
441
362
|
}
|
|
442
363
|
//# sourceMappingURL=loop-store.js.map
|