@llblab/pi-kit 0.18.2 → 0.19.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.
Files changed (113) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +3 -1
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +35 -37
  4. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +5 -3
  5. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +19 -1
  6. package/node_modules/@llblab/pi-state-flow/README.md +102 -48
  7. package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +6 -9
  8. package/node_modules/@llblab/pi-state-flow/dist/index.js +6 -9
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.d.ts +2 -2
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.js +21 -8
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.d.ts +40 -24
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.js +93 -63
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.d.ts +4 -3
  14. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.js +9 -9
  15. package/node_modules/@llblab/pi-state-flow/dist/lib/config.d.ts +1 -1
  16. package/node_modules/@llblab/pi-state-flow/dist/lib/config.js +6 -5
  17. package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +8 -7
  18. package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +56 -58
  19. package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.js +12 -24
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +7 -18
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +47 -78
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.d.ts +4 -6
  23. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +241 -437
  24. package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +2 -72
  25. package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +120 -499
  26. package/node_modules/@llblab/pi-state-flow/dist/lib/history.d.ts +2 -1
  27. package/node_modules/@llblab/pi-state-flow/dist/lib/history.js +4 -3
  28. package/node_modules/@llblab/pi-state-flow/dist/lib/json.d.ts +3 -0
  29. package/node_modules/@llblab/pi-state-flow/dist/lib/json.js +43 -22
  30. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.d.ts +1 -5
  31. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.js +0 -2
  32. package/node_modules/@llblab/pi-state-flow/dist/lib/memory.d.ts +1 -14
  33. package/node_modules/@llblab/pi-state-flow/dist/lib/memory.js +5 -37
  34. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +1 -2
  35. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +13 -32
  36. package/node_modules/@llblab/pi-state-flow/dist/lib/query.d.ts +6 -6
  37. package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +25 -21
  38. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.d.ts +3 -3
  39. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.js +15 -12
  40. package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.d.ts +2 -4
  41. package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.js +5 -1
  42. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +26 -88
  43. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +159 -255
  44. package/node_modules/@llblab/pi-state-flow/dist/lib/skills.d.ts +0 -3
  45. package/node_modules/@llblab/pi-state-flow/dist/lib/skills.js +1 -47
  46. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +16 -33
  47. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +48 -137
  48. package/node_modules/@llblab/pi-state-flow/dist/lib/state.d.ts +16 -11
  49. package/node_modules/@llblab/pi-state-flow/dist/lib/state.js +13 -14
  50. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +1 -9
  51. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +15 -43
  52. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.d.ts +5 -9
  53. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.js +13 -45
  54. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +1 -1
  55. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.d.ts +15 -7
  56. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.js +104 -24
  57. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.d.ts +0 -2
  58. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +11 -14
  59. package/node_modules/@llblab/pi-state-flow/dist/package.json +9 -6
  60. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +11 -17
  61. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +8 -8
  62. package/node_modules/@llblab/pi-state-flow/docs/README.md +2 -2
  63. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +64 -67
  64. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +24 -6
  65. package/node_modules/@llblab/pi-state-flow/docs/filesystem-recovery.md +12 -15
  66. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +22 -27
  67. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +25 -41
  68. package/node_modules/@llblab/pi-state-flow/docs/performance.md +38 -421
  69. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +33 -46
  70. package/node_modules/@llblab/pi-state-flow/docs/usage.md +37 -61
  71. package/node_modules/@llblab/pi-state-flow/index.ts +7 -71
  72. package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +26 -11
  73. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +116 -88
  74. package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +12 -10
  75. package/node_modules/@llblab/pi-state-flow/lib/config.ts +6 -6
  76. package/node_modules/@llblab/pi-state-flow/lib/context.ts +52 -59
  77. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +11 -20
  78. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +44 -86
  79. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +237 -460
  80. package/node_modules/@llblab/pi-state-flow/lib/git.ts +110 -552
  81. package/node_modules/@llblab/pi-state-flow/lib/history.ts +4 -3
  82. package/node_modules/@llblab/pi-state-flow/lib/json.ts +39 -23
  83. package/node_modules/@llblab/pi-state-flow/lib/logging.ts +1 -7
  84. package/node_modules/@llblab/pi-state-flow/lib/memory.ts +5 -44
  85. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +14 -25
  86. package/node_modules/@llblab/pi-state-flow/lib/query.ts +26 -22
  87. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +17 -11
  88. package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +7 -5
  89. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +154 -254
  90. package/node_modules/@llblab/pi-state-flow/lib/skills.ts +1 -49
  91. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +58 -142
  92. package/node_modules/@llblab/pi-state-flow/lib/state.ts +27 -19
  93. package/node_modules/@llblab/pi-state-flow/lib/status.ts +16 -54
  94. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +12 -40
  95. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +1 -1
  96. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +104 -22
  97. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +16 -26
  98. package/node_modules/@llblab/pi-state-flow/package.json +9 -6
  99. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +11 -17
  100. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +8 -8
  101. package/package.json +2 -2
  102. package/node_modules/@llblab/pi-state-flow/dist/lib/discovery.d.ts +0 -21
  103. package/node_modules/@llblab/pi-state-flow/dist/lib/discovery.js +0 -125
  104. package/node_modules/@llblab/pi-state-flow/dist/lib/maintenance.d.ts +0 -36
  105. package/node_modules/@llblab/pi-state-flow/dist/lib/maintenance.js +0 -98
  106. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.d.ts +0 -13
  107. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.js +0 -167
  108. package/node_modules/@llblab/pi-state-flow/dist/lib/publication.d.ts +0 -86
  109. package/node_modules/@llblab/pi-state-flow/dist/lib/publication.js +0 -437
  110. package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +0 -133
  111. package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +0 -147
  112. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +0 -171
  113. package/node_modules/@llblab/pi-state-flow/lib/publication.ts +0 -458
@@ -1,8 +1,11 @@
1
1
  import {
2
- classifyArtifactFreshness,
3
- type ArtifactInvalidationReason,
4
- type ArtifactInvalidationRequest,
5
- type ArtifactSourceIdentity,
2
+ classifyArtifactCompilationNeed,
3
+ inspectRegisteredArtifactPaths,
4
+ sameArtifactSourceFingerprint,
5
+ type ArtifactInvalidationReason,
6
+ type ArtifactInvalidationRequest,
7
+ type ArtifactSourceFingerprint,
8
+ type ArtifactSourceIdentity,
6
9
  } from "./artifact.ts";
7
10
  import { isObject } from "./json.ts";
8
11
 
@@ -35,7 +38,7 @@ export interface ArtifactAcquisitionOptions {
35
38
  /** Caller-assessed semantic sufficiency; only relevant to a concrete relevant gap. */
36
39
  materializedSufficient?: boolean;
37
40
  explicitRefresh?: boolean;
38
- /** Runtime-owned freshness evidence retained beside the semantic artifact. */
41
+ /** Runtime-owned compilation evidence retained beside the semantic artifact. */
39
42
  provenance?: unknown;
40
43
  }
41
44
 
@@ -45,6 +48,7 @@ export interface SuccessfulArtifactRead extends ArtifactInvalidationRequest {}
45
48
  interface PendingRead {
46
49
  toolName: string;
47
50
  args: unknown;
51
+ fingerprint?: ArtifactSourceFingerprint;
48
52
  }
49
53
 
50
54
  function readPath(toolName: unknown, args: unknown): string | undefined {
@@ -84,7 +88,12 @@ export class ArtifactReadTracker {
84
88
  const path = readPath(pending.toolName, pending.args);
85
89
  if (path === undefined) return;
86
90
  const candidate = this.#candidates.get(path);
87
- if (candidate !== undefined) this.successful.set(path, structuredClone(candidate));
91
+ if (candidate === undefined || pending.fingerprint === undefined) return;
92
+ const observation = inspectRegisteredArtifactPaths([path])[0];
93
+ if (observation?.kind !== "present" || !sameArtifactSourceFingerprint(pending.fingerprint, observation.fingerprint)) return;
94
+ const finalObservation = inspectRegisteredArtifactPaths([path])[0];
95
+ if (finalObservation?.kind !== "present" || !sameArtifactSourceFingerprint(observation.fingerprint, finalObservation.fingerprint)) return;
96
+ this.successful.set(path, structuredClone({ ...candidate, sourceFingerprint: finalObservation.fingerprint }));
88
97
  }
89
98
 
90
99
  #record(toolCallId: string, toolName: string, args: unknown): void {
@@ -92,14 +101,20 @@ export class ArtifactReadTracker {
92
101
  this.#pending.delete(toolCallId);
93
102
  return;
94
103
  }
95
- this.#pending.set(toolCallId, { toolName, args });
104
+ const path = readPath(toolName, args);
105
+ const observation = path !== undefined && this.#candidates.has(path) ? inspectRegisteredArtifactPaths([path])[0] : undefined;
106
+ this.#pending.set(toolCallId, {
107
+ toolName,
108
+ args,
109
+ ...(observation?.kind === "present" ? { fingerprint: observation.fingerprint } : {}),
110
+ });
96
111
  }
97
112
  }
98
113
 
99
114
  /**
100
115
  * Apply one materialized-first source acquisition policy.
101
116
  *
102
- * Freshness invalidation always wins. Otherwise routine use and a new session
117
+ * Required recompilation always wins. Otherwise routine use and a new session
103
118
  * stay on materialized state; only a concrete source need permits rereading.
104
119
  */
105
120
  export function decideArtifactAcquisition(
@@ -108,15 +123,15 @@ export function decideArtifactAcquisition(
108
123
  compiler: string,
109
124
  options: ArtifactAcquisitionOptions,
110
125
  ): ArtifactAcquisitionDecision {
111
- const freshness = classifyArtifactFreshness(
126
+ const need = classifyArtifactCompilationNeed(
112
127
  source,
113
128
  metadata,
114
129
  compiler,
115
130
  options.explicitRefresh ?? false,
116
131
  options.provenance,
117
132
  );
118
- if (freshness.kind === "requires-compilation") {
119
- return { kind: "read-source", reason: freshness.reason };
133
+ if (need.kind === "requires-compilation") {
134
+ return { kind: "read-source", reason: need.reason };
120
135
  }
121
136
 
122
137
  switch (options.intent) {
@@ -1,16 +1,76 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { lstatSync } from "node:fs";
3
+ import { isAbsolute, resolve } from "node:path";
2
4
  import { containsNull, isJsonValue, isObject, type JsonObject, type JsonValue } from "./json.ts";
3
5
 
4
6
  const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/;
5
- const MODEL_FORBIDDEN_PROVENANCE_FIELDS = ["hash", "compiler", "sourceHash", "compilerRevision", "compiledAt", "source_hash_verified"] as const;
7
+ const MODEL_FORBIDDEN_PROVENANCE_FIELDS = ["hash", "compiler", "sourceHash", "sourceFingerprint", "compilerRevision", "compiledAt", "source_hash_verified"] as const;
6
8
 
7
- /** Current compiler protocol for ordinary source artifacts such as Knowledge Markdown. */
9
+ export interface ArtifactSourceFingerprint {
10
+ size: number;
11
+ mtimeNs: string;
12
+ }
13
+
14
+ export function parseArtifactSourceFingerprint(value: unknown): ArtifactSourceFingerprint | undefined {
15
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
16
+ const candidate = value as Record<string, unknown>;
17
+ if (Object.keys(candidate).sort().join(",") !== "mtimeNs,size"
18
+ || !Number.isSafeInteger(candidate.size) || (candidate.size as number) < 0
19
+ || typeof candidate.mtimeNs !== "string" || !/^-?\d+$/.test(candidate.mtimeNs)) return undefined;
20
+ return { size: candidate.size as number, mtimeNs: candidate.mtimeNs };
21
+ }
22
+
23
+ export function sameArtifactSourceFingerprint(left: ArtifactSourceFingerprint, right: ArtifactSourceFingerprint): boolean {
24
+ return left.size === right.size && left.mtimeNs === right.mtimeNs;
25
+ }
26
+
27
+ export type ArtifactSourceObservation =
28
+ | { path: string; kind: "present"; fingerprint: ArtifactSourceFingerprint }
29
+ | { path: string; kind: "missing" }
30
+ | { path: string; kind: "unavailable"; reason: string };
31
+
32
+ function artifactSourceErrorCode(error: unknown): string | undefined {
33
+ return error instanceof Error && "code" in error
34
+ ? (error as NodeJS.ErrnoException).code
35
+ : undefined;
36
+ }
37
+
38
+ /** Inspect only exact registered artifact paths without reading source bodies or traversing directories. */
39
+ export function inspectRegisteredArtifactPaths(paths: Iterable<string>): ArtifactSourceObservation[] {
40
+ return [...new Set(paths)].sort().map((path) => {
41
+ if (!isAbsolute(path) || resolve(path) !== path) {
42
+ return { path, kind: "unavailable", reason: "artifact path is not canonical and absolute" };
43
+ }
44
+ let metadata;
45
+ try {
46
+ metadata = lstatSync(path, { bigint: true });
47
+ } catch (error) {
48
+ const code = artifactSourceErrorCode(error);
49
+ if (code === "ENOENT" || code === "ENOTDIR") return { path, kind: "missing" };
50
+ return { path, kind: "unavailable", reason: error instanceof Error ? error.message : String(error) };
51
+ }
52
+ if (!metadata.isFile() || metadata.isSymbolicLink()) {
53
+ return { path, kind: "unavailable", reason: "artifact source is not a regular non-symlink file" };
54
+ }
55
+ if (metadata.size > BigInt(Number.MAX_SAFE_INTEGER)) {
56
+ return { path, kind: "unavailable", reason: "artifact source size exceeds the supported range" };
57
+ }
58
+ return {
59
+ path,
60
+ kind: "present",
61
+ fingerprint: { size: Number(metadata.size), mtimeNs: metadata.mtimeNs.toString() },
62
+ };
63
+ });
64
+ }
65
+
66
+ /** Current compiler protocol for ordinary registered source artifacts. */
8
67
  export const ORDINARY_ARTIFACT_COMPILER = "artifact-v1";
9
68
 
10
- /** Freshness fields that runtime-owned scope metadata may retain per artifact path. */
69
+ /** Compilation evidence that runtime-owned scope metadata may retain per artifact path. */
11
70
  export interface ArtifactProvenance {
12
71
  /** Retained value; absent means unavailable, malformed means fail closed. */
13
72
  sourceHash?: unknown;
73
+ sourceFingerprint?: unknown;
14
74
  compilerRevision?: unknown;
15
75
  compiledAt?: unknown;
16
76
  /** Internal marker for an uninterpretable retained entry; every dependent capability fails closed. */
@@ -25,7 +85,7 @@ export type ArtifactProvenanceRegistry = Record<string, ArtifactProvenance>;
25
85
  * `hash`, `compiler`, and `compiled_at` remain accepted only as retired embedded
26
86
  * provenance input from pre-0.7 state; they are consumed as compatibility evidence
27
87
  * and stripped from model projection. New compilations keep semantic fields here and
28
- * runtime-owned freshness in the scope `meta.json` provenance registry.
88
+ * runtime-owned compilation evidence in the scope `meta.json` provenance registry.
29
89
  */
30
90
  export type ArtifactMetadata = JsonObject & {
31
91
  description: string;
@@ -40,10 +100,17 @@ export type ArtifactMetadata = JsonObject & {
40
100
  /** Artifact source paths are the canonical registry keys. */
41
101
  export type ArtifactRegistry = JsonObject & Record<string, ArtifactMetadata>;
42
102
 
43
- /** Source identity is sufficient for freshness decisions; it never contains the source body. */
103
+ /** Source identity is sufficient for compilation decisions; it never contains the source body. */
104
+ export type ArtifactScope = "global" | "cwd" | "session";
105
+
44
106
  export interface ArtifactSourceIdentity {
45
107
  path: string;
46
- hash: string;
108
+ /** Exact semantic owner selected from the effective global → CWD → session overlay. */
109
+ scope?: ArtifactScope;
110
+ /** Transitional legacy content identity; generic maintenance uses the filesystem fingerprint instead. */
111
+ hash?: string;
112
+ /** Stable filesystem evidence observed across the successful source read. */
113
+ sourceFingerprint?: ArtifactSourceFingerprint;
47
114
  }
48
115
 
49
116
  export type ArtifactInvalidationReason =
@@ -53,8 +120,8 @@ export type ArtifactInvalidationReason =
53
120
  | "invalid-metadata"
54
121
  | "explicit-refresh";
55
122
 
56
- export type ArtifactFreshness =
57
- | { kind: "fresh" }
123
+ export type ArtifactCompilationNeed =
124
+ | { kind: "current" }
58
125
  | { kind: "requires-compilation"; reason: ArtifactInvalidationReason };
59
126
 
60
127
  export interface ArtifactInvalidationRequest extends ArtifactSourceIdentity {
@@ -64,22 +131,10 @@ export interface ArtifactInvalidationRequest extends ArtifactSourceIdentity {
64
131
  /** Model-visible invalidation projection: paths and reasons only, never source identity. */
65
132
  export interface ArtifactInvalidationNotice {
66
133
  path: string;
134
+ scope?: ArtifactScope;
67
135
  reason: ArtifactInvalidationReason;
68
136
  }
69
137
 
70
- export interface ArtifactInvalidationPlan {
71
- fresh: ArtifactSourceIdentity[];
72
- requiresCompilation: ArtifactInvalidationRequest[];
73
- removed: string[];
74
- }
75
-
76
- export interface ArtifactInvalidationOptions {
77
- /** Refresh every source, or only paths in the supplied set. */
78
- explicitRefresh?: boolean | ReadonlySet<string>;
79
- /** Explicit absence evidence supplied by the source owner, not inferred from a partial candidate set. */
80
- removed?: readonly string[];
81
- }
82
-
83
138
  /** Trusted compilation input; embedded timestamps are accepted here, but never in model patches. */
84
139
  export type ArtifactCompilerOutput = JsonObject & {
85
140
  description: string;
@@ -191,9 +246,12 @@ function validateSourceIdentity(source: ArtifactSourceIdentity): void {
191
246
  if (typeof source.path !== "string" || source.path.trim().length === 0) {
192
247
  throw new Error("Artifact source path must be non-empty");
193
248
  }
194
- if (!isArtifactHash(source.hash)) {
249
+ if (source.hash !== undefined && !isArtifactHash(source.hash)) {
195
250
  throw new Error(`Artifact source at ${source.path} must have a sha256:<64 lowercase hex characters> hash`);
196
251
  }
252
+ if (source.sourceFingerprint !== undefined && parseArtifactSourceFingerprint(source.sourceFingerprint) === undefined) {
253
+ throw new Error(`Artifact source fingerprint at ${source.path} is invalid`);
254
+ }
197
255
  }
198
256
 
199
257
  function validateCompilerRevision(compiler: string, path?: string): void {
@@ -204,12 +262,6 @@ function validateCompilerRevision(compiler: string, path?: string): void {
204
262
  }
205
263
  }
206
264
 
207
- function refreshRequested(path: string, explicitRefresh: boolean | ReadonlySet<string> | undefined): boolean {
208
- if (explicitRefresh === true) return true;
209
- if (!explicitRefresh || typeof explicitRefresh !== "object" || typeof explicitRefresh.has !== "function") return false;
210
- return explicitRefresh.has(path);
211
- }
212
-
213
265
  function isProvenanceEntry(value: unknown): value is ArtifactProvenance {
214
266
  return isObject(value) && (value.malformed === undefined || value.malformed === true);
215
267
  }
@@ -225,13 +277,14 @@ export function parseArtifactProvenanceRegistry(value: unknown, context = "Artif
225
277
  registry[path] = { malformed: true };
226
278
  continue;
227
279
  }
228
- const known = new Set(["sourceHash", "compilerRevision", "compiledAt"]);
280
+ const known = new Set(["sourceHash", "sourceFingerprint", "compilerRevision", "compiledAt"]);
229
281
  if (Object.keys(entry).some((key) => !known.has(key))) {
230
282
  registry[path] = { malformed: true };
231
283
  continue;
232
284
  }
233
285
  registry[path] = {
234
286
  ...(Object.hasOwn(entry, "sourceHash") ? { sourceHash: entry.sourceHash } : {}),
287
+ ...(Object.hasOwn(entry, "sourceFingerprint") ? { sourceFingerprint: entry.sourceFingerprint } : {}),
235
288
  ...(Object.hasOwn(entry, "compilerRevision") ? { compilerRevision: entry.compilerRevision } : {}),
236
289
  ...(Object.hasOwn(entry, "compiledAt") ? { compiledAt: entry.compiledAt } : {}),
237
290
  };
@@ -246,6 +299,7 @@ export function serializeArtifactProvenanceRegistry(registry: Readonly<ArtifactP
246
299
  if (entry.malformed === true) continue;
247
300
  const fields: JsonObject = {};
248
301
  if (entry.sourceHash !== undefined) fields.sourceHash = entry.sourceHash as JsonValue;
302
+ if (entry.sourceFingerprint !== undefined) fields.sourceFingerprint = entry.sourceFingerprint as JsonValue;
249
303
  if (entry.compilerRevision !== undefined) fields.compilerRevision = entry.compilerRevision as JsonValue;
250
304
  if (entry.compiledAt !== undefined) fields.compiledAt = entry.compiledAt as JsonValue;
251
305
  if (Object.keys(fields).length === 0) continue;
@@ -258,84 +312,50 @@ function invalidField(value: unknown, validate: (candidate: unknown) => boolean)
258
312
  return value !== undefined && !validate(value);
259
313
  }
260
314
 
261
- const INVALID_EVIDENCE = Symbol("invalid-evidence");
262
-
263
315
  /** Later authority wins per field; absent runtime evidence falls back to retired embedded values. */
264
316
  function fieldEvidence(entry: ArtifactProvenance | undefined, field: "sourceHash" | "compilerRevision" | "compiledAt", legacy: unknown): unknown {
265
- if (entry?.malformed === true) return INVALID_EVIDENCE;
266
317
  if (entry !== undefined && Object.hasOwn(entry, field)) return entry[field];
267
318
  return legacy;
268
319
  }
269
320
 
270
- /** Classify freshness from semantic state and runtime provenance without acquiring the source body. */
271
- export function classifyArtifactFreshness(
321
+ /** Decide whether retained compilation evidence requires source reacquisition. */
322
+ export function classifyArtifactCompilationNeed(
272
323
  source: ArtifactSourceIdentity,
273
324
  metadata: unknown,
274
325
  compiler: string,
275
326
  explicitRefresh = false,
276
327
  provenance?: unknown,
277
- ): ArtifactFreshness {
328
+ ): ArtifactCompilationNeed {
278
329
  validateSourceIdentity(source);
279
330
  validateCompilerRevision(compiler);
280
331
  if (metadata === undefined) return { kind: "requires-compilation", reason: "new" };
281
332
  if (!isArtifactMetadata(metadata)) return { kind: "requires-compilation", reason: "invalid-metadata" };
282
333
  const entry = isProvenanceEntry(provenance) ? provenance : undefined;
283
- const runtime = entry?.malformed === true;
284
- const sourceHash = runtime ? INVALID_EVIDENCE : fieldEvidence(entry, "sourceHash", metadata.hash);
285
- if (sourceHash === INVALID_EVIDENCE || invalidField(sourceHash, (value) => isArtifactHash(value))) {
334
+ if ((provenance !== undefined && entry === undefined) || entry?.malformed === true) {
286
335
  return { kind: "requires-compilation", reason: "invalid-metadata" };
287
336
  }
288
- if (typeof sourceHash === "string" && sourceHash !== source.hash) {
289
- return { kind: "requires-compilation", reason: "source-changed" };
337
+ if (source.sourceFingerprint !== undefined) {
338
+ const retained = parseArtifactSourceFingerprint(entry?.sourceFingerprint);
339
+ if (retained === undefined) return { kind: "requires-compilation", reason: "invalid-metadata" };
340
+ if (!sameArtifactSourceFingerprint(source.sourceFingerprint, retained)) return { kind: "requires-compilation", reason: "source-changed" };
341
+ }
342
+ if (source.sourceFingerprint === undefined || source.hash !== undefined) {
343
+ // Explicit current-hash observations, including Skills, retain their identity contract.
344
+ const sourceHash = fieldEvidence(entry, "sourceHash", metadata.hash);
345
+ if (invalidField(sourceHash, isArtifactHash)) return { kind: "requires-compilation", reason: "invalid-metadata" };
346
+ if (source.hash !== undefined && typeof sourceHash === "string" && sourceHash !== source.hash) {
347
+ return { kind: "requires-compilation", reason: "source-changed" };
348
+ }
290
349
  }
291
- const compilerRevision = runtime ? INVALID_EVIDENCE : fieldEvidence(entry, "compilerRevision", metadata.compiler);
292
- if (compilerRevision === INVALID_EVIDENCE || invalidField(compilerRevision, (value) => typeof value === "string" && value.trim().length > 0)) {
350
+ const compilerRevision = fieldEvidence(entry, "compilerRevision", metadata.compiler);
351
+ if (invalidField(compilerRevision, (value) => typeof value === "string" && value.trim().length > 0)) {
293
352
  return { kind: "requires-compilation", reason: "invalid-metadata" };
294
353
  }
295
354
  if (typeof compilerRevision === "string" && compilerRevision !== compiler) {
296
355
  return { kind: "requires-compilation", reason: "compiler-changed" };
297
356
  }
298
357
  if (explicitRefresh) return { kind: "requires-compilation", reason: "explicit-refresh" };
299
- return { kind: "fresh" };
300
- }
301
-
302
- /** Produce a deterministic acquisition plan from path/hash candidates and retained evidence. */
303
- export function planArtifactInvalidation(
304
- sources: readonly ArtifactSourceIdentity[],
305
- registry: Readonly<Record<string, unknown>>,
306
- compiler: string,
307
- options: ArtifactInvalidationOptions = {},
308
- provenance: Readonly<ArtifactProvenanceRegistry> = {},
309
- ): ArtifactInvalidationPlan {
310
- if (!isObject(registry)) throw new Error("Artifacts must be a path-keyed JSON object");
311
- validateCompilerRevision(compiler);
312
- const ordered = [...sources].sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
313
- const seen = new Set<string>();
314
- const fresh: ArtifactSourceIdentity[] = [];
315
- const requiresCompilation: ArtifactInvalidationRequest[] = [];
316
- for (const source of ordered) {
317
- if (seen.has(source.path)) throw new Error(`Duplicate artifact source path: ${source.path}`);
318
- seen.add(source.path);
319
- const metadata = Object.hasOwn(registry, source.path) ? registry[source.path] : undefined;
320
- const entry = Object.hasOwn(provenance, source.path) ? provenance[source.path] : undefined;
321
- const freshness = classifyArtifactFreshness(
322
- source,
323
- metadata,
324
- compiler,
325
- refreshRequested(source.path, options.explicitRefresh),
326
- entry,
327
- );
328
- const identity = { path: source.path, hash: source.hash };
329
- if (freshness.kind === "fresh") fresh.push(identity);
330
- else requiresCompilation.push({ ...identity, reason: freshness.reason });
331
- }
332
- if (options.removed !== undefined && !Array.isArray(options.removed)) throw new Error("Removed artifact paths must be an array");
333
- const removed = [...new Set(options.removed ?? [])];
334
- for (const path of removed) {
335
- if (typeof path !== "string" || path.trim().length === 0) throw new Error("Removed artifact paths must be non-empty");
336
- if (seen.has(path)) throw new Error(`Artifact source cannot be both present and removed: ${path}`);
337
- }
338
- return { fresh, requiresCompilation, removed: removed.filter((path) => Object.hasOwn(registry, path)).sort() };
358
+ return { kind: "current" };
339
359
  }
340
360
 
341
361
  /** Split one compiler output into model-visible semantics and runtime-owned provenance. */
@@ -352,7 +372,8 @@ export function compileArtifact(update: ArtifactCompilationUpdate): CompiledArti
352
372
  return {
353
373
  semantic,
354
374
  provenance: {
355
- sourceHash: update.source.hash,
375
+ ...(update.source.hash === undefined ? {} : { sourceHash: update.source.hash }),
376
+ ...(update.source.sourceFingerprint === undefined ? {} : { sourceFingerprint: structuredClone(update.source.sourceFingerprint) }),
356
377
  compilerRevision: update.compiler,
357
378
  ...(typeof update.output.compiled_at === "string" ? { compiledAt: update.output.compiled_at } : {}),
358
379
  },
@@ -417,14 +438,14 @@ export function updateArtifactRegistry(
417
438
  }
418
439
 
419
440
  /** Runtime-owned artifact fields that never belong in ordinary model context. */
420
- const RUNTIME_ARTIFACT_FIELDS = [...MODEL_FORBIDDEN_PROVENANCE_FIELDS, "compiled_at"] as const;
441
+ const RUNTIME_ARTIFACT_FIELDS = [...MODEL_FORBIDDEN_PROVENANCE_FIELDS, "compiled_at", "hint"] as const;
421
442
 
422
443
  /** Validate authored fields only: legacy retained evidence stays readable but cannot be model-edited. */
423
444
  export function validateModelArtifactPatch(patch: JsonObject): void {
424
445
  for (const [path, entry] of Object.entries(patch)) {
425
446
  if (!isObject(entry)) continue; // Whole-artifact deletion and materialized shape belong to the transition owner.
426
447
  const field = RUNTIME_ARTIFACT_FIELDS.find((field) => Object.hasOwn(entry, field));
427
- if (field !== undefined) throw new Error(`Artifact patch at ${path} cannot set runtime-owned provenance field ${field}`);
448
+ if (field !== undefined) throw new Error(`Artifact patch at ${path} cannot set runtime-owned field ${field}`);
428
449
  }
429
450
  }
430
451
 
@@ -436,12 +457,19 @@ export function projectArtifactForModel(entry: unknown): unknown {
436
457
  return projected;
437
458
  }
438
459
 
439
- /** Strip retained runtime bookkeeping from a model-visible artifact registry. */
440
- export function projectArtifactsForModel(registry: Readonly<ArtifactRegistry>): ArtifactRegistry {
460
+ export type ArtifactModelHints = Readonly<Record<string, string | readonly string[]>>;
461
+
462
+ /** Strip retained runtime bookkeeping and add deterministic runtime-only guidance. */
463
+ export function projectArtifactsForModel(registry: Readonly<ArtifactRegistry>, hints: ArtifactModelHints = {}): ArtifactRegistry {
441
464
  const projected: ArtifactRegistry = {};
442
465
  for (const [path, entry] of Object.entries(registry)) {
466
+ const modelEntry = projectArtifactForModel(entry);
467
+ const values = (Array.isArray(hints[path]) ? hints[path] : [hints[path]])
468
+ .filter((value): value is string => typeof value === "string" && value.trim().length > 0);
469
+ const hint = [...new Set(values)].sort().join("\n");
470
+ if (hint.length > 0 && isObject(modelEntry)) modelEntry.hint = hint;
443
471
  Object.defineProperty(projected, path, {
444
- value: projectArtifactForModel(entry),
472
+ value: modelEntry,
445
473
  enumerable: true,
446
474
  configurable: true,
447
475
  writable: true,
@@ -1,14 +1,14 @@
1
1
  import { estimateTokens, type AgentMessage } from "@earendil-works/pi-agent-core";
2
2
  import type { CompactionResult } from "@earendil-works/pi-coding-agent";
3
3
 
4
- export const STATE_FLOW_COMPACTION_SUMMARY = "State Flow accepted the completed work before this boundary. Current memory is restored from its durable revision and projected separately; use the retained native entries for subsequent work.";
4
+ export const STATE_FLOW_COMPACTION_SUMMARY = "State Flow accepted the completed work before this boundary. Current memory is restored from its retained semantic boundary and projected separately; use the retained native entries for subsequent work.";
5
5
  /** A modest margin above Pi's default 20k retained suffix absorbs estimation drift. */
6
6
  export const STATE_FLOW_COMPACTION_MIN_CONTEXT_TOKENS = 24_000;
7
7
 
8
8
  export interface StateFlowCompactionDetails {
9
9
  version: 1;
10
10
  owner: "state-flow";
11
- revision: string;
11
+ boundary: string;
12
12
  step: number;
13
13
  }
14
14
 
@@ -22,7 +22,7 @@ type ActiveEntry = {
22
22
  id?: unknown;
23
23
  type?: unknown;
24
24
  customType?: unknown;
25
- message?: { role?: unknown; stopReason?: unknown; content?: unknown };
25
+ message?: { role?: unknown; stopReason?: unknown; content?: unknown; timestamp?: unknown };
26
26
  };
27
27
 
28
28
  function stateFlowEntry(entry: ActiveEntry): boolean {
@@ -55,23 +55,25 @@ export function hasCompactionSizedTranscript(entries: readonly ActiveEntry[]): b
55
55
  /** Retain the complete latest accepted user iteration without hiding foreign extension context. */
56
56
  export function planStateFlowCompaction(
57
57
  entries: readonly ActiveEntry[],
58
- revision: string,
58
+ boundary: string,
59
59
  step: number,
60
+ runAnchorTimestamp: number | undefined,
60
61
  ): StateFlowCompactionPlan | undefined {
61
- if (!/^[0-9a-f]{40,64}$/.test(revision) && !/^file:[0-9a-f]{64}$/.test(revision)) return undefined;
62
+ if (boundary.trim().length === 0 || typeof runAnchorTimestamp !== "number" || !Number.isFinite(runAnchorTimestamp)) return undefined;
62
63
  if (!Number.isSafeInteger(step) || step < 0 || entries.length === 0) return undefined;
63
64
  const terminal = entries.findLastIndex((entry) => entry.type === "message" && entry.message?.role === "assistant");
64
65
  if (terminal < 0 || entries[terminal]?.message?.stopReason === "aborted"
65
66
  || entries[terminal]?.message?.stopReason === "error"
66
67
  || entries[terminal]?.message?.stopReason === "length") return undefined;
67
- let keep = terminal;
68
- while (keep >= 0 && !(entries[keep]?.type === "message" && entries[keep]?.message?.role === "user")) keep--;
69
- if (keep < 0) return undefined;
70
- if (entries.slice(0, keep).some((entry) => entry.type === "custom" && !stateFlowEntry(entry))) return undefined;
68
+ // Steering is part of the same run; an absent or colliding timestamp cannot prove its first entry.
69
+ const isRunAnchor = (entry: ActiveEntry) => entry.type === "message" && entry.message?.role === "user" && entry.message.timestamp === runAnchorTimestamp;
70
+ const keep = entries.findIndex(isRunAnchor);
71
+ if (keep < 0 || keep > terminal || entries.findLastIndex(isRunAnchor) !== keep) return undefined;
72
+ if (entries.slice(0, keep).some((entry) => entry.type === "custom_message" || (entry.type === "custom" && !stateFlowEntry(entry)))) return undefined;
71
73
  const firstKeptEntryId = entries[keep]?.id;
72
74
  const leafId = entries.at(-1)?.id;
73
75
  if (typeof firstKeptEntryId !== "string" || typeof leafId !== "string") return undefined;
74
- return { leafId, firstKeptEntryId, details: { version: 1, owner: "state-flow", revision, step } };
76
+ return { leafId, firstKeptEntryId, details: { version: 1, owner: "state-flow", boundary, step } };
75
77
  }
76
78
 
77
79
  /** Customize only the extension-owned manual request and only while its planned leaf remains selected. */
@@ -3,6 +3,7 @@ import { lstatSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
5
  import { getDurableRepositoryRoot } from "./durable.ts";
6
+ import { DEFAULT_HISTORY_LIMIT, MAX_HISTORY_LIMIT } from "./history.ts";
6
7
  import { isObject } from "./json.ts";
7
8
 
8
9
  export interface StateFlowConfig {
@@ -15,7 +16,7 @@ export interface StateFlowConfig {
15
16
  logging: boolean;
16
17
  /** Show successful patch_state arguments in the interactive tool row. */
17
18
  showSuccessfulPatches: boolean;
18
- remotePublication?: "off" | "turn-end" | "transition";
19
+ historyLimit: number;
19
20
  }
20
21
 
21
22
  /** Read the repository-global config once at extension load/reload. Missing config uses defaults; invalid config never falls back. */
@@ -29,6 +30,7 @@ export function loadStateFlowConfig(agentDir = getAgentDir(), repositoryRoot = g
29
30
  passiveTools: true,
30
31
  logging: false,
31
32
  showSuccessfulPatches: true,
33
+ historyLimit: DEFAULT_HISTORY_LIMIT,
32
34
  };
33
35
  let value: unknown;
34
36
  try {
@@ -37,7 +39,7 @@ export function loadStateFlowConfig(agentDir = getAgentDir(), repositoryRoot = g
37
39
  } catch (error) {
38
40
  throw new Error(`Cannot read State Flow configuration: ${path}`, { cause: error });
39
41
  }
40
- const allowed = new Set(["autoStart", "passiveBootstrap", "passiveTools", "logging", "showSuccessfulPatches", "remotePublication"]);
42
+ const allowed = new Set(["autoStart", "passiveBootstrap", "passiveTools", "logging", "showSuccessfulPatches", "historyLimit"]);
41
43
  if (!isObject(value) || Object.keys(value).some((key) => !allowed.has(key))) {
42
44
  throw new Error(`State Flow configuration contains unknown settings: ${path}`);
43
45
  }
@@ -46,9 +48,7 @@ export function loadStateFlowConfig(agentDir = getAgentDir(), repositoryRoot = g
46
48
  if (Object.hasOwn(value, "passiveTools") && typeof value.passiveTools !== "boolean") throw new Error(`State Flow passiveTools must be a boolean: ${path}`);
47
49
  if (Object.hasOwn(value, "logging") && typeof value.logging !== "boolean") throw new Error(`State Flow logging must be a boolean: ${path}`);
48
50
  if (Object.hasOwn(value, "showSuccessfulPatches") && typeof value.showSuccessfulPatches !== "boolean") throw new Error(`State Flow showSuccessfulPatches must be a boolean: ${path}`);
49
- if (Object.hasOwn(value, "remotePublication") && value.remotePublication !== "off" && value.remotePublication !== "turn-end" && value.remotePublication !== "transition") {
50
- throw new Error(`State Flow remotePublication must be off, turn-end, or transition: ${path}`);
51
- }
51
+ if (Object.hasOwn(value, "historyLimit") && (!Number.isSafeInteger(value.historyLimit) || (value.historyLimit as number) < 0 || (value.historyLimit as number) > MAX_HISTORY_LIMIT)) throw new Error(`State Flow historyLimit must be an integer from 0 to ${MAX_HISTORY_LIMIT}: ${path}`);
52
52
  return {
53
53
  directory,
54
54
  autoStart: value.autoStart === true,
@@ -56,6 +56,6 @@ export function loadStateFlowConfig(agentDir = getAgentDir(), repositoryRoot = g
56
56
  passiveTools: value.passiveTools !== false,
57
57
  logging: value.logging === true,
58
58
  showSuccessfulPatches: value.showSuccessfulPatches !== false,
59
- ...(value.remotePublication === undefined ? {} : { remotePublication: value.remotePublication as "off" | "turn-end" | "transition" }),
59
+ historyLimit: typeof value.historyLimit === "number" ? value.historyLimit : DEFAULT_HISTORY_LIMIT,
60
60
  };
61
61
  }