@llblab/pi-kit 0.6.0 → 0.7.1

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 (129) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +4 -4
  3. package/node_modules/@llblab/pi-actors/AGENTS.md +1 -1
  4. package/node_modules/@llblab/pi-actors/CHANGELOG.md +6 -0
  5. package/node_modules/@llblab/pi-actors/README.md +1 -1
  6. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.d.ts +3 -0
  7. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.js +14 -1
  8. package/node_modules/@llblab/pi-actors/dist/lib/command-templates.js +45 -3
  9. package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +1 -1
  10. package/node_modules/@llblab/pi-actors/dist/lib/observability.d.ts +16 -3
  11. package/node_modules/@llblab/pi-actors/dist/lib/observability.js +92 -7
  12. package/node_modules/@llblab/pi-actors/dist/lib/pi.d.ts +0 -1
  13. package/node_modules/@llblab/pi-actors/dist/lib/pi.js +15 -24
  14. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.d.ts +17 -0
  15. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.js +44 -0
  16. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.d.ts +4 -0
  17. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.js +102 -4
  18. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +58 -39
  19. package/node_modules/@llblab/pi-actors/dist/lib/runtime.js +14 -6
  20. package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +1 -1
  21. package/node_modules/@llblab/pi-actors/docs/async-runs.md +1 -1
  22. package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +18 -23
  23. package/node_modules/@llblab/pi-actors/lib/async-runs.ts +18 -1
  24. package/node_modules/@llblab/pi-actors/lib/command-templates.ts +41 -3
  25. package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +1 -1
  26. package/node_modules/@llblab/pi-actors/lib/observability.ts +119 -5
  27. package/node_modules/@llblab/pi-actors/lib/pi.ts +15 -28
  28. package/node_modules/@llblab/pi-actors/lib/run-delivery-lineage.ts +68 -0
  29. package/node_modules/@llblab/pi-actors/lib/run-delivery.ts +120 -4
  30. package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +69 -44
  31. package/node_modules/@llblab/pi-actors/lib/runtime.ts +17 -6
  32. package/node_modules/@llblab/pi-actors/package.json +1 -1
  33. package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +1 -1
  34. package/node_modules/@llblab/pi-state-flow/AGENTS.md +33 -12
  35. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +125 -2
  36. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +87 -45
  37. package/node_modules/@llblab/pi-state-flow/README.md +203 -107
  38. package/node_modules/@llblab/pi-state-flow/docs/README.md +4 -0
  39. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +188 -0
  40. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +42 -0
  41. package/node_modules/@llblab/pi-state-flow/index.ts +164 -2
  42. package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +138 -0
  43. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +273 -0
  44. package/node_modules/@llblab/pi-state-flow/lib/config.ts +48 -0
  45. package/node_modules/@llblab/pi-state-flow/lib/context.ts +18 -5
  46. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +268 -0
  47. package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +117 -0
  48. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +562 -0
  49. package/node_modules/@llblab/pi-state-flow/lib/episode.ts +24 -12
  50. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +606 -70
  51. package/node_modules/@llblab/pi-state-flow/lib/git.ts +666 -0
  52. package/node_modules/@llblab/pi-state-flow/lib/history.ts +95 -0
  53. package/node_modules/@llblab/pi-state-flow/lib/json.ts +24 -0
  54. package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +141 -0
  55. package/node_modules/@llblab/pi-state-flow/lib/memory.ts +52 -0
  56. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +88 -0
  57. package/node_modules/@llblab/pi-state-flow/lib/publication.ts +296 -0
  58. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +23 -7
  59. package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +79 -0
  60. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +264 -0
  61. package/node_modules/@llblab/pi-state-flow/lib/session.ts +6 -0
  62. package/node_modules/@llblab/pi-state-flow/lib/skills.ts +99 -7
  63. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +305 -48
  64. package/node_modules/@llblab/pi-state-flow/lib/state.ts +74 -7
  65. package/node_modules/@llblab/pi-state-flow/lib/status.ts +125 -6
  66. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +196 -0
  67. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +233 -0
  68. package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +70 -24
  69. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +254 -29
  70. package/node_modules/@llblab/pi-state-flow/package.json +8 -2
  71. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +128 -0
  72. package/node_modules/@llblab/pi-telegram/AGENTS.md +14 -9
  73. package/node_modules/@llblab/pi-telegram/BACKLOG.md +23 -5
  74. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +18 -0
  75. package/node_modules/@llblab/pi-telegram/README.md +13 -9
  76. package/node_modules/@llblab/pi-telegram/docs/README.md +1 -0
  77. package/node_modules/@llblab/pi-telegram/docs/architecture.md +227 -23
  78. package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +1 -1
  79. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +70 -19
  80. package/node_modules/@llblab/pi-telegram/docs/outbound.md +6 -6
  81. package/node_modules/@llblab/pi-telegram/docs/public-api.md +13 -6
  82. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +3 -1
  83. package/node_modules/@llblab/pi-telegram/index.ts +4 -1418
  84. package/node_modules/@llblab/pi-telegram/lib/agent-messages.ts +6 -3
  85. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +46 -1
  86. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +32 -19
  87. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +600 -135
  88. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +962 -55
  89. package/node_modules/@llblab/pi-telegram/lib/bus.ts +355 -26
  90. package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +718 -0
  91. package/node_modules/@llblab/pi-telegram/lib/commands.ts +237 -11
  92. package/node_modules/@llblab/pi-telegram/lib/config.ts +242 -26
  93. package/node_modules/@llblab/pi-telegram/lib/extension.ts +1851 -0
  94. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +20 -2
  95. package/node_modules/@llblab/pi-telegram/lib/journal.ts +2184 -126
  96. package/node_modules/@llblab/pi-telegram/lib/locks.ts +44 -2
  97. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +152 -13
  98. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +97 -10
  99. package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -0
  100. package/node_modules/@llblab/pi-telegram/lib/polling.ts +85 -17
  101. package/node_modules/@llblab/pi-telegram/lib/preview.ts +17 -0
  102. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +6 -2
  103. package/node_modules/@llblab/pi-telegram/lib/queue.ts +118 -26
  104. package/node_modules/@llblab/pi-telegram/lib/rendering.ts +4 -1
  105. package/node_modules/@llblab/pi-telegram/lib/replies.ts +21 -2
  106. package/node_modules/@llblab/pi-telegram/lib/routing.ts +344 -112
  107. package/node_modules/@llblab/pi-telegram/lib/setup.ts +44 -4
  108. package/node_modules/@llblab/pi-telegram/lib/status.ts +51 -4
  109. package/node_modules/@llblab/pi-telegram/lib/sync.ts +308 -39
  110. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +353 -22
  111. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +664 -0
  112. package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +226 -0
  113. package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +118 -0
  114. package/node_modules/@llblab/pi-telegram/lib/threads.ts +1686 -129
  115. package/node_modules/@llblab/pi-telegram/lib/turns.ts +7 -0
  116. package/node_modules/@llblab/pi-telegram/lib/updates.ts +1319 -97
  117. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +1643 -0
  118. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +968 -0
  119. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +84 -0
  120. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  121. package/node_modules/@llblab/pi-telegram/screenshot.png +0 -0
  122. package/node_modules/@llblab/pi-telegram/scripts/measure-bus.mjs +83 -0
  123. package/node_modules/@llblab/pi-telegram/scripts/measure-workspace.mjs +101 -0
  124. package/node_modules/@llblab/{skills → pi-telegram/skills}/show-me/SKILL.md +28 -6
  125. package/node_modules/@llblab/pi-telegram/skills/show-me/references/telegram-surfaces.md +43 -0
  126. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/delivery-and-threads.md +1 -1
  127. package/node_modules/@llblab/skills/package.json +2 -3
  128. package/package.json +6 -5
  129. /package/node_modules/@llblab/pi-telegram/lib/{logs.ts → logging.ts} +0 -0
@@ -0,0 +1,117 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
2
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { hashArtifactSource, type ArtifactSourceIdentity } from "./artifact.ts";
5
+
6
+ /** Opaque source metadata discovered without decoding or retaining Markdown bodies. */
7
+ export interface ArtifactSourceCandidate extends ArtifactSourceIdentity {
8
+ /** Raw byte count used as a conservative maintenance token-budget ceiling. */
9
+ bytes: number;
10
+ }
11
+
12
+ export interface GlobalMarkdownDiscoveryResult {
13
+ sources: ArtifactSourceCandidate[];
14
+ removed: string[];
15
+ }
16
+
17
+ export function getKnowledgeRoot(agentDir = getAgentDir()): string {
18
+ return resolve(agentDir, "knowledge");
19
+ }
20
+
21
+ function errorCode(error: unknown): string | undefined {
22
+ return error instanceof Error && "code" in error
23
+ ? (error as NodeJS.ErrnoException).code
24
+ : undefined;
25
+ }
26
+
27
+ function isMissing(error: unknown): boolean {
28
+ return errorCode(error) === "ENOENT";
29
+ }
30
+
31
+ function isInside(root: string, path: string): boolean {
32
+ const child = relative(root, path);
33
+ return child === "" || (!isAbsolute(child) && child !== ".." && !child.startsWith(`..${sep}`));
34
+ }
35
+
36
+ function canonicalExistingPath(path: string): string | undefined {
37
+ try {
38
+ return realpathSync.native(path);
39
+ } catch (error) {
40
+ if (isMissing(error)) return undefined;
41
+ throw error;
42
+ }
43
+ }
44
+
45
+ function readRegularFile(path: string): Buffer | undefined {
46
+ let descriptor: number | undefined;
47
+ try {
48
+ descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
49
+ if (!fstatSync(descriptor).isFile()) return undefined;
50
+ return readFileSync(descriptor);
51
+ } catch (error) {
52
+ if (isMissing(error) || errorCode(error) === "ELOOP") return undefined;
53
+ throw error;
54
+ } finally {
55
+ if (descriptor !== undefined) closeSync(descriptor);
56
+ }
57
+ }
58
+
59
+ /** Discover regular `*.md` files beneath a Knowledge root without following directory or file symlinks. */
60
+ export function discoverGlobalMarkdownSources(knowledgeRoot = getKnowledgeRoot()): ArtifactSourceCandidate[] {
61
+ const configuredRoot = resolve(knowledgeRoot);
62
+ const root = canonicalExistingPath(configuredRoot);
63
+ if (root === undefined) return [];
64
+ if (!statSync(root).isDirectory()) throw new Error(`Knowledge root is not a directory: ${configuredRoot}`);
65
+
66
+ const sources: ArtifactSourceCandidate[] = [];
67
+ const visit = (directory: string): void => {
68
+ const entries = readdirSync(directory, { withFileTypes: true })
69
+ .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
70
+ for (const entry of entries) {
71
+ const candidate = join(directory, entry.name);
72
+ let metadata;
73
+ try {
74
+ metadata = lstatSync(candidate);
75
+ } catch (error) {
76
+ if (isMissing(error)) continue;
77
+ throw error;
78
+ }
79
+ if (metadata.isSymbolicLink()) continue;
80
+
81
+ const canonical = canonicalExistingPath(candidate);
82
+ if (canonical === undefined || !isInside(root, canonical)) continue;
83
+ if (metadata.isDirectory()) {
84
+ visit(canonical);
85
+ continue;
86
+ }
87
+ if (!metadata.isFile() || !entry.name.endsWith(".md")) continue;
88
+
89
+ const body = readRegularFile(canonical);
90
+ if (body === undefined) continue;
91
+ sources.push({ path: canonical, hash: hashArtifactSource(body), bytes: body.byteLength });
92
+ }
93
+ };
94
+
95
+ visit(root);
96
+ return sources.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
97
+ }
98
+
99
+ /** Session-lifetime index that makes source removals explicit on each initialization refresh. */
100
+ export class GlobalMarkdownDiscovery {
101
+ readonly knowledgeRoot: string;
102
+ #knownPaths = new Set<string>();
103
+
104
+ constructor(knowledgeRoot = getKnowledgeRoot()) {
105
+ this.knowledgeRoot = resolve(knowledgeRoot);
106
+ }
107
+
108
+ refresh(): GlobalMarkdownDiscoveryResult {
109
+ const sources = discoverGlobalMarkdownSources(this.knowledgeRoot);
110
+ const currentPaths = new Set(sources.map((source) => source.path));
111
+ const removed = [...this.#knownPaths]
112
+ .filter((path) => !currentPaths.has(path))
113
+ .sort();
114
+ this.#knownPaths = currentPaths;
115
+ return { sources, removed };
116
+ }
117
+ }
@@ -0,0 +1,562 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ closeSync,
4
+ constants,
5
+ fstatSync,
6
+ lstatSync,
7
+ mkdirSync,
8
+ openSync,
9
+ readFileSync,
10
+ renameSync,
11
+ rmSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
15
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
16
+ import { canonicalJson, containsNull, isJsonValue } from "./json.ts";
17
+ import { validateScopeStream, validateTemporalState, type ScopeStream, type TemporalState } from "./temporal.ts";
18
+ import { isMaterializedState, type MaterializedState, type StateScope } from "./state.ts";
19
+
20
+ const LEGACY_MAX_SCOPE_SLUG_LENGTH = 80;
21
+ const LEGACY_SCOPE_KEY_PATTERN = /^[A-Za-z0-9._-]{1,80}-[a-f0-9]{64}$/;
22
+ const SESSION_KEY_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
23
+ const STATE_FILE = "state.json";
24
+ const CHECKPOINT_FILE = "checkpoint.json";
25
+ const PATCHES_FILE = "patches.jsonl";
26
+
27
+ /** Canonical replay sources; current state is deliberately not serialized beside the tail. */
28
+ export interface ScopeStreamSources {
29
+ checkpoint: string;
30
+ patches: string;
31
+ }
32
+
33
+ export interface SessionAddress {
34
+ readonly id: string;
35
+ readonly key: string;
36
+ }
37
+
38
+ /** CWD storage requires its canonical owner; legacy ownerless sources are read-only. */
39
+ export function serializeScopeStream(stream: ScopeStream, scope: StateScope, cwdIdentity?: string): ScopeStreamSources {
40
+ validateScopeStream(stream, scope);
41
+ if (scope === "cwd" && cwdIdentity === undefined) throw new Error("State Flow CWD scope serialization requires its canonical identity");
42
+ if (scope !== "cwd" && cwdIdentity !== undefined) throw new Error("Only State Flow CWD scope serialization accepts a CWD identity");
43
+ const checkpoint = scope === "cwd"
44
+ ? { ...stream.checkpoint, owner: { cwd: resolve(cwdIdentity!) } }
45
+ : stream.checkpoint;
46
+ return {
47
+ checkpoint: `${canonicalJson(checkpoint)}\n`,
48
+ patches: stream.patches.map((record) => `${canonicalJson(record)}\n`).join(""),
49
+ };
50
+ }
51
+
52
+ /** Decode the entire bounded replay input before accepting any materialized state. */
53
+ export function parseScopeStream(
54
+ checkpointSource: string | undefined,
55
+ patchesSource: string | undefined,
56
+ scope: StateScope,
57
+ expectedCwd?: string,
58
+ ): ScopeStream | undefined {
59
+ if (checkpointSource === undefined && patchesSource === undefined) return undefined;
60
+ if (checkpointSource === undefined || patchesSource === undefined) {
61
+ throw new Error(`State Flow ${scope} scope has an incomplete checkpoint/tail pair`);
62
+ }
63
+ let checkpoint: unknown;
64
+ try {
65
+ checkpoint = JSON.parse(checkpointSource);
66
+ } catch {
67
+ throw new Error(`State Flow ${scope} checkpoint contains invalid JSON`);
68
+ }
69
+ if (checkpoint !== null && typeof checkpoint === "object" && !Array.isArray(checkpoint) && Object.hasOwn(checkpoint, "owner")) {
70
+ const { owner, ...semantic } = checkpoint as Record<string, unknown>;
71
+ if (scope !== "cwd" || owner === null || typeof owner !== "object" || Array.isArray(owner)
72
+ || Object.keys(owner).join(",") !== "cwd" || typeof (owner as { cwd?: unknown }).cwd !== "string") {
73
+ throw new Error("Invalid State Flow CWD scope identity");
74
+ }
75
+ if (expectedCwd !== undefined && (owner as { cwd: string }).cwd !== resolve(expectedCwd)) throw new Error("State Flow CWD scope identity mismatch");
76
+ checkpoint = semantic;
77
+ } else if (scope === "cwd" && expectedCwd !== undefined) {
78
+ throw new Error("State Flow CWD scope identity is missing");
79
+ }
80
+ const patches: unknown[] = [];
81
+ for (const [index, line] of patchesSource.split(/\r?\n/).entries()) {
82
+ if (line.trim().length === 0) continue;
83
+ try {
84
+ patches.push(JSON.parse(line));
85
+ } catch {
86
+ throw new Error(`State Flow ${scope} tail contains invalid JSON at line ${index + 1}`);
87
+ }
88
+ }
89
+ const stream = { checkpoint, patches };
90
+ validateScopeStream(stream, scope);
91
+ return stream;
92
+ }
93
+
94
+ export interface TemporalScopePaths {
95
+ directory: string;
96
+ checkpoint: string;
97
+ patches: string;
98
+ }
99
+
100
+ export function temporalScopePaths(cwd: string, sessionId: string, scope: StateScope, repositoryRoot: string, sessionKey = sessionId): TemporalScopePaths {
101
+ const directory = scope === "global" ? resolve(repositoryRoot)
102
+ : scope === "cwd" ? cwdScopePaths(cwd, repositoryRoot).directory
103
+ : scope === "session" ? sessionScopePaths(cwd, sessionId, repositoryRoot, sessionKey).directory
104
+ : undefined;
105
+ if (directory === undefined) throw new Error("Unknown temporal scope");
106
+ return { directory, checkpoint: join(directory, CHECKPOINT_FILE), patches: join(directory, PATCHES_FILE) };
107
+ }
108
+
109
+ export function sessionRuntimePaths(cwd: string, sessionId: string, repositoryRoot: string, sessionKey = sessionId): { config: string; meta: string } {
110
+ const directory = sessionScopePaths(cwd, sessionId, repositoryRoot, sessionKey).directory;
111
+ return { config: join(directory, "config.json"), meta: join(directory, "meta.json") };
112
+ }
113
+
114
+ /** A temporal reader never treats a legacy current snapshot as an anchored checkpoint. */
115
+ export function loadScopeStream(cwd: string, sessionId: string, scope: StateScope, repositoryRoot: string, sessionKey = sessionId): ScopeStream | undefined {
116
+ const paths = temporalScopePaths(cwd, sessionId, scope, repositoryRoot, sessionKey);
117
+ if (readRegularBytes(join(paths.directory, STATE_FILE), repositoryRoot) !== undefined) {
118
+ throw new Error(`Legacy State Flow storage requires explicit migration: ${paths.directory}`);
119
+ }
120
+ return parseScopeStream(readRegularFile(paths.checkpoint, repositoryRoot), readRegularFile(paths.patches, repositoryRoot), scope, scope === "cwd" ? cwd : undefined);
121
+ }
122
+
123
+ /** Include legacy names in the CAS basis solely to prevent format races during cutover. */
124
+ export function captureTemporalFileBases(cwd: string, sessionId: string, repositoryRoot: string, sessionKey = sessionId): DurableFileBase[] {
125
+ const paths = (["global", "cwd", "session"] as const).flatMap((scope) => {
126
+ const pair = temporalScopePaths(cwd, sessionId, scope, repositoryRoot, sessionKey);
127
+ 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])];
129
+ });
130
+ return captureOwnedFileBases(paths, repositoryRoot);
131
+ }
132
+
133
+ /** Select exact serialized scope updates from one validated active temporal cohort. */
134
+ export function temporalStateFileUpdates(
135
+ cwd: string,
136
+ sessionId: string,
137
+ view: TemporalState,
138
+ scopes: readonly StateScope[],
139
+ repositoryRoot: string,
140
+ sessionKey = sessionId,
141
+ ): OwnedFileUpdate[] {
142
+ validateTemporalState(view);
143
+ const seen = new Set<StateScope>();
144
+ return scopes.flatMap((scope) => {
145
+ if (seen.has(scope)) throw new Error(`Duplicate temporal scope update: ${scope}`);
146
+ seen.add(scope);
147
+ const paths = temporalScopePaths(cwd, sessionId, scope, repositoryRoot, sessionKey);
148
+ if (readRegularBytes(join(paths.directory, STATE_FILE), repositoryRoot) !== undefined) {
149
+ throw new Error(`Legacy State Flow storage requires explicit migration: ${paths.directory}`);
150
+ }
151
+ const sources = serializeScopeStream(view.scopes[scope], scope, scope === "cwd" ? cwd : undefined);
152
+ return [{ path: paths.checkpoint, content: sources.checkpoint }, { path: paths.patches, content: sources.patches }];
153
+ });
154
+ }
155
+
156
+ export interface DurablePaths {
157
+ repositoryRoot: string;
158
+ globalState: string;
159
+ globalPatches: string;
160
+ }
161
+
162
+ export interface ScopePaths {
163
+ directory: string;
164
+ state: string;
165
+ patches: string;
166
+ }
167
+
168
+ export interface DurableFileBase {
169
+ path: string;
170
+ identity: "missing" | `sha256:${string}`;
171
+ content?: string;
172
+ /** Opaque originals for byte-exact rollback, including non-UTF-8 legacy journals. */
173
+ bytes?: Uint8Array;
174
+ }
175
+
176
+ /** Dedicated runtime storage, independent from Markdown source discovery. */
177
+ export function getDurableRepositoryRoot(agentDir = getAgentDir()): string {
178
+ return resolve(agentDir, "state-flow");
179
+ }
180
+
181
+ /** Legacy snapshot paths retained only for one-way migration and exact ownership checks. */
182
+ export function durablePaths(repositoryRoot = getDurableRepositoryRoot()): DurablePaths {
183
+ const root = resolve(repositoryRoot);
184
+ return {
185
+ repositoryRoot: root,
186
+ globalState: join(root, STATE_FILE),
187
+ globalPatches: join(root, PATCHES_FILE),
188
+ };
189
+ }
190
+
191
+ function legacyReadableScopeKey(identity: string, readable: string): string {
192
+ if (identity.length === 0) throw new Error("State Flow scope identity must be non-empty");
193
+ const slug = readable.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, LEGACY_MAX_SCOPE_SLUG_LENGTH) || "scope";
194
+ return `${slug}-${createHash("sha256").update(identity).digest("hex")}`;
195
+ }
196
+
197
+ /** Match Pi's native project-session directory convention exactly. */
198
+ export function cwdScopeKey(cwd: string): string {
199
+ const canonical = resolve(cwd);
200
+ return `--${canonical.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
201
+ }
202
+
203
+ /** One safe directory segment, normally the native Pi session filename stem. */
204
+ export function sessionScopeKey(key: string): string {
205
+ if (!SESSION_KEY_PATTERN.test(key)) throw new Error("State Flow session storage key must be one Pi-safe path segment");
206
+ return key;
207
+ }
208
+
209
+ /** Prefer the actual native file stem; reproduce it from the immutable header when in-memory. */
210
+ export function sessionStorageKey(sessionFile: string | undefined, sessionId: string, timestamp?: string): string {
211
+ if (sessionFile !== undefined) {
212
+ const name = basename(sessionFile);
213
+ if (!name.endsWith(".jsonl")) throw new Error("State Flow session file must use Pi's .jsonl format");
214
+ return sessionScopeKey(name.slice(0, -".jsonl".length));
215
+ }
216
+ if (timestamp !== undefined) return sessionScopeKey(`${timestamp.replace(/[:.]/g, "-")}_${sessionId}`);
217
+ return sessionScopeKey(sessionId);
218
+ }
219
+
220
+ export function resolveSessionAddress(sessionFile: string | undefined, sessionId: string, timestamp?: string): SessionAddress {
221
+ if (sessionId.trim().length === 0 || sessionId !== sessionId.trim()) throw new Error("State Flow session identity must be non-empty and trimmed");
222
+ return Object.freeze({ id: sessionId, key: sessionStorageKey(sessionFile, sessionId, timestamp) });
223
+ }
224
+
225
+ /** Read-only migration input for the untagged hashed-layout draft. */
226
+ export function legacyCwdScopeKey(cwd: string): string {
227
+ const canonical = resolve(cwd);
228
+ return legacyReadableScopeKey(canonical, `--${canonical.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`);
229
+ }
230
+
231
+ export function legacySessionScopeKey(sessionId: string): string {
232
+ return legacyReadableScopeKey(sessionScopeKey(sessionId), sessionId);
233
+ }
234
+
235
+ export function cwdScopePaths(cwd: string, repositoryRoot = getDurableRepositoryRoot()): ScopePaths {
236
+ const directory = join(resolve(repositoryRoot), cwdScopeKey(cwd));
237
+ return { directory, state: join(directory, STATE_FILE), patches: join(directory, PATCHES_FILE) };
238
+ }
239
+
240
+ export function sessionScopePaths(
241
+ cwd: string,
242
+ sessionId: string,
243
+ repositoryRoot = getDurableRepositoryRoot(),
244
+ sessionKey = sessionId,
245
+ ): ScopePaths {
246
+ const directory = join(cwdScopePaths(cwd, repositoryRoot).directory, sessionScopeKey(sessionKey));
247
+ return { directory, state: join(directory, STATE_FILE), patches: join(directory, PATCHES_FILE) };
248
+ }
249
+
250
+ export function cwdStatePath(cwd: string, repositoryRoot = getDurableRepositoryRoot()): string {
251
+ return cwdScopePaths(cwd, repositoryRoot).state;
252
+ }
253
+
254
+ export function cwdPatchesPath(cwd: string, repositoryRoot = getDurableRepositoryRoot()): string {
255
+ return cwdScopePaths(cwd, repositoryRoot).patches;
256
+ }
257
+
258
+ /** Historical paths from the pre-0.4 hashed-layout draft; never selected for new writes. */
259
+ export function legacyTemporalScopePaths(cwd: string, sessionId: string, scope: StateScope, repositoryRoot: string): TemporalScopePaths {
260
+ const root = resolve(repositoryRoot);
261
+ const cwdDirectory = join(root, legacyCwdScopeKey(cwd));
262
+ 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) };
264
+ }
265
+
266
+ export function legacySessionRuntimePaths(cwd: string, sessionId: string, repositoryRoot: string): { config: string; meta: string } {
267
+ const directory = legacyTemporalScopePaths(cwd, sessionId, "session", repositoryRoot).directory;
268
+ return { config: join(directory, "config.json"), meta: join(directory, "meta.json") };
269
+ }
270
+
271
+ export function captureLegacyTemporalFileBases(cwd: string, sessionId: string, repositoryRoot: string): DurableFileBase[] {
272
+ const paths = (["global", "cwd", "session"] as const).flatMap((scope) => {
273
+ const pair = legacyTemporalScopePaths(cwd, sessionId, scope, repositoryRoot);
274
+ const runtime = scope === "session" ? legacySessionRuntimePaths(cwd, sessionId, repositoryRoot) : undefined;
275
+ return [pair.checkpoint, pair.patches, join(pair.directory, STATE_FILE), ...(runtime === undefined ? [] : [runtime.config, runtime.meta])];
276
+ });
277
+ return captureOwnedFileBases(paths, repositoryRoot);
278
+ }
279
+
280
+ export function sessionStatePath(cwd: string, sessionId: string, repositoryRoot = getDurableRepositoryRoot(), sessionKey = sessionId): string {
281
+ return sessionScopePaths(cwd, sessionId, repositoryRoot, sessionKey).state;
282
+ }
283
+
284
+ export function sessionPatchesPath(cwd: string, sessionId: string, repositoryRoot = getDurableRepositoryRoot(), sessionKey = sessionId): string {
285
+ return sessionScopePaths(cwd, sessionId, repositoryRoot, sessionKey).patches;
286
+ }
287
+
288
+ /** Exact semantic file shapes, including legacy snapshots only during the storage cutover. */
289
+ export function isStateFlowOwnedPath(candidate: string, repositoryRoot = getDurableRepositoryRoot()): boolean {
290
+ const root = resolve(repositoryRoot);
291
+ const absolute = resolve(candidate);
292
+ const global = durablePaths(root);
293
+ if (absolute === global.globalState || absolute === global.globalPatches || absolute === join(root, CHECKPOINT_FILE)) return true;
294
+ const segments = relative(root, absolute).split(sep);
295
+ const cwdKey = (value: string) => (value.startsWith("--") && value.endsWith("--")) || LEGACY_SCOPE_KEY_PATTERN.test(value);
296
+ const sessionKey = (value: string) => {
297
+ try { return sessionScopeKey(value) === value; } catch { return false; }
298
+ };
299
+ if (segments.length === 2) {
300
+ return cwdKey(segments[0]!) && (segments[1] === STATE_FILE || segments[1] === CHECKPOINT_FILE || segments[1] === PATCHES_FILE);
301
+ }
302
+ if (segments.length === 3) {
303
+ return cwdKey(segments[0]!)
304
+ && (sessionKey(segments[1]!) || LEGACY_SCOPE_KEY_PATTERN.test(segments[1]!))
305
+ && (segments[2] === STATE_FILE || segments[2] === CHECKPOINT_FILE || segments[2] === PATCHES_FILE
306
+ || segments[2] === "config.json" || segments[2] === "meta.json");
307
+ }
308
+ return false;
309
+ }
310
+
311
+ function missing(error: unknown): boolean {
312
+ return error instanceof Error
313
+ && "code" in error
314
+ && (error as NodeJS.ErrnoException).code === "ENOENT";
315
+ }
316
+
317
+ function assertWithinRepository(path: string, repositoryRoot: string): void {
318
+ const child = relative(repositoryRoot, path);
319
+ if (child === "" || child === ".." || child.startsWith(`..${sep}`)) {
320
+ throw new Error(`Durable State Flow path escapes its repository: ${path}`);
321
+ }
322
+ }
323
+
324
+ /** Reject symlinked directory components instead of following them during reads or writes. */
325
+ function assertDirectoryChain(repositoryRoot: string, directory: string, create: boolean): boolean {
326
+ const root = resolve(repositoryRoot);
327
+ const target = resolve(directory);
328
+ if (target !== root) assertWithinRepository(target, root);
329
+ const relativeDirectory = relative(root, target);
330
+ const directories = [root];
331
+ if (relativeDirectory.length > 0) {
332
+ let current = root;
333
+ for (const segment of relativeDirectory.split(sep)) {
334
+ current = join(current, segment);
335
+ directories.push(current);
336
+ }
337
+ }
338
+ for (const current of directories) {
339
+ try {
340
+ const metadata = lstatSync(current);
341
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
342
+ throw new Error(`Durable State Flow directory is not a regular directory: ${current}`);
343
+ }
344
+ } catch (error) {
345
+ if (!missing(error)) throw error;
346
+ if (!create) return false;
347
+ mkdirSync(current);
348
+ }
349
+ }
350
+ return true;
351
+ }
352
+
353
+ function readRegularBytes(path: string, repositoryRoot: string): Buffer | undefined {
354
+ if (!assertDirectoryChain(repositoryRoot, dirname(path), false)) return undefined;
355
+ let descriptor: number | undefined;
356
+ try {
357
+ descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
358
+ if (!fstatSync(descriptor).isFile()) {
359
+ throw new Error(`Durable State Flow path is not a regular file: ${path}`);
360
+ }
361
+ return readFileSync(descriptor);
362
+ } catch (error) {
363
+ if (missing(error)) return undefined;
364
+ if (error instanceof Error
365
+ && "code" in error
366
+ && (error as NodeJS.ErrnoException).code === "ELOOP") {
367
+ throw new Error(`Durable State Flow path is not a regular file: ${path}`);
368
+ }
369
+ throw error;
370
+ } finally {
371
+ if (descriptor !== undefined) closeSync(descriptor);
372
+ }
373
+ }
374
+
375
+ function readRegularFile(path: string, repositoryRoot: string): string | undefined {
376
+ return readRegularBytes(path, repositoryRoot)?.toString("utf8");
377
+ }
378
+
379
+ function byteIdentity(bytes: Uint8Array | undefined): DurableFileBase["identity"] {
380
+ return bytes === undefined ? "missing" : `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
381
+ }
382
+
383
+ function fileBase(path: string, repositoryRoot: string): DurableFileBase {
384
+ const bytes = readRegularBytes(path, repositoryRoot);
385
+ if (bytes === undefined) return { path, identity: "missing" };
386
+ return { path, identity: byteIdentity(bytes), content: bytes.toString("utf8"), bytes };
387
+ }
388
+
389
+ function assertCurrentBytes(path: string, root: string, expected: Uint8Array | undefined): void {
390
+ if (byteIdentity(readRegularBytes(path, root)) !== byteIdentity(expected)) {
391
+ throw new Error(`State Flow file conflict at ${path}; preserve concurrent bytes and reconcile before retrying`);
392
+ }
393
+ }
394
+
395
+ /** Capture exact owned bytes for one compare-and-swap publication or migration cohort. */
396
+ export function captureOwnedFileBases(paths: readonly string[], repositoryRoot: string): DurableFileBase[] {
397
+ const root = resolve(repositoryRoot);
398
+ return paths.map((path) => {
399
+ if (!isStateFlowOwnedPath(path, root)) throw new Error(`Cannot capture a non-State Flow path: ${path}`);
400
+ return fileBase(resolve(path), root);
401
+ });
402
+ }
403
+
404
+ function validateState(value: unknown, path: string): asserts value is MaterializedState {
405
+ if (!isMaterializedState(value) || !isJsonValue(value) || containsNull(value)) {
406
+ throw new Error(`Durable State Flow file has invalid materialized state: ${path}`);
407
+ }
408
+ }
409
+
410
+ /** One-way legacy current-state interpretation; explanatory journals are not recovery input. */
411
+ export function parseStateSource(source: string | undefined, path: string): MaterializedState | undefined {
412
+ if (source === undefined) return undefined;
413
+ let value: unknown;
414
+ try {
415
+ value = JSON.parse(source);
416
+ } catch {
417
+ throw new Error(`Durable State Flow file contains invalid JSON: ${path}`);
418
+ }
419
+ validateState(value, path);
420
+ return structuredClone(value);
421
+ }
422
+
423
+ interface PreparedFile {
424
+ path: string;
425
+ repositoryRoot: string;
426
+ temporary: string;
427
+ original?: Uint8Array;
428
+ next?: Uint8Array;
429
+ }
430
+
431
+ function prepareFile(path: string, repositoryRoot: string, content: string | undefined, expected?: DurableFileBase): PreparedFile {
432
+ assertWithinRepository(path, repositoryRoot);
433
+ assertDirectoryChain(repositoryRoot, dirname(path), true);
434
+ const original = readRegularBytes(path, repositoryRoot);
435
+ if (expected !== undefined && byteIdentity(original) !== expected.identity) {
436
+ throw new Error(`State Flow file conflict during preparation: ${path}`);
437
+ }
438
+ const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
439
+ const next = content === undefined ? undefined : Buffer.from(content);
440
+ if (next !== undefined) writeFileSync(temporary, next, { flag: "wx", mode: 0o600 });
441
+ return { path, repositoryRoot, temporary, original, next };
442
+ }
443
+
444
+ function restorePrepared(prepared: PreparedFile): void {
445
+ assertCurrentBytes(prepared.path, prepared.repositoryRoot, prepared.next);
446
+ if (prepared.original === undefined) {
447
+ rmSync(prepared.path, { force: true });
448
+ return;
449
+ }
450
+ const rollback = join(dirname(prepared.path), `.${basename(prepared.path)}.${process.pid}.${randomUUID()}.rollback`);
451
+ try {
452
+ writeFileSync(rollback, prepared.original, { flag: "wx", mode: 0o600 });
453
+ renameSync(rollback, prepared.path);
454
+ } finally {
455
+ rmSync(rollback, { force: true });
456
+ }
457
+ }
458
+
459
+ function publishPrepared(prepared: PreparedFile[]): void {
460
+ let published = 0;
461
+ try {
462
+ for (const item of prepared) {
463
+ assertCurrentBytes(item.path, item.repositoryRoot, item.original);
464
+ if (item.next === undefined) rmSync(item.path, { force: true });
465
+ else renameSync(item.temporary, item.path);
466
+ published += 1;
467
+ }
468
+ } catch (error) {
469
+ let rollbackError: unknown;
470
+ for (let index = published - 1; index >= 0; index--) {
471
+ try {
472
+ restorePrepared(prepared[index]!);
473
+ } catch (failure) {
474
+ rollbackError ??= failure;
475
+ }
476
+ }
477
+ if (rollbackError !== undefined) {
478
+ throw new AggregateError([error, rollbackError], "Durable State Flow transition publication and rollback failed");
479
+ }
480
+ throw error;
481
+ } finally {
482
+ for (const item of prepared) rmSync(item.temporary, { force: true });
483
+ }
484
+ }
485
+
486
+ export interface OwnedFileUpdate {
487
+ path: string;
488
+ /** Omission means removal of this exact owned file, not a semantic null patch. */
489
+ content?: string;
490
+ }
491
+
492
+ /** Verify the publisher's exact output before commit or rollback, without trusting changed worktree bytes. */
493
+ export function assertOwnedFileUpdates(updates: readonly OwnedFileUpdate[], repositoryRoot: string): void {
494
+ const root = resolve(repositoryRoot);
495
+ for (const update of updates) {
496
+ if (!isStateFlowOwnedPath(update.path, root)) throw new Error(`Cannot inspect a non-State Flow path: ${update.path}`);
497
+ assertCurrentBytes(update.path, root, update.content === undefined ? undefined : Buffer.from(update.content));
498
+ }
499
+ }
500
+
501
+ /** Publish a prevalidated file cohort, preserving original bytes for failed preparation/publication. */
502
+ export function writeOwnedFileUpdates(
503
+ updates: readonly OwnedFileUpdate[],
504
+ bases: readonly DurableFileBase[],
505
+ repositoryRoot: string,
506
+ ): string[] {
507
+ const root = resolve(repositoryRoot);
508
+ const byPath = new Map(bases.map((base) => [resolve(base.path), base]));
509
+ const seen = new Set<string>();
510
+ const prepared: PreparedFile[] = [];
511
+ try {
512
+ for (const update of updates) {
513
+ const path = resolve(update.path);
514
+ if (!isStateFlowOwnedPath(path, root)) throw new Error(`Cannot update a non-State Flow path: ${path}`);
515
+ if (seen.has(path)) throw new Error(`Duplicate State Flow file update: ${path}`);
516
+ seen.add(path);
517
+ const base = byPath.get(path);
518
+ if (base === undefined) throw new Error(`State Flow file update has no captured base: ${path}`);
519
+ prepared.push(prepareFile(path, root, update.content, base));
520
+ }
521
+ } catch (error) {
522
+ for (const item of prepared) rmSync(item.temporary, { force: true });
523
+ throw error;
524
+ }
525
+ publishPrepared(prepared);
526
+ return prepared.map(({ path }) => path);
527
+ }
528
+
529
+ /** Restore exact pre-transition bytes only while files still match this publisher's output. */
530
+ export function restoreDurableFileBases(
531
+ bases: readonly DurableFileBase[],
532
+ repositoryRoot: string,
533
+ expectedCurrent: readonly OwnedFileUpdate[],
534
+ ): void {
535
+ const root = resolve(repositoryRoot);
536
+ const expected = new Map(expectedCurrent.map((update) => [resolve(update.path), update]));
537
+ for (const base of bases) {
538
+ assertWithinRepository(base.path, root);
539
+ if (!isStateFlowOwnedPath(base.path, root)) {
540
+ throw new Error(`Cannot restore a non-State Flow path: ${base.path}`);
541
+ }
542
+ }
543
+ for (const base of bases) {
544
+ const update = expected.get(resolve(base.path));
545
+ if (update === undefined) throw new Error(`Rollback has no published basis: ${base.path}`);
546
+ assertCurrentBytes(base.path, root, update.content === undefined ? undefined : Buffer.from(update.content));
547
+ if (base.identity === "missing") {
548
+ rmSync(base.path, { force: true });
549
+ continue;
550
+ }
551
+ const original = base.bytes ?? (base.content === undefined ? undefined : Buffer.from(base.content));
552
+ if (original === undefined) throw new Error(`Durable State Flow base content is missing: ${base.path}`);
553
+ assertDirectoryChain(root, dirname(base.path), true);
554
+ const temporary = join(dirname(base.path), `.${basename(base.path)}.${process.pid}.${randomUUID()}.restore`);
555
+ try {
556
+ writeFileSync(temporary, original, { flag: "wx", mode: 0o600 });
557
+ renameSync(temporary, base.path);
558
+ } finally {
559
+ rmSync(temporary, { force: true });
560
+ }
561
+ }
562
+ }