loopforge 2.11.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 +145 -128
- 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 +170 -61
- 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 +23 -21
- package/dist/evidence-provider.d.ts.map +1 -1
- package/dist/evidence-provider.js +34 -74
- 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 +22 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -16
- package/dist/index.js.map +1 -1
- package/dist/loop-compiler.d.ts +1 -1
- package/dist/loop-compiler.d.ts.map +1 -1
- package/dist/loop-compiler.js +250 -337
- 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 -309
- 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 +83 -13
- 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 +49 -93
- package/dist/mcp/session.d.ts.map +1 -1
- package/dist/mcp/session.js +544 -245
- 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 +399 -274
- package/dist/mcp/tools.js.map +1 -1
- package/dist/policy-metrics.d.ts +17 -1
- package/dist/policy-metrics.d.ts.map +1 -1
- package/dist/policy-metrics.js +164 -1
- package/dist/policy-metrics.js.map +1 -1
- package/dist/policy.d.ts +34 -18
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +113 -18
- package/dist/policy.js.map +1 -1
- package/dist/prompt-assembler.d.ts +5 -3
- package/dist/prompt-assembler.d.ts.map +1 -1
- package/dist/prompt-assembler.js +126 -77
- 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 +232 -192
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +15 -100
- 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 +25 -18
- package/dist/round-coordinator.d.ts.map +1 -1
- package/dist/round-coordinator.js +83 -71
- package/dist/round-coordinator.js.map +1 -1
- package/dist/round-driver.d.ts +14 -10
- package/dist/round-driver.d.ts.map +1 -1
- package/dist/round-driver.js +9 -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 +19 -12
- package/dist/round-transaction.d.ts.map +1 -1
- package/dist/round-transaction.js +102 -88
- 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/runtime-services.js +460 -0
- 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 +9 -8
- package/dist/token-utils.d.ts.map +1 -1
- package/dist/token-utils.js +50 -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 +12 -6
- package/skills/perception/SKILL.md +199 -164
- 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 -71
- package/dist/enforcement-gate.d.ts.map +0 -1
- package/dist/enforcement-gate.js +0 -744
- 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 +0 -596
- package/dist/engine.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 -181
- package/dist/self-eval.js.map +0 -1
- package/dist/verification-gate.d.ts +0 -56
- package/dist/verification-gate.d.ts.map +0 -1
- package/dist/verification-gate.js +0 -563
- package/dist/verification-gate.js.map +0 -1
package/dist/loop-store.js
CHANGED
|
@@ -1,25 +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
7
|
import { isRecord } from "./token-utils.js";
|
|
16
|
-
|
|
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
|
+
}
|
|
32
|
+
}
|
|
17
33
|
function loopIdFromEntry(entry) {
|
|
18
34
|
if (typeof entry.loop_id === "string" && entry.loop_id)
|
|
19
35
|
return entry.loop_id;
|
|
20
36
|
const taskId = String(entry.task_id ?? "");
|
|
21
|
-
if (!taskId.startsWith("loop:"))
|
|
22
|
-
return null;
|
|
23
37
|
const session = taskId.match(/^loop:(.+):session$/);
|
|
24
38
|
if (session)
|
|
25
39
|
return session[1];
|
|
@@ -27,22 +41,19 @@ function loopIdFromEntry(entry) {
|
|
|
27
41
|
return round?.[1] ?? null;
|
|
28
42
|
}
|
|
29
43
|
function roundFromEntry(entry) {
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
32
|
-
return
|
|
33
|
-
}
|
|
44
|
+
const round = entry.loop_lineage?.round;
|
|
45
|
+
if (typeof round === "number" && Number.isInteger(round))
|
|
46
|
+
return round;
|
|
34
47
|
const match = String(entry.task_id ?? "").match(/:r(\d+)(?::|$)/);
|
|
35
48
|
return match ? Number(match[1]) : null;
|
|
36
49
|
}
|
|
37
|
-
function
|
|
38
|
-
return
|
|
50
|
+
function isEvent(value) {
|
|
51
|
+
return isRecord(value) && value.format === LOOP_EVENT_FORMAT;
|
|
39
52
|
}
|
|
40
53
|
export class FileLoopStore {
|
|
41
54
|
root;
|
|
42
55
|
lockDepth = 0;
|
|
43
|
-
constructor(root = ".loopforge") {
|
|
44
|
-
this.root = resolve(root);
|
|
45
|
-
}
|
|
56
|
+
constructor(root = ".loopforge") { this.root = resolve(root); }
|
|
46
57
|
withLock(fn) {
|
|
47
58
|
if (this.lockDepth > 0) {
|
|
48
59
|
this.lockDepth++;
|
|
@@ -54,46 +65,64 @@ export class FileLoopStore {
|
|
|
54
65
|
}
|
|
55
66
|
}
|
|
56
67
|
mkdirSync(this.root, { recursive: true });
|
|
57
|
-
const
|
|
58
|
-
const
|
|
68
|
+
const lock = join(this.root, ".store.lock");
|
|
69
|
+
const owner = join(lock, "owner.json");
|
|
59
70
|
const token = randomUUID();
|
|
60
71
|
const deadline = Date.now() + 1000;
|
|
72
|
+
let spinCount = 0;
|
|
61
73
|
for (;;) {
|
|
62
74
|
try {
|
|
63
|
-
mkdirSync(
|
|
64
|
-
writeFileSync(
|
|
75
|
+
mkdirSync(lock);
|
|
76
|
+
writeFileSync(owner, JSON.stringify({ token, pid: process.pid }));
|
|
65
77
|
break;
|
|
66
78
|
}
|
|
67
79
|
catch {
|
|
68
80
|
let stale = false;
|
|
69
81
|
try {
|
|
70
|
-
const
|
|
71
|
-
const age = Date.now() - statSync(
|
|
72
|
-
if (age > 5000
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
+
}
|
|
83
99
|
}
|
|
84
100
|
}
|
|
85
101
|
catch {
|
|
86
|
-
|
|
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;
|
|
87
112
|
}
|
|
88
113
|
if (stale) {
|
|
89
114
|
try {
|
|
90
|
-
rmSync(
|
|
115
|
+
rmSync(lock, { recursive: true });
|
|
91
116
|
}
|
|
92
117
|
catch { /* race */ }
|
|
93
118
|
continue;
|
|
94
119
|
}
|
|
95
120
|
if (Date.now() >= deadline)
|
|
96
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++;
|
|
97
126
|
}
|
|
98
127
|
}
|
|
99
128
|
this.lockDepth = 1;
|
|
@@ -103,338 +132,232 @@ export class FileLoopStore {
|
|
|
103
132
|
finally {
|
|
104
133
|
this.lockDepth = 0;
|
|
105
134
|
try {
|
|
106
|
-
const
|
|
107
|
-
if (
|
|
108
|
-
rmSync(
|
|
135
|
+
const current = JSON.parse(readFileSync(owner, "utf8"));
|
|
136
|
+
if (current.token === token)
|
|
137
|
+
rmSync(lock, { recursive: true });
|
|
109
138
|
}
|
|
110
|
-
catch { /* never
|
|
139
|
+
catch { /* never remove another owner's lock */ }
|
|
111
140
|
}
|
|
112
141
|
}
|
|
113
142
|
listLoopIds() {
|
|
114
|
-
const
|
|
115
|
-
if (!existsSync(
|
|
143
|
+
const root = join(this.root, "loops");
|
|
144
|
+
if (!existsSync(root))
|
|
116
145
|
return [];
|
|
117
|
-
const
|
|
118
|
-
for (const name of readdirSync(
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
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);
|
|
122
155
|
}
|
|
156
|
+
ids.push(metadata.loopId);
|
|
123
157
|
}
|
|
124
|
-
return
|
|
158
|
+
return ids.sort();
|
|
125
159
|
}
|
|
126
160
|
readSession(loopId) {
|
|
127
161
|
validateLoopId(loopId);
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
162
|
+
const path = join(this.loopDir(loopId), "session.json");
|
|
163
|
+
const value = this.readJson(path);
|
|
164
|
+
if (value === null)
|
|
131
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
|
+
}
|
|
132
172
|
return value;
|
|
133
173
|
}
|
|
134
174
|
writeSession(loopId, document) {
|
|
135
175
|
validateLoopId(loopId);
|
|
136
|
-
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 });
|
|
137
178
|
}
|
|
138
179
|
readRound(loopId, round) {
|
|
139
180
|
validateLoopId(loopId);
|
|
140
181
|
if (!Number.isInteger(round) || round < 1)
|
|
141
182
|
return null;
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
183
|
+
const path = join(this.loopDir(loopId), "rounds", `${round}.json`);
|
|
184
|
+
const value = this.readJson(path);
|
|
185
|
+
if (value === null)
|
|
145
186
|
return null;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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 : [] };
|
|
161
206
|
}
|
|
162
207
|
listEntries(loopId) {
|
|
163
|
-
const ids = loopId ? [loopId] : this.listLoopIds();
|
|
164
208
|
const result = [];
|
|
165
|
-
for (const id of
|
|
209
|
+
for (const id of loopId ? [loopId] : this.listLoopIds()) {
|
|
166
210
|
const session = this.readSession(id);
|
|
167
211
|
if (session)
|
|
168
212
|
result.push(session.entry);
|
|
169
|
-
const
|
|
170
|
-
if (!existsSync(
|
|
213
|
+
const dir = join(this.loopDir(id), "rounds");
|
|
214
|
+
if (!existsSync(dir))
|
|
171
215
|
continue;
|
|
172
|
-
for (const file of readdirSync(
|
|
216
|
+
for (const file of readdirSync(dir).filter((name) => /^\d+\.json$/.test(name))) {
|
|
173
217
|
const round = this.readRound(id, Number(file.slice(0, -5)));
|
|
174
218
|
if (!round)
|
|
175
219
|
continue;
|
|
176
|
-
if (round.lineage)
|
|
177
|
-
result.push({
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
if (round.feedback)
|
|
183
|
-
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);
|
|
184
224
|
result.push(...round.events);
|
|
185
225
|
}
|
|
186
226
|
}
|
|
187
227
|
return result;
|
|
188
228
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
replaceEntries(entries) {
|
|
200
|
-
this.withLock(() => {
|
|
201
|
-
for (const entry of entries)
|
|
202
|
-
this.writeEntry(entry);
|
|
203
|
-
});
|
|
204
|
-
}
|
|
205
|
-
migrateLegacyVault(path = ".promptcraft/prompt_vault.json") {
|
|
206
|
-
const source = resolve(path);
|
|
207
|
-
const marker = join(this.root, "migrations", "promptcraft-v1.json");
|
|
208
|
-
if (existsSync(marker)) {
|
|
209
|
-
return { source, imported: 0, skipped: 0, alreadyMigrated: true };
|
|
210
|
-
}
|
|
211
|
-
let imported = 0;
|
|
212
|
-
let skipped = 0;
|
|
213
|
-
const raw = this.readJson(source);
|
|
214
|
-
const entries = isRecord(raw) && Array.isArray(raw.entries)
|
|
215
|
-
? raw.entries.filter(isRecord)
|
|
216
|
-
: [];
|
|
217
|
-
this.withLock(() => {
|
|
218
|
-
const existing = new Set(this.listEntries().map((entry) => String(entry.task_id ?? "")));
|
|
219
|
-
for (const entry of entries) {
|
|
220
|
-
if (!loopIdFromEntry(entry) || existing.has(String(entry.task_id ?? ""))) {
|
|
221
|
-
skipped++;
|
|
222
|
-
continue;
|
|
223
|
-
}
|
|
224
|
-
this.writeEntry(entry);
|
|
225
|
-
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);
|
|
235
|
+
}
|
|
236
|
+
if (derived !== loopId) {
|
|
237
|
+
throw new StorageCorruptionError("sequence_cross_loop", this.loopDir(loopId), loopId);
|
|
226
238
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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;
|
|
234
254
|
});
|
|
235
|
-
|
|
255
|
+
this.assertSequence(entries, loopId);
|
|
256
|
+
return entries;
|
|
236
257
|
}
|
|
237
|
-
writeEntry(entry)
|
|
238
|
-
|
|
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);
|
|
239
265
|
if (!loopId)
|
|
240
266
|
throw new Error("LoopStore only accepts loop-scoped entries");
|
|
241
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 });
|
|
242
274
|
const now = new Date().toISOString();
|
|
243
275
|
const dir = this.loopDir(loopId);
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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 });
|
|
248
282
|
if (entry.task_type === "session_state" || entry.task_id === `loop:${loopId}:session`) {
|
|
249
|
-
this.atomicWrite(join(dir, "session.json"), {
|
|
250
|
-
schemaVersion: LOOP_STORE_SCHEMA_VERSION,
|
|
251
|
-
loopId,
|
|
252
|
-
updatedAt: now,
|
|
253
|
-
entry,
|
|
254
|
-
});
|
|
283
|
+
this.atomicWrite(join(dir, "session.json"), { format: LOOP_STORE_FORMAT, loopId, updatedAt: now, entry });
|
|
255
284
|
return;
|
|
256
285
|
}
|
|
257
286
|
const round = roundFromEntry(entry);
|
|
258
287
|
if (!round)
|
|
259
288
|
throw new Error(`Loop entry has no round: ${entry.task_id ?? "unknown"}`);
|
|
260
|
-
const current = this.readRound(loopId, round) ?? {
|
|
261
|
-
schemaVersion: LOOP_STORE_SCHEMA_VERSION,
|
|
262
|
-
loopId,
|
|
263
|
-
round,
|
|
264
|
-
updatedAt: now,
|
|
265
|
-
events: [],
|
|
266
|
-
};
|
|
289
|
+
const current = this.readRound(loopId, round) ?? { format: LOOP_STORE_FORMAT, loopId, round, updatedAt: now, events: [] };
|
|
267
290
|
const taskId = String(entry.task_id ?? "");
|
|
268
|
-
if (taskId.endsWith(":
|
|
269
|
-
current.
|
|
270
|
-
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}`)
|
|
271
294
|
current.lineage = entry;
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const transactionRaw = current.feedback?.loop_lineage?.round_transaction ??
|
|
280
|
-
entry.loop_lineage?.round_transaction;
|
|
281
|
-
const transaction = parseRoundTransactionSnapshot(isRecord(transactionRaw) ? transactionRaw.snapshot : undefined);
|
|
282
|
-
if (transaction) {
|
|
283
|
-
current.transaction = transaction;
|
|
284
|
-
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;
|
|
285
302
|
}
|
|
286
303
|
current.updatedAt = now;
|
|
287
304
|
this.atomicWrite(join(dir, "rounds", `${round}.json`), current);
|
|
288
305
|
}
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
|
|
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 };
|
|
292
332
|
}
|
|
333
|
+
loopDir(loopId) { return join(this.root, "loops", createHash("sha256").update(loopId).digest("hex")); }
|
|
293
334
|
readJson(path) {
|
|
294
335
|
try {
|
|
295
336
|
return JSON.parse(readFileSync(path, "utf8"));
|
|
296
337
|
}
|
|
297
|
-
catch {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
atomicWrite(path, value) {
|
|
302
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
303
|
-
const temporary = `${path}.tmp.${randomUUID().slice(0, 8)}`;
|
|
304
|
-
writeFileSync(temporary, JSON.stringify(value, null, 2), "utf8");
|
|
305
|
-
renameSync(temporary, path);
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
/** @deprecated Use LoopStore directly.
|
|
309
|
-
*
|
|
310
|
-
* Compatibility adapter so modules that still accept VaultBackend can
|
|
311
|
-
* operate on a LoopStore. Persistent truth is the typed per-loop
|
|
312
|
-
* documents; no Markdown lineage is written. */
|
|
313
|
-
export class LoopStoreBackend {
|
|
314
|
-
store;
|
|
315
|
-
constructor(store = new FileLoopStore()) {
|
|
316
|
-
this.store = store;
|
|
317
|
-
}
|
|
318
|
-
withLock(fn) { return this.store.withLock(fn); }
|
|
319
|
-
queryEntries(opts) {
|
|
320
|
-
return this.store.listEntries().filter((entry) => {
|
|
321
|
-
const taskId = String(entry.task_id ?? "");
|
|
322
|
-
if (opts?.feedbackOnly && !taskId.endsWith(":feedback"))
|
|
323
|
-
return false;
|
|
324
|
-
if (!opts?.feedbackOnly && taskId.endsWith(":feedback"))
|
|
325
|
-
return false;
|
|
326
|
-
if (opts?.prefix && !taskId.startsWith(opts.prefix))
|
|
327
|
-
return false;
|
|
328
|
-
if (opts?.taskIdPattern && !taskId.includes(opts.taskIdPattern))
|
|
329
|
-
return false;
|
|
330
|
-
return true;
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
appendEntry(entry) { this.store.appendEntry(entry); }
|
|
334
|
-
appendEntries(entries) { return this.store.appendEntries(entries); }
|
|
335
|
-
}
|
|
336
|
-
/** Adapter that wraps a VaultBackend as a LoopStore for backward compat.
|
|
337
|
-
*
|
|
338
|
-
* SessionManager uses this when a VaultBackend is provided directly so
|
|
339
|
-
* VaultSessionStateStore can operate on typed session documents while
|
|
340
|
-
* the underlying storage remains VaultBackend entries. */
|
|
341
|
-
export class VaultBackendLoopStore {
|
|
342
|
-
backend;
|
|
343
|
-
constructor(backend) {
|
|
344
|
-
this.backend = backend;
|
|
345
|
-
}
|
|
346
|
-
withLock(fn) {
|
|
347
|
-
return typeof this.backend.withLock === "function"
|
|
348
|
-
? this.backend.withLock(fn)
|
|
349
|
-
: fn();
|
|
350
|
-
}
|
|
351
|
-
listLoopIds() {
|
|
352
|
-
return [...new Set(this.backend.queryEntries()
|
|
353
|
-
.filter((e) => typeof e.loop_id === "string")
|
|
354
|
-
.map((e) => e.loop_id))].sort();
|
|
355
|
-
}
|
|
356
|
-
listEntries(loopId) {
|
|
357
|
-
if (loopId)
|
|
358
|
-
return this.backend.queryEntries({ prefix: `loop:${loopId}` });
|
|
359
|
-
return this.backend.queryEntries();
|
|
360
|
-
}
|
|
361
|
-
appendEntry(entry) { this.backend.appendEntry(entry); }
|
|
362
|
-
appendEntries(entries) { return this.backend.appendEntries(entries); }
|
|
363
|
-
replaceEntries(_entries) {
|
|
364
|
-
throw new Error("VaultBackendLoopStore.replaceEntries is not supported. " +
|
|
365
|
-
"Use LoopStore (FileLoopStore) directly for bulk replace operations.");
|
|
366
|
-
}
|
|
367
|
-
readSession(loopId) {
|
|
368
|
-
const entry = this.backend.queryEntries({ prefix: `loop:${loopId}:session` })
|
|
369
|
-
.find((e) => e.task_type === "session_state" && e.loop_id === loopId);
|
|
370
|
-
if (!entry)
|
|
371
|
-
return null;
|
|
372
|
-
return {
|
|
373
|
-
schemaVersion: LOOP_STORE_SCHEMA_VERSION,
|
|
374
|
-
loopId,
|
|
375
|
-
updatedAt: typeof entry.timestamp === "string"
|
|
376
|
-
? entry.timestamp
|
|
377
|
-
: new Date().toISOString(),
|
|
378
|
-
entry,
|
|
379
|
-
};
|
|
380
|
-
}
|
|
381
|
-
writeSession(loopId, document) {
|
|
382
|
-
const entry = document.entry;
|
|
383
|
-
// Ensure the entry carries identifying fields expected by queryEntries.
|
|
384
|
-
entry.task_type = "session_state";
|
|
385
|
-
entry.loop_id = loopId;
|
|
386
|
-
entry.task_id = `loop:${loopId}:session`;
|
|
387
|
-
// Session state lives in the vault entries list. The backend may
|
|
388
|
-
// provide readVault/writeVault as optional compat methods; if not,
|
|
389
|
-
// we fall back to appendEntry (which may create duplicates, but
|
|
390
|
-
// VaultSessionStateStore.load returns the first match).
|
|
391
|
-
const be = this.backend;
|
|
392
|
-
if (typeof be.readVault === "function" &&
|
|
393
|
-
typeof be.writeVault === "function") {
|
|
394
|
-
// Call via .call(be) so methods that reference `this` (e.g.
|
|
395
|
-
// MemoryBackend.readVault) keep their receiver.
|
|
396
|
-
const data = be.readVault.call(be);
|
|
397
|
-
const entries = Array.isArray(data.entries)
|
|
398
|
-
? data.entries
|
|
399
|
-
: [];
|
|
400
|
-
const filtered = entries.filter((item) => !(item.task_type === "session_state" && item.loop_id === loopId));
|
|
401
|
-
be.writeVault.call(be, { entries: [...filtered, entry] });
|
|
402
|
-
return;
|
|
338
|
+
catch (error) {
|
|
339
|
+
if (error.code === "ENOENT")
|
|
340
|
+
return null;
|
|
341
|
+
throw new StorageCorruptionError("invalid_json", path, undefined, undefined, `Invalid JSON in ${path}`);
|
|
403
342
|
}
|
|
404
|
-
this.backend.appendEntry(entry);
|
|
405
343
|
}
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (transactionParsed) {
|
|
422
|
-
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
|
+
}
|
|
423
359
|
}
|
|
424
|
-
return {
|
|
425
|
-
schemaVersion: LOOP_STORE_SCHEMA_VERSION,
|
|
426
|
-
loopId,
|
|
427
|
-
round,
|
|
428
|
-
updatedAt: entries[0].timestamp ?? new Date().toISOString(),
|
|
429
|
-
lineage: lineage,
|
|
430
|
-
feedback: feedback,
|
|
431
|
-
transaction,
|
|
432
|
-
promptArtifact: transaction?.promptArtifact,
|
|
433
|
-
events,
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
migrateLegacyVault(_path) {
|
|
437
|
-
return { source: _path ?? "vault", imported: 0, skipped: 0, alreadyMigrated: true };
|
|
438
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); }
|
|
439
362
|
}
|
|
440
363
|
//# sourceMappingURL=loop-store.js.map
|