@llblab/pi-kit 0.7.0 → 0.8.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/CHANGELOG.md +8 -0
- package/README.md +2 -2
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +21 -21
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +3 -122
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +11 -0
- package/node_modules/@llblab/pi-state-flow/README.md +41 -35
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +36 -16
- package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +4 -4
- package/node_modules/@llblab/pi-state-flow/index.ts +23 -1
- package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +3 -0
- package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +191 -29
- package/node_modules/@llblab/pi-state-flow/lib/config.ts +6 -1
- package/node_modules/@llblab/pi-state-flow/lib/context.ts +42 -7
- package/node_modules/@llblab/pi-state-flow/lib/durable.ts +38 -8
- package/node_modules/@llblab/pi-state-flow/lib/episode.ts +1 -13
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +290 -134
- package/node_modules/@llblab/pi-state-flow/lib/git.ts +32 -16
- package/node_modules/@llblab/pi-state-flow/lib/logging.ts +41 -0
- package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +12 -6
- package/node_modules/@llblab/pi-state-flow/lib/migration.ts +16 -0
- package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +3 -0
- package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +167 -31
- package/node_modules/@llblab/pi-state-flow/lib/skills.ts +15 -6
- package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +40 -34
- package/node_modules/@llblab/pi-state-flow/lib/state.ts +6 -0
- package/node_modules/@llblab/pi-state-flow/lib/status.ts +4 -9
- package/node_modules/@llblab/pi-state-flow/lib/storage.ts +25 -5
- package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +17 -147
- package/node_modules/@llblab/pi-state-flow/lib/transition.ts +49 -27
- package/node_modules/@llblab/pi-state-flow/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -2
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +3 -2
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +6 -1
- package/node_modules/@llblab/pi-telegram/README.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +8 -6
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +4 -4
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +18 -0
- package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +32 -19
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +5 -0
- package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +189 -15
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +3 -0
- package/node_modules/@llblab/pi-telegram/lib/config.ts +67 -4
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +65 -6
- package/node_modules/@llblab/pi-telegram/lib/locks.ts +6 -1
- package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +49 -3
- package/node_modules/@llblab/pi-telegram/lib/preview.ts +17 -0
- package/node_modules/@llblab/pi-telegram/lib/prompts.ts +1 -0
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +66 -8
- package/node_modules/@llblab/pi-telegram/lib/rendering.ts +4 -1
- package/node_modules/@llblab/pi-telegram/lib/replies.ts +19 -0
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +39 -0
- package/node_modules/@llblab/pi-telegram/lib/setup.ts +44 -4
- package/node_modules/@llblab/pi-telegram/lib/status.ts +41 -4
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +74 -18
- package/node_modules/@llblab/pi-telegram/lib/turns.ts +7 -0
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/package.json +3 -3
- package/node_modules/@llblab/pi-state-flow/lib/validation.ts +0 -27
- /package/node_modules/@llblab/pi-telegram/lib/{logs.ts → logging.ts} +0 -0
|
@@ -1,20 +1,40 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { containsNull, isJsonValue, isObject, type JsonObject } from "./json.ts";
|
|
2
|
+
import { containsNull, isJsonValue, isObject, type JsonObject, type JsonValue } from "./json.ts";
|
|
3
3
|
|
|
4
4
|
const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
5
|
+
const MODEL_FORBIDDEN_PROVENANCE_FIELDS = ["hash", "compiler", "sourceHash", "compilerRevision", "compiledAt", "source_hash_verified"] as const;
|
|
5
6
|
|
|
6
7
|
/** Current compiler protocol for ordinary source artifacts such as Knowledge Markdown. */
|
|
7
8
|
export const ORDINARY_ARTIFACT_COMPILER = "artifact-v1";
|
|
8
9
|
|
|
9
|
-
/**
|
|
10
|
+
/** Freshness fields that runtime-owned scope metadata may retain per artifact path. */
|
|
11
|
+
export interface ArtifactProvenance {
|
|
12
|
+
/** Retained value; absent means unavailable, malformed means fail closed. */
|
|
13
|
+
sourceHash?: unknown;
|
|
14
|
+
compilerRevision?: unknown;
|
|
15
|
+
compiledAt?: unknown;
|
|
16
|
+
/** Internal marker for an uninterpretable retained entry; every dependent capability fails closed. */
|
|
17
|
+
malformed?: true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ArtifactProvenanceRegistry = Record<string, ArtifactProvenance>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Model-visible artifact metadata retained per canonical source path.
|
|
24
|
+
*
|
|
25
|
+
* `hash`, `compiler`, and `compiled_at` remain accepted only as retired embedded
|
|
26
|
+
* provenance input from pre-0.7 state; they are consumed as compatibility evidence
|
|
27
|
+
* and stripped from model projection. New compilations keep semantic fields here and
|
|
28
|
+
* runtime-owned freshness in the scope `meta.json` provenance registry.
|
|
29
|
+
*/
|
|
10
30
|
export type ArtifactMetadata = JsonObject & {
|
|
11
31
|
description: string;
|
|
12
|
-
hash: string;
|
|
13
|
-
compiler: string;
|
|
14
|
-
compiled_at?: string;
|
|
15
32
|
compilation?: JsonObject;
|
|
16
33
|
kind?: string;
|
|
17
34
|
tags?: string[];
|
|
35
|
+
hash?: string;
|
|
36
|
+
compiler?: string;
|
|
37
|
+
compiled_at?: string;
|
|
18
38
|
};
|
|
19
39
|
|
|
20
40
|
/** Artifact source paths are the canonical registry keys. */
|
|
@@ -41,6 +61,12 @@ export interface ArtifactInvalidationRequest extends ArtifactSourceIdentity {
|
|
|
41
61
|
reason: ArtifactInvalidationReason;
|
|
42
62
|
}
|
|
43
63
|
|
|
64
|
+
/** Model-visible invalidation projection: paths and reasons only, never source identity. */
|
|
65
|
+
export interface ArtifactInvalidationNotice {
|
|
66
|
+
path: string;
|
|
67
|
+
reason: ArtifactInvalidationReason;
|
|
68
|
+
}
|
|
69
|
+
|
|
44
70
|
export interface ArtifactInvalidationPlan {
|
|
45
71
|
fresh: ArtifactSourceIdentity[];
|
|
46
72
|
requiresCompilation: ArtifactInvalidationRequest[];
|
|
@@ -67,6 +93,12 @@ export interface ArtifactCompilationUpdate {
|
|
|
67
93
|
output: ArtifactCompilerOutput;
|
|
68
94
|
}
|
|
69
95
|
|
|
96
|
+
/** One compilation split into its model-visible and runtime-owned halves. */
|
|
97
|
+
export interface CompiledArtifact {
|
|
98
|
+
semantic: ArtifactMetadata;
|
|
99
|
+
provenance: ArtifactProvenance;
|
|
100
|
+
}
|
|
101
|
+
|
|
70
102
|
export function hashArtifactSource(source: string | Uint8Array): string {
|
|
71
103
|
return `sha256:${createHash("sha256").update(source).digest("hex")}`;
|
|
72
104
|
}
|
|
@@ -83,15 +115,6 @@ export function validateArtifactMetadata(value: unknown, path = "<unknown>"): as
|
|
|
83
115
|
if (typeof value.description !== "string" || value.description.trim().length === 0) {
|
|
84
116
|
throw new Error(`Artifact metadata at ${path} must have a non-empty description`);
|
|
85
117
|
}
|
|
86
|
-
if (!isArtifactHash(value.hash)) {
|
|
87
|
-
throw new Error(`Artifact metadata at ${path} must have a sha256:<64 lowercase hex characters> hash`);
|
|
88
|
-
}
|
|
89
|
-
if (typeof value.compiler !== "string" || value.compiler.trim().length === 0) {
|
|
90
|
-
throw new Error(`Artifact metadata at ${path} must have a non-empty compiler revision`);
|
|
91
|
-
}
|
|
92
|
-
if (Object.hasOwn(value, "compiled_at") && typeof value.compiled_at !== "string") {
|
|
93
|
-
throw new Error(`Artifact metadata at ${path} compiled_at must be a string`);
|
|
94
|
-
}
|
|
95
118
|
if (Object.hasOwn(value, "compilation") && !isObject(value.compilation)) {
|
|
96
119
|
throw new Error(`Artifact metadata at ${path} compilation must be an object`);
|
|
97
120
|
}
|
|
@@ -104,6 +127,15 @@ export function validateArtifactMetadata(value: unknown, path = "<unknown>"): as
|
|
|
104
127
|
|| new Set(value.tags).size !== value.tags.length)) {
|
|
105
128
|
throw new Error(`Artifact metadata at ${path} tags must be unique non-empty trimmed strings`);
|
|
106
129
|
}
|
|
130
|
+
if (Object.hasOwn(value, "hash") && !isArtifactHash(value.hash)) {
|
|
131
|
+
throw new Error(`Artifact metadata at ${path} must have a sha256:<64 lowercase hex characters> hash`);
|
|
132
|
+
}
|
|
133
|
+
if (Object.hasOwn(value, "compiler") && (typeof value.compiler !== "string" || value.compiler.trim().length === 0)) {
|
|
134
|
+
throw new Error(`Artifact metadata at ${path} must have a non-empty compiler revision`);
|
|
135
|
+
}
|
|
136
|
+
if (Object.hasOwn(value, "compiled_at") && typeof value.compiled_at !== "string") {
|
|
137
|
+
throw new Error(`Artifact metadata at ${path} compiled_at must be a string`);
|
|
138
|
+
}
|
|
107
139
|
}
|
|
108
140
|
|
|
109
141
|
export function isArtifactMetadata(value: unknown): value is ArtifactMetadata {
|
|
@@ -176,29 +208,102 @@ function refreshRequested(path: string, explicitRefresh: boolean | ReadonlySet<s
|
|
|
176
208
|
return explicitRefresh.has(path);
|
|
177
209
|
}
|
|
178
210
|
|
|
179
|
-
|
|
211
|
+
function isProvenanceEntry(value: unknown): value is ArtifactProvenance {
|
|
212
|
+
return isObject(value) && (value.malformed === undefined || value.malformed === true);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Parse one scope `meta.json` provenance registry; missing input means no recorded evidence. */
|
|
216
|
+
export function parseArtifactProvenanceRegistry(value: unknown, context = "Artifact provenance"): ArtifactProvenanceRegistry {
|
|
217
|
+
if (value === undefined) return {};
|
|
218
|
+
if (!isObject(value)) throw new Error(`${context} must be a path-keyed JSON object`);
|
|
219
|
+
const registry: ArtifactProvenanceRegistry = {};
|
|
220
|
+
for (const [path, entry] of Object.entries(value)) {
|
|
221
|
+
if (path.trim().length === 0) throw new Error(`${context} path keys must be non-empty`);
|
|
222
|
+
if (!isObject(entry) || !isJsonValue(entry)) {
|
|
223
|
+
registry[path] = { malformed: true };
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const known = new Set(["sourceHash", "compilerRevision", "compiledAt"]);
|
|
227
|
+
if (Object.keys(entry).some((key) => !known.has(key))) {
|
|
228
|
+
registry[path] = { malformed: true };
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
registry[path] = {
|
|
232
|
+
...(Object.hasOwn(entry, "sourceHash") ? { sourceHash: entry.sourceHash } : {}),
|
|
233
|
+
...(Object.hasOwn(entry, "compilerRevision") ? { compilerRevision: entry.compilerRevision } : {}),
|
|
234
|
+
...(Object.hasOwn(entry, "compiledAt") ? { compiledAt: entry.compiledAt } : {}),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return registry;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Canonical retained form; uninterpretable entries cannot round-trip and are omitted. */
|
|
241
|
+
export function serializeArtifactProvenanceRegistry(registry: Readonly<ArtifactProvenanceRegistry>): JsonObject {
|
|
242
|
+
const artifacts: JsonObject = {};
|
|
243
|
+
for (const [path, entry] of Object.entries(registry)) {
|
|
244
|
+
if (entry.malformed === true) continue;
|
|
245
|
+
const fields: JsonObject = {};
|
|
246
|
+
if (entry.sourceHash !== undefined) fields.sourceHash = entry.sourceHash as JsonValue;
|
|
247
|
+
if (entry.compilerRevision !== undefined) fields.compilerRevision = entry.compilerRevision as JsonValue;
|
|
248
|
+
if (entry.compiledAt !== undefined) fields.compiledAt = entry.compiledAt as JsonValue;
|
|
249
|
+
if (Object.keys(fields).length === 0) continue;
|
|
250
|
+
artifacts[path] = fields;
|
|
251
|
+
}
|
|
252
|
+
return artifacts;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function invalidField(value: unknown, validate: (candidate: unknown) => boolean): boolean {
|
|
256
|
+
return value !== undefined && !validate(value);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const INVALID_EVIDENCE = Symbol("invalid-evidence");
|
|
260
|
+
|
|
261
|
+
/** Later authority wins per field; absent runtime evidence falls back to retired embedded values. */
|
|
262
|
+
function fieldEvidence(entry: ArtifactProvenance | undefined, field: "sourceHash" | "compilerRevision" | "compiledAt", legacy: unknown): unknown {
|
|
263
|
+
if (entry?.malformed === true) return INVALID_EVIDENCE;
|
|
264
|
+
if (entry !== undefined && Object.hasOwn(entry, field)) return entry[field];
|
|
265
|
+
return legacy;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Classify freshness from semantic state and runtime provenance without acquiring the source body. */
|
|
180
269
|
export function classifyArtifactFreshness(
|
|
181
270
|
source: ArtifactSourceIdentity,
|
|
182
271
|
metadata: unknown,
|
|
183
272
|
compiler: string,
|
|
184
273
|
explicitRefresh = false,
|
|
274
|
+
provenance?: unknown,
|
|
185
275
|
): ArtifactFreshness {
|
|
186
276
|
validateSourceIdentity(source);
|
|
187
277
|
validateCompilerRevision(compiler);
|
|
188
278
|
if (metadata === undefined) return { kind: "requires-compilation", reason: "new" };
|
|
189
279
|
if (!isArtifactMetadata(metadata)) return { kind: "requires-compilation", reason: "invalid-metadata" };
|
|
190
|
-
|
|
191
|
-
|
|
280
|
+
const entry = isProvenanceEntry(provenance) ? provenance : undefined;
|
|
281
|
+
const runtime = entry?.malformed === true;
|
|
282
|
+
const sourceHash = runtime ? INVALID_EVIDENCE : fieldEvidence(entry, "sourceHash", metadata.hash);
|
|
283
|
+
if (sourceHash === INVALID_EVIDENCE || invalidField(sourceHash, (value) => isArtifactHash(value))) {
|
|
284
|
+
return { kind: "requires-compilation", reason: "invalid-metadata" };
|
|
285
|
+
}
|
|
286
|
+
if (typeof sourceHash === "string" && sourceHash !== source.hash) {
|
|
287
|
+
return { kind: "requires-compilation", reason: "source-changed" };
|
|
288
|
+
}
|
|
289
|
+
const compilerRevision = runtime ? INVALID_EVIDENCE : fieldEvidence(entry, "compilerRevision", metadata.compiler);
|
|
290
|
+
if (compilerRevision === INVALID_EVIDENCE || invalidField(compilerRevision, (value) => typeof value === "string" && value.trim().length > 0)) {
|
|
291
|
+
return { kind: "requires-compilation", reason: "invalid-metadata" };
|
|
292
|
+
}
|
|
293
|
+
if (typeof compilerRevision === "string" && compilerRevision !== compiler) {
|
|
294
|
+
return { kind: "requires-compilation", reason: "compiler-changed" };
|
|
295
|
+
}
|
|
192
296
|
if (explicitRefresh) return { kind: "requires-compilation", reason: "explicit-refresh" };
|
|
193
297
|
return { kind: "fresh" };
|
|
194
298
|
}
|
|
195
299
|
|
|
196
|
-
/** Produce a deterministic acquisition plan from path/hash candidates
|
|
300
|
+
/** Produce a deterministic acquisition plan from path/hash candidates and retained evidence. */
|
|
197
301
|
export function planArtifactInvalidation(
|
|
198
302
|
sources: readonly ArtifactSourceIdentity[],
|
|
199
303
|
registry: Readonly<Record<string, unknown>>,
|
|
200
304
|
compiler: string,
|
|
201
305
|
options: ArtifactInvalidationOptions = {},
|
|
306
|
+
provenance: Readonly<ArtifactProvenanceRegistry> = {},
|
|
202
307
|
): ArtifactInvalidationPlan {
|
|
203
308
|
if (!isObject(registry)) throw new Error("Artifacts must be a path-keyed JSON object");
|
|
204
309
|
validateCompilerRevision(compiler);
|
|
@@ -210,11 +315,13 @@ export function planArtifactInvalidation(
|
|
|
210
315
|
if (seen.has(source.path)) throw new Error(`Duplicate artifact source path: ${source.path}`);
|
|
211
316
|
seen.add(source.path);
|
|
212
317
|
const metadata = Object.hasOwn(registry, source.path) ? registry[source.path] : undefined;
|
|
318
|
+
const entry = Object.hasOwn(provenance, source.path) ? provenance[source.path] : undefined;
|
|
213
319
|
const freshness = classifyArtifactFreshness(
|
|
214
320
|
source,
|
|
215
321
|
metadata,
|
|
216
322
|
compiler,
|
|
217
323
|
refreshRequested(source.path, options.explicitRefresh),
|
|
324
|
+
entry,
|
|
218
325
|
);
|
|
219
326
|
const identity = { path: source.path, hash: source.hash };
|
|
220
327
|
if (freshness.kind === "fresh") fresh.push(identity);
|
|
@@ -224,22 +331,52 @@ export function planArtifactInvalidation(
|
|
|
224
331
|
return { fresh, requiresCompilation, removed };
|
|
225
332
|
}
|
|
226
333
|
|
|
227
|
-
|
|
334
|
+
/** Split one compiler output into model-visible semantics and runtime-owned provenance. */
|
|
335
|
+
export function compileArtifact(update: ArtifactCompilationUpdate): CompiledArtifact {
|
|
228
336
|
validateSourceIdentity(update.source);
|
|
229
337
|
validateCompilerRevision(update.compiler, update.source.path);
|
|
230
|
-
if (!isObject(update.output) ||
|
|
231
|
-
throw new Error(`Artifact compiler output at ${update.source.path} cannot set runtime-owned
|
|
338
|
+
if (!isObject(update.output) || MODEL_FORBIDDEN_PROVENANCE_FIELDS.some((field) => Object.hasOwn(update.output, field))) {
|
|
339
|
+
throw new Error(`Artifact compiler output at ${update.source.path} cannot set runtime-owned provenance fields`);
|
|
232
340
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
341
|
+
// Timestamps are runtime evidence; the model-visible entry never retains them.
|
|
342
|
+
const semantic = structuredClone(update.output) as ArtifactMetadata;
|
|
343
|
+
delete semantic.compiled_at;
|
|
344
|
+
validateArtifactMetadata(semantic, update.source.path);
|
|
345
|
+
return {
|
|
346
|
+
semantic,
|
|
347
|
+
provenance: {
|
|
348
|
+
sourceHash: update.source.hash,
|
|
349
|
+
compilerRevision: update.compiler,
|
|
350
|
+
...(typeof update.output.compiled_at === "string" ? { compiledAt: update.output.compiled_at } : {}),
|
|
351
|
+
},
|
|
237
352
|
};
|
|
238
|
-
validateArtifactMetadata(metadata, update.source.path);
|
|
239
|
-
return metadata;
|
|
240
353
|
}
|
|
241
354
|
|
|
242
|
-
/**
|
|
355
|
+
/** Merge one compilation/removal cohort into the runtime-owned provenance registry. */
|
|
356
|
+
export function updateArtifactProvenance(
|
|
357
|
+
registry: Readonly<ArtifactProvenanceRegistry>,
|
|
358
|
+
updates: readonly ArtifactCompilationUpdate[],
|
|
359
|
+
removed: readonly string[] = [],
|
|
360
|
+
): ArtifactProvenanceRegistry {
|
|
361
|
+
const next = structuredClone(registry) as ArtifactProvenanceRegistry;
|
|
362
|
+
for (const update of updates) next[update.source.path] = compileArtifact(update).provenance;
|
|
363
|
+
for (const path of removed) delete next[path];
|
|
364
|
+
return next;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Keep only provenance whose artifact path still exists in the given semantic registry. */
|
|
368
|
+
export function pruneArtifactProvenance(
|
|
369
|
+
registry: Readonly<ArtifactProvenanceRegistry>,
|
|
370
|
+
semantic: Readonly<Record<string, unknown>>,
|
|
371
|
+
): ArtifactProvenanceRegistry {
|
|
372
|
+
const next: ArtifactProvenanceRegistry = {};
|
|
373
|
+
for (const [path, entry] of Object.entries(registry)) {
|
|
374
|
+
if (Object.hasOwn(semantic, path)) next[path] = structuredClone(entry);
|
|
375
|
+
}
|
|
376
|
+
return next;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Validate and apply a whole compilation/removal cohort of model-visible artifacts. */
|
|
243
380
|
export function updateArtifactRegistry(
|
|
244
381
|
registry: ArtifactRegistry,
|
|
245
382
|
updates: readonly ArtifactCompilationUpdate[],
|
|
@@ -249,7 +386,7 @@ export function updateArtifactRegistry(
|
|
|
249
386
|
const compiled = new Map<string, ArtifactMetadata>();
|
|
250
387
|
for (const update of updates) {
|
|
251
388
|
if (compiled.has(update.source.path)) throw new Error(`Duplicate artifact compilation: ${update.source.path}`);
|
|
252
|
-
compiled.set(update.source.path,
|
|
389
|
+
compiled.set(update.source.path, compileArtifact(update).semantic);
|
|
253
390
|
}
|
|
254
391
|
const removals = new Set<string>();
|
|
255
392
|
for (const path of removed) {
|
|
@@ -271,3 +408,28 @@ export function updateArtifactRegistry(
|
|
|
271
408
|
}
|
|
272
409
|
return next;
|
|
273
410
|
}
|
|
411
|
+
|
|
412
|
+
/** Runtime-owned artifact fields that never belong in ordinary model context. */
|
|
413
|
+
const RUNTIME_ARTIFACT_FIELDS = [...MODEL_FORBIDDEN_PROVENANCE_FIELDS, "compiled_at"] as const;
|
|
414
|
+
|
|
415
|
+
/** Strip retained runtime bookkeeping from one model-visible artifact entry. */
|
|
416
|
+
export function projectArtifactForModel(entry: unknown): unknown {
|
|
417
|
+
if (!isObject(entry)) return entry;
|
|
418
|
+
const projected = structuredClone(entry);
|
|
419
|
+
for (const field of RUNTIME_ARTIFACT_FIELDS) delete projected[field];
|
|
420
|
+
return projected;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Strip retained runtime bookkeeping from a model-visible artifact registry. */
|
|
424
|
+
export function projectArtifactsForModel(registry: Readonly<ArtifactRegistry>): ArtifactRegistry {
|
|
425
|
+
const projected: ArtifactRegistry = {};
|
|
426
|
+
for (const [path, entry] of Object.entries(registry)) {
|
|
427
|
+
Object.defineProperty(projected, path, {
|
|
428
|
+
value: projectArtifactForModel(entry),
|
|
429
|
+
enumerable: true,
|
|
430
|
+
configurable: true,
|
|
431
|
+
writable: true,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
return projected;
|
|
435
|
+
}
|
|
@@ -9,6 +9,8 @@ import { isObject } from "./json.ts";
|
|
|
9
9
|
export interface StateFlowConfig {
|
|
10
10
|
directory: string;
|
|
11
11
|
autoStart: boolean;
|
|
12
|
+
/** Opt-in local capture of rejected patch attempts and unresolved terminal drafts. */
|
|
13
|
+
logging: boolean;
|
|
12
14
|
remotePublication?: "off" | "turn-end" | "transition";
|
|
13
15
|
}
|
|
14
16
|
|
|
@@ -18,6 +20,7 @@ export function loadStateFlowConfig(agentDir = getAgentDir()): StateFlowConfig {
|
|
|
18
20
|
const defaults: StateFlowConfig = {
|
|
19
21
|
directory: getDurableRepositoryRoot(agentDir),
|
|
20
22
|
autoStart: false,
|
|
23
|
+
logging: false,
|
|
21
24
|
};
|
|
22
25
|
let value: unknown;
|
|
23
26
|
try {
|
|
@@ -26,11 +29,12 @@ export function loadStateFlowConfig(agentDir = getAgentDir()): StateFlowConfig {
|
|
|
26
29
|
} catch (error) {
|
|
27
30
|
throw new Error(`Cannot read State Flow configuration: ${path}`, { cause: error });
|
|
28
31
|
}
|
|
29
|
-
const allowed = new Set(["directory", "autoStart", "remotePublication"]);
|
|
32
|
+
const allowed = new Set(["directory", "autoStart", "logging", "remotePublication"]);
|
|
30
33
|
if (!isObject(value) || Object.keys(value).some((key) => !allowed.has(key))) {
|
|
31
34
|
throw new Error(`State Flow configuration contains unknown settings: ${path}`);
|
|
32
35
|
}
|
|
33
36
|
if (Object.hasOwn(value, "autoStart") && typeof value.autoStart !== "boolean") throw new Error(`State Flow autoStart must be a boolean: ${path}`);
|
|
37
|
+
if (Object.hasOwn(value, "logging") && typeof value.logging !== "boolean") throw new Error(`State Flow logging must be a boolean: ${path}`);
|
|
34
38
|
if (Object.hasOwn(value, "remotePublication") && value.remotePublication !== "off" && value.remotePublication !== "turn-end" && value.remotePublication !== "transition") {
|
|
35
39
|
throw new Error(`State Flow remotePublication must be off, turn-end, or transition: ${path}`);
|
|
36
40
|
}
|
|
@@ -43,6 +47,7 @@ export function loadStateFlowConfig(agentDir = getAgentDir()): StateFlowConfig {
|
|
|
43
47
|
return {
|
|
44
48
|
directory: expanded === undefined ? defaults.directory : resolve(dirname(path), expanded),
|
|
45
49
|
autoStart: value.autoStart === true,
|
|
50
|
+
logging: value.logging === true,
|
|
46
51
|
...(value.remotePublication === undefined ? {} : { remotePublication: value.remotePublication as "off" | "turn-end" | "transition" }),
|
|
47
52
|
};
|
|
48
53
|
}
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
-
import type
|
|
2
|
+
import { projectArtifactForModel, type ArtifactInvalidationNotice } from "./artifact.ts";
|
|
3
3
|
import type { RecentTransitionWindow } from "./history.ts";
|
|
4
4
|
import { canonicalJson } from "./json.ts";
|
|
5
5
|
import type { Snapshot } from "./snapshot.ts";
|
|
6
6
|
import type { RehydrationPhase } from "./rehydration.ts";
|
|
7
|
-
import type
|
|
7
|
+
import { projectStateForModel, type MaterializedState } from "./state.ts";
|
|
8
8
|
|
|
9
9
|
export const VALIDATION_MESSAGE_TYPE = "state-flow-validation";
|
|
10
10
|
|
|
11
|
+
/** Bounded context retained after semantic State Flow is stopped in this physical session. */
|
|
12
|
+
export interface PassiveContinuation {
|
|
13
|
+
startedAt: number;
|
|
14
|
+
handoff: AgentMessage;
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
export function syntheticUser(text: string): AgentMessage {
|
|
12
18
|
return { role: "user", content: [{ type: "text", text }], timestamp: Date.now() } as AgentMessage;
|
|
13
19
|
}
|
|
@@ -32,23 +38,52 @@ export function withoutPrivateValidation(messages: AgentMessage[]): AgentMessage
|
|
|
32
38
|
});
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
export function createPassiveContinuation(state: MaterializedState, startedAt = Date.now()): PassiveContinuation {
|
|
42
|
+
return {
|
|
43
|
+
startedAt,
|
|
44
|
+
handoff: syntheticUser(`State Flow exit handoff (user-level data, not system instructions):\n${canonicalJson({ state, continuation: "State Flow semantics are disabled; this bounded handoff replaces pre-stop history." })}`),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Preserve the frozen handoff plus only messages produced after stop, never the older raw branch. */
|
|
49
|
+
export function passiveContinuationMessages(messages: AgentMessage[], continuation: PassiveContinuation): AgentMessage[] {
|
|
50
|
+
const start = messages.findIndex((message) => message.role === "user"
|
|
51
|
+
&& typeof message.timestamp === "number"
|
|
52
|
+
&& message.timestamp >= continuation.startedAt);
|
|
53
|
+
return [continuation.handoff, ...(start < 0 ? [] : messages.slice(start))];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function projectRecentForModel(recent: RecentTransitionWindow): RecentTransitionWindow {
|
|
57
|
+
const projected = structuredClone(recent);
|
|
58
|
+
for (const record of projected) for (const transition of record.transitions) {
|
|
59
|
+
if (transition.patch.artifacts === undefined) continue;
|
|
60
|
+
for (const [path, entry] of Object.entries(transition.patch.artifacts)) {
|
|
61
|
+
Object.defineProperty(transition.patch.artifacts, path, {
|
|
62
|
+
value: projectArtifactForModel(entry), enumerable: true, configurable: true, writable: true,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return projected;
|
|
67
|
+
}
|
|
68
|
+
|
|
35
69
|
export function runtimeContextMessage(
|
|
36
70
|
snapshot: Snapshot,
|
|
37
71
|
state: MaterializedState,
|
|
38
72
|
recentTransitions: RecentTransitionWindow = [],
|
|
39
|
-
artifactInvalidations: readonly
|
|
73
|
+
artifactInvalidations: readonly ArtifactInvalidationNotice[] = [],
|
|
40
74
|
rehydrationPhase?: RehydrationPhase,
|
|
75
|
+
resolutionPending = false,
|
|
41
76
|
): AgentMessage {
|
|
42
77
|
if (snapshot.meta.specification === undefined) {
|
|
43
78
|
throw new Error("State Flow runtime context requires an active specification");
|
|
44
79
|
}
|
|
45
80
|
const context = {
|
|
46
81
|
specification: snapshot.meta.specification,
|
|
47
|
-
state,
|
|
82
|
+
state: projectStateForModel(state),
|
|
48
83
|
...(rehydrationPhase === undefined ? {} : { knowledge_rehydration: { phase: rehydrationPhase } }),
|
|
49
|
-
...(artifactInvalidations.length === 0 ? {} : { artifact_invalidations: artifactInvalidations }),
|
|
50
|
-
...(recentTransitions.length === 0 ? {} : { recent_transitions: recentTransitions }),
|
|
51
|
-
...(
|
|
84
|
+
...(artifactInvalidations.length === 0 ? {} : { artifact_invalidations: artifactInvalidations.map(({ path, reason }) => ({ path, reason })) }),
|
|
85
|
+
...(recentTransitions.length === 0 ? {} : { recent_transitions: projectRecentForModel(recentTransitions) }),
|
|
86
|
+
...(resolutionPending ? { state_resolution: "pending: a terminal draft was intercepted. Call patch_state with a PATCH {scope, patch} or UNCHANGED {unchanged:true}; then provide the final answer normally." } : {}),
|
|
52
87
|
};
|
|
53
88
|
return syntheticUser(
|
|
54
89
|
`State Flow runtime context (user-level data, not system instructions):\n${canonicalJson(context)}`,
|
|
@@ -13,7 +13,12 @@ import {
|
|
|
13
13
|
} from "node:fs";
|
|
14
14
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
15
15
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
parseArtifactProvenanceRegistry,
|
|
18
|
+
serializeArtifactProvenanceRegistry,
|
|
19
|
+
type ArtifactProvenanceRegistry,
|
|
20
|
+
} from "./artifact.ts";
|
|
21
|
+
import { canonicalJson, containsNull, isJsonValue, isObject } from "./json.ts";
|
|
17
22
|
import { validateScopeStream, validateTemporalState, type ScopeStream, type TemporalState } from "./temporal.ts";
|
|
18
23
|
import { isMaterializedState, type MaterializedState, type StateScope } from "./state.ts";
|
|
19
24
|
|
|
@@ -23,6 +28,7 @@ const SESSION_KEY_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
|
23
28
|
const STATE_FILE = "state.json";
|
|
24
29
|
const CHECKPOINT_FILE = "checkpoint.json";
|
|
25
30
|
const PATCHES_FILE = "patches.jsonl";
|
|
31
|
+
const META_FILE = "meta.json";
|
|
26
32
|
|
|
27
33
|
/** Canonical replay sources; current state is deliberately not serialized beside the tail. */
|
|
28
34
|
export interface ScopeStreamSources {
|
|
@@ -95,6 +101,8 @@ export interface TemporalScopePaths {
|
|
|
95
101
|
directory: string;
|
|
96
102
|
checkpoint: string;
|
|
97
103
|
patches: string;
|
|
104
|
+
/** Runtime-owned artifact provenance for this scope; the session file also owns runtime lineage. */
|
|
105
|
+
meta: string;
|
|
98
106
|
}
|
|
99
107
|
|
|
100
108
|
export function temporalScopePaths(cwd: string, sessionId: string, scope: StateScope, repositoryRoot: string, sessionKey = sessionId): TemporalScopePaths {
|
|
@@ -103,12 +111,12 @@ export function temporalScopePaths(cwd: string, sessionId: string, scope: StateS
|
|
|
103
111
|
: scope === "session" ? sessionScopePaths(cwd, sessionId, repositoryRoot, sessionKey).directory
|
|
104
112
|
: undefined;
|
|
105
113
|
if (directory === undefined) throw new Error("Unknown temporal scope");
|
|
106
|
-
return { directory, checkpoint: join(directory, CHECKPOINT_FILE), patches: join(directory, PATCHES_FILE) };
|
|
114
|
+
return { directory, checkpoint: join(directory, CHECKPOINT_FILE), patches: join(directory, PATCHES_FILE), meta: join(directory, META_FILE) };
|
|
107
115
|
}
|
|
108
116
|
|
|
109
117
|
export function sessionRuntimePaths(cwd: string, sessionId: string, repositoryRoot: string, sessionKey = sessionId): { config: string; meta: string } {
|
|
110
118
|
const directory = sessionScopePaths(cwd, sessionId, repositoryRoot, sessionKey).directory;
|
|
111
|
-
return { config: join(directory, "config.json"), meta: join(directory,
|
|
119
|
+
return { config: join(directory, "config.json"), meta: join(directory, META_FILE) };
|
|
112
120
|
}
|
|
113
121
|
|
|
114
122
|
/** A temporal reader never treats a legacy current snapshot as an anchored checkpoint. */
|
|
@@ -125,7 +133,7 @@ export function captureTemporalFileBases(cwd: string, sessionId: string, reposit
|
|
|
125
133
|
const paths = (["global", "cwd", "session"] as const).flatMap((scope) => {
|
|
126
134
|
const pair = temporalScopePaths(cwd, sessionId, scope, repositoryRoot, sessionKey);
|
|
127
135
|
const runtime = scope === "session" ? sessionRuntimePaths(cwd, sessionId, repositoryRoot, sessionKey) : undefined;
|
|
128
|
-
return [pair.checkpoint, pair.patches, join(pair.directory, STATE_FILE), ...(runtime === undefined ? [] : [runtime.config, runtime.meta])];
|
|
136
|
+
return [pair.checkpoint, pair.patches, join(pair.directory, STATE_FILE), ...(runtime === undefined ? [pair.meta] : [runtime.config, runtime.meta])];
|
|
129
137
|
});
|
|
130
138
|
return captureOwnedFileBases(paths, repositoryRoot);
|
|
131
139
|
}
|
|
@@ -159,6 +167,27 @@ export interface DurablePaths {
|
|
|
159
167
|
globalPatches: string;
|
|
160
168
|
}
|
|
161
169
|
|
|
170
|
+
/** One scope-level provenance document; versioned for lenient forward evolution. */
|
|
171
|
+
export function serializeScopeProvenance(registry: Readonly<ArtifactProvenanceRegistry>): string {
|
|
172
|
+
return `${canonicalJson({ version: 1, artifacts: serializeArtifactProvenanceRegistry(registry) })}\n`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Missing provenance is unavailable evidence, never corrupt state. */
|
|
176
|
+
export function parseScopeProvenance(source: string | undefined, path: string): ArtifactProvenanceRegistry {
|
|
177
|
+
if (source === undefined) return {};
|
|
178
|
+
let value: unknown;
|
|
179
|
+
try {
|
|
180
|
+
value = JSON.parse(source);
|
|
181
|
+
} catch {
|
|
182
|
+
throw new Error(`State Flow provenance file contains invalid JSON: ${path}`);
|
|
183
|
+
}
|
|
184
|
+
if (!isObject(value) || value.version !== 1 || !Object.hasOwn(value, "artifacts")
|
|
185
|
+
|| Object.keys(value).some((key) => key !== "version" && key !== "artifacts")) {
|
|
186
|
+
throw new Error(`Invalid State Flow provenance document: ${path}`);
|
|
187
|
+
}
|
|
188
|
+
return parseArtifactProvenanceRegistry(value.artifacts, `State Flow provenance at ${path}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
162
191
|
export interface ScopePaths {
|
|
163
192
|
directory: string;
|
|
164
193
|
state: string;
|
|
@@ -260,7 +289,7 @@ export function legacyTemporalScopePaths(cwd: string, sessionId: string, scope:
|
|
|
260
289
|
const root = resolve(repositoryRoot);
|
|
261
290
|
const cwdDirectory = join(root, legacyCwdScopeKey(cwd));
|
|
262
291
|
const directory = scope === "global" ? root : scope === "cwd" ? cwdDirectory : join(cwdDirectory, legacySessionScopeKey(sessionId));
|
|
263
|
-
return { directory, checkpoint: join(directory, CHECKPOINT_FILE), patches: join(directory, PATCHES_FILE) };
|
|
292
|
+
return { directory, checkpoint: join(directory, CHECKPOINT_FILE), patches: join(directory, PATCHES_FILE), meta: join(directory, META_FILE) };
|
|
264
293
|
}
|
|
265
294
|
|
|
266
295
|
export function legacySessionRuntimePaths(cwd: string, sessionId: string, repositoryRoot: string): { config: string; meta: string } {
|
|
@@ -290,20 +319,21 @@ export function isStateFlowOwnedPath(candidate: string, repositoryRoot = getDura
|
|
|
290
319
|
const root = resolve(repositoryRoot);
|
|
291
320
|
const absolute = resolve(candidate);
|
|
292
321
|
const global = durablePaths(root);
|
|
293
|
-
if (absolute === global.globalState || absolute === global.globalPatches
|
|
322
|
+
if (absolute === global.globalState || absolute === global.globalPatches
|
|
323
|
+
|| absolute === join(root, CHECKPOINT_FILE) || absolute === join(root, META_FILE)) return true;
|
|
294
324
|
const segments = relative(root, absolute).split(sep);
|
|
295
325
|
const cwdKey = (value: string) => (value.startsWith("--") && value.endsWith("--")) || LEGACY_SCOPE_KEY_PATTERN.test(value);
|
|
296
326
|
const sessionKey = (value: string) => {
|
|
297
327
|
try { return sessionScopeKey(value) === value; } catch { return false; }
|
|
298
328
|
};
|
|
299
329
|
if (segments.length === 2) {
|
|
300
|
-
return cwdKey(segments[0]!) && (segments[1] === STATE_FILE || segments[1] === CHECKPOINT_FILE || segments[1] === PATCHES_FILE);
|
|
330
|
+
return cwdKey(segments[0]!) && (segments[1] === STATE_FILE || segments[1] === CHECKPOINT_FILE || segments[1] === PATCHES_FILE || segments[1] === META_FILE);
|
|
301
331
|
}
|
|
302
332
|
if (segments.length === 3) {
|
|
303
333
|
return cwdKey(segments[0]!)
|
|
304
334
|
&& (sessionKey(segments[1]!) || LEGACY_SCOPE_KEY_PATTERN.test(segments[1]!))
|
|
305
335
|
&& (segments[2] === STATE_FILE || segments[2] === CHECKPOINT_FILE || segments[2] === PATCHES_FILE
|
|
306
|
-
|| segments[2] === "config.json" || segments[2] ===
|
|
336
|
+
|| segments[2] === "config.json" || segments[2] === META_FILE);
|
|
307
337
|
}
|
|
308
338
|
return false;
|
|
309
339
|
}
|
|
@@ -22,20 +22,8 @@ export function stopEpisode(snapshot: Snapshot): Snapshot {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/** Apply one user-run boundary while preserving checkpoint-owned runtime state. */
|
|
25
|
-
export function prepareRun(snapshot: Snapshot, prompt: string
|
|
26
|
-
if (snapshot.meta.specification === undefined) {
|
|
27
|
-
snapshot.meta.specification = prompt;
|
|
28
|
-
return true;
|
|
29
|
-
}
|
|
30
|
-
if (isRetry) return false;
|
|
25
|
+
export function prepareRun(snapshot: Snapshot, prompt: string): boolean {
|
|
31
26
|
snapshot.meta.specification = prompt;
|
|
32
27
|
snapshot.meta.validation = undefined;
|
|
33
28
|
return true;
|
|
34
29
|
}
|
|
35
|
-
|
|
36
|
-
/** Clear only transient validation metadata; never disable or reset the episode. */
|
|
37
|
-
export function abandonValidation(snapshot: Snapshot): boolean {
|
|
38
|
-
if (snapshot.meta.validation === undefined) return false;
|
|
39
|
-
snapshot.meta.validation = undefined;
|
|
40
|
-
return true;
|
|
41
|
-
}
|