@llblab/pi-kit 0.24.1 → 0.25.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 (85) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +6 -0
  3. package/README.md +5 -4
  4. package/node_modules/@llblab/pi-claude-usage/AGENTS.md +20 -0
  5. package/node_modules/@llblab/pi-claude-usage/BACKLOG.md +3 -0
  6. package/node_modules/@llblab/pi-claude-usage/CHANGELOG.md +13 -0
  7. package/node_modules/@llblab/pi-claude-usage/LICENSE +22 -0
  8. package/node_modules/@llblab/pi-claude-usage/README.md +110 -0
  9. package/node_modules/@llblab/pi-claude-usage/banner.jpg +0 -0
  10. package/node_modules/@llblab/pi-claude-usage/index.ts +1159 -0
  11. package/node_modules/@llblab/pi-claude-usage/package.json +60 -0
  12. package/node_modules/@llblab/pi-state-flow/AGENTS.md +42 -56
  13. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +16 -3
  14. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +19 -0
  15. package/node_modules/@llblab/pi-state-flow/README.md +15 -12
  16. package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +2 -2
  17. package/node_modules/@llblab/pi-state-flow/dist/index.js +2 -2
  18. package/node_modules/@llblab/pi-state-flow/dist/lib/config.d.ts +7 -3
  19. package/node_modules/@llblab/pi-state-flow/dist/lib/config.js +16 -7
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +9 -9
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +5 -4
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.js +2 -2
  23. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +1 -1
  24. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +7 -4
  25. package/node_modules/@llblab/pi-state-flow/dist/lib/episode.d.ts +3 -3
  26. package/node_modules/@llblab/pi-state-flow/dist/lib/episode.js +5 -5
  27. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.d.ts +3 -5
  28. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +275 -199
  29. package/node_modules/@llblab/pi-state-flow/dist/lib/history.d.ts +11 -4
  30. package/node_modules/@llblab/pi-state-flow/dist/lib/history.js +6 -7
  31. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.d.ts +4 -1
  32. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.js +1 -0
  33. package/node_modules/@llblab/pi-state-flow/dist/lib/query.d.ts +4 -5
  34. package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +13 -13
  35. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.d.ts +7 -6
  36. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.js +9 -9
  37. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +3 -2
  38. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +17 -12
  39. package/node_modules/@llblab/pi-state-flow/dist/lib/session.d.ts +4 -1
  40. package/node_modules/@llblab/pi-state-flow/dist/lib/session.js +2 -1
  41. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +17 -8
  42. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +49 -20
  43. package/node_modules/@llblab/pi-state-flow/dist/lib/state.d.ts +22 -3
  44. package/node_modules/@llblab/pi-state-flow/dist/lib/state.js +30 -10
  45. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +5 -3
  46. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +19 -28
  47. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +17 -15
  48. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +52 -52
  49. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.d.ts +8 -4
  50. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.js +34 -18
  51. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.d.ts +5 -5
  52. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +13 -19
  53. package/node_modules/@llblab/pi-state-flow/dist/package.json +3 -3
  54. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +2 -2
  55. package/node_modules/@llblab/pi-state-flow/docs/README.md +2 -1
  56. package/node_modules/@llblab/pi-state-flow/docs/agent-contract-relocation.md +72 -0
  57. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +36 -32
  58. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +12 -4
  59. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +5 -5
  60. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +6 -6
  61. package/node_modules/@llblab/pi-state-flow/docs/performance.md +1 -1
  62. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +13 -12
  63. package/node_modules/@llblab/pi-state-flow/docs/usage.md +32 -29
  64. package/node_modules/@llblab/pi-state-flow/index.ts +3 -2
  65. package/node_modules/@llblab/pi-state-flow/lib/config.ts +20 -10
  66. package/node_modules/@llblab/pi-state-flow/lib/context.ts +15 -14
  67. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +1 -1
  68. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +8 -6
  69. package/node_modules/@llblab/pi-state-flow/lib/episode.ts +6 -6
  70. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +274 -197
  71. package/node_modules/@llblab/pi-state-flow/lib/history.ts +16 -11
  72. package/node_modules/@llblab/pi-state-flow/lib/logging.ts +5 -1
  73. package/node_modules/@llblab/pi-state-flow/lib/query.ts +16 -16
  74. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +11 -11
  75. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +19 -13
  76. package/node_modules/@llblab/pi-state-flow/lib/session.ts +6 -3
  77. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +55 -22
  78. package/node_modules/@llblab/pi-state-flow/lib/state.ts +46 -13
  79. package/node_modules/@llblab/pi-state-flow/lib/status.ts +23 -32
  80. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +66 -65
  81. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +39 -19
  82. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +19 -27
  83. package/node_modules/@llblab/pi-state-flow/package.json +3 -3
  84. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +2 -2
  85. package/package.json +6 -2
@@ -1,9 +1,8 @@
1
1
  import type { ArtifactInvalidationReason } from "./artifact.ts";
2
- import { projectRecentTransitionsWithLimit, type RecentTransitionWindow } from "./history.ts";
3
- import { retainedMemoryScopes } from "./memory.ts";
2
+ import type { RecentTransitionWindow } from "./history.ts";
4
3
  import { conciseDiagnostic } from "./protocol.ts";
5
4
  import type { Snapshot } from "./snapshot.ts";
6
- import { overlayStates, type ScopedStates, type StateScope } from "./state.ts";
5
+ import { overlayStates, projectSemanticState, type SemanticState, type ScopedStates, type StateScope } from "./state.ts";
7
6
  import type { ScopeRevisions, TransitionBoundary } from "./temporal.ts";
8
7
 
9
8
  export const STATUS_KEY = "state-flow";
@@ -23,6 +22,8 @@ export interface StatusDiagnostics {
23
22
  cwdScopeKey: string;
24
23
  sessionScopeKey: string;
25
24
  scopeStates: ScopedStates;
25
+ /** Overlay raw scopes before defaults so absent scalar planes cannot mask lower scopes. */
26
+ effectiveState?: SemanticState;
26
27
  recent: RecentTransitionWindow;
27
28
  historyLimit: number;
28
29
  temporal?: { head: TransitionBoundary; historyDepth: number; tailCounts: Record<StateScope, number>; revisions: ScopeRevisions };
@@ -32,12 +33,13 @@ export interface StatusDiagnostics {
32
33
  }
33
34
 
34
35
  export function formatScopeRevisionVector(revisions: ScopeRevisions): string {
35
- return `G${revisions.global}/C${revisions.cwd}/S${revisions.session}`;
36
+ return `g${revisions.global}c${revisions.cwd}s${revisions.session}`;
36
37
  }
37
38
 
38
- export function compactStatus(snapshot: Snapshot, revisions: ScopeRevisions, colorize: Colorize): string | undefined {
39
- if (!snapshot.config.enabled) return undefined;
40
- return `${colorize("accent", "state-flow")} ${colorize("dim", formatScopeRevisionVector(revisions))}`;
39
+ export function compactStatus(snapshot: Snapshot, _revisions: ScopeRevisions, colorize: Colorize): string | undefined {
40
+ const { mode } = snapshot.config;
41
+ if (mode === "off") return undefined;
42
+ return `${colorize("accent", "state-flow")} ${colorize("dim", mode)}`;
41
43
  }
42
44
 
43
45
  function countArtifacts(states: ScopedStates, scope: StateScope): number {
@@ -45,20 +47,17 @@ function countArtifacts(states: ScopedStates, scope: StateScope): number {
45
47
  }
46
48
 
47
49
  export function detailedStatus(snapshot: Snapshot, diagnostics: StatusDiagnostics): string {
48
- const projectedRecent = projectRecentTransitionsWithLimit(
49
- diagnostics.historyLimit,
50
- diagnostics.recent,
51
- );
52
50
  const available = diagnostics.temporal !== undefined && diagnostics.durableStateError === undefined;
53
- const materialized = !available ? undefined : overlayStates(
51
+ const materialized = !available ? undefined : diagnostics.effectiveState ?? projectSemanticState(overlayStates(
54
52
  diagnostics.scopeStates.global,
55
53
  diagnostics.scopeStates.cwd,
56
54
  diagnostics.scopeStates.session,
57
- );
58
- const stateJson = materialized === undefined ? undefined : JSON.stringify(materialized, null, 2);
55
+ ));
56
+ // Only top-level plane boundaries gain whitespace; nested user JSON stays unchanged.
57
+ const stateJson = materialized === undefined ? undefined : JSON.stringify(materialized, null, 2).replace(/,\n(?= ")/g, ",\n\n");
59
58
  const invalidated = available ? String(diagnostics.staleArtifacts.length) : "unavailable";
60
59
  const invalidationLines = diagnostics.staleArtifacts.length === 0
61
- ? ["Pending artifact invalidations: none"]
60
+ ? []
62
61
  : [
63
62
  "Pending artifact invalidations:",
64
63
  ...diagnostics.staleArtifacts.map(({ scope, path, reason }) => `- [${scope}] ${path} — ${reason}`),
@@ -66,30 +65,22 @@ export function detailedStatus(snapshot: Snapshot, diagnostics: StatusDiagnostic
66
65
  const temporal = available ? diagnostics.temporal : undefined;
67
66
  const temporalLines = temporal === undefined
68
67
  ? [`Temporal materialization unavailable: ${conciseDiagnostic(diagnostics.durableStateError ?? "no selected branch runtime")}`,
69
- `Hot history: unavailable; configured maximum depth ${diagnostics.historyLimit}`,
70
- "Retained patch tails: unavailable"]
71
- : [`Temporal head: ${JSON.stringify(temporal.head.id)}; branch-local position ${temporal.head.position}`,
72
- `Scope revisions: global #${temporal.revisions.global}; CWD #${temporal.revisions.cwd}; session #${temporal.revisions.session}; effective ${formatScopeRevisionVector(temporal.revisions)}`,
73
- `Hot history: offsets 0..${temporal.historyDepth}; maximum depth ${diagnostics.historyLimit}`,
74
- `Retained patch tails: global ${temporal.tailCounts.global}; CWD ${temporal.tailCounts.cwd}; session ${temporal.tailCounts.session}`];
75
- const artifacts = (scope: StateScope) => available ? countArtifacts(diagnostics.scopeStates, scope) : "unknown";
76
- const memoryScopes = available ? retainedMemoryScopes(diagnostics.scopeStates) : undefined;
68
+ `Hot history: unavailable; configured maximum depth ${diagnostics.historyLimit}`]
69
+ : [`Scope revisions: ${formatScopeRevisionVector(temporal.revisions)}`,
70
+ `Runtime metadata: step #${snapshot.meta.step}${snapshot.meta.bootstrap ? "; bootstrap" : ""}`,
71
+ `Hot history: offsets 0..${temporal.historyDepth}; maximum depth ${diagnostics.historyLimit}`];
72
+ const artifacts = (scope: StateScope) => countArtifacts(diagnostics.scopeStates, scope);
73
+ const hasArtifacts = available && (["global", "cwd", "session"] as const).some((scope) => artifacts(scope) > 0);
77
74
 
78
75
  return [
79
- `State Flow diagnostics — config.enabled=${snapshot.config.enabled}; branch mode=${snapshot.config.enabled ? "active" : "inactive"}`,
80
76
  `Repository: ${diagnostics.repositoryRoot}`,
81
77
  `Scope keys: CWD ${diagnostics.cwdScopeKey}; session ${diagnostics.sessionScopeKey}`,
82
- "Session files: config.json owns behavior; runtime.json owns branch recovery; meta.json owns scope provenance",
83
- `Runtime metadata: step #${snapshot.meta.step}; bootstrap ${snapshot.meta.bootstrap === true}`,
84
- "Memory: owner state-flow; global retention enabled; global fallback active",
85
- `Memory-bearing scopes: global ${memoryScopes?.global ?? "unknown"}; CWD ${memoryScopes?.cwd ?? "unknown"}; session ${memoryScopes?.session ?? "unknown"}`,
86
78
  ...temporalLines,
87
- ...(diagnostics.publicationError === undefined ? [] : [`Memory writes paused after Stop: ${conciseDiagnostic(diagnostics.publicationError)}`]),
88
- `Artifacts: global ${artifacts("global")}; CWD ${artifacts("cwd")}; session ${artifacts("session")}; pending invalidations ${invalidated}`,
89
- available ? `Recent transitions: global ${diagnostics.recent.filter(({ transitions }) => transitions.some(({ scope }) => scope === "global")).length}; CWD ${diagnostics.recent.filter(({ transitions }) => transitions.some(({ scope }) => scope === "cwd")).length}; session ${diagnostics.recent.filter(({ transitions }) => transitions.some(({ scope }) => scope === "session")).length}; active ${projectedRecent.length}` : "Recent transitions: unavailable",
79
+ ...(diagnostics.publicationError === undefined ? [] : [`Memory writes paused after mode change: ${conciseDiagnostic(diagnostics.publicationError)}`]),
80
+ ...(hasArtifacts ? [`Artifacts: global ${artifacts("global")}; CWD ${artifacts("cwd")}; session ${artifacts("session")}; pending invalidations ${invalidated}`] : []),
90
81
  ...invalidationLines,
91
82
  ...(stateJson === undefined
92
83
  ? ["Effective memory: unavailable"]
93
- : [`Effective memory (${Buffer.byteLength(stateJson, "utf8")} JSON bytes; global → CWD → session overlay):`, "", stateJson]),
84
+ : ["Effective memory:", "", stateJson]),
94
85
  ].join("\n");
95
86
  }
@@ -4,6 +4,7 @@
4
4
  // pi-telegram is absent or its registry is not ready, registration fails open and retries.
5
5
 
6
6
  import { conciseDiagnostic, diagnosticText } from "./protocol.ts";
7
+ import type { StateFlowMode } from "./snapshot.ts";
7
8
  import { formatScopeRevisionVector } from "./status.ts";
8
9
  import type { ScopeRevisions } from "./temporal.ts";
9
10
 
@@ -18,7 +19,8 @@ export function stateFlowTelegramSectionSpecifiers(moduleUrl = import.meta.url):
18
19
  }
19
20
 
20
21
  export interface StateFlowTelegramSnapshot {
21
- enabled: boolean;
22
+ /** The current session's selected mode. */
23
+ mode: StateFlowMode;
22
24
  /** Legacy branch step retained for existing adapter ports; current runtime ports also supply owner revisions. */
23
25
  step: number;
24
26
  revisions?: ScopeRevisions;
@@ -29,11 +31,11 @@ export interface StateFlowTelegramSnapshot {
29
31
  export type StateFlowTelegramScope = "global" | "cwd" | "session" | "effective";
30
32
 
31
33
  export interface StateFlowTelegramState {
32
- artifacts: Record<string, unknown>;
33
- contract: Record<string, unknown>;
34
- working: Record<string, unknown>;
35
- intents: Record<string, unknown>;
36
- response: string;
34
+ artifacts?: Record<string, unknown>;
35
+ contract?: Record<string, unknown>;
36
+ working?: Record<string, unknown>;
37
+ intents?: Record<string, unknown>;
38
+ response?: string;
37
39
  lazy?: unknown;
38
40
  }
39
41
 
@@ -110,17 +112,17 @@ export interface StateFlowTelegramPort {
110
112
  state(scope: StateFlowTelegramScope): StateFlowTelegramState;
111
113
  /** Optional additive capability; absent legacy ports retain their branch-step presentation. */
112
114
  revisions?(): ScopeRevisions;
115
+ /** Active may need a settled native boundary; inactive modes apply immediately. */
113
116
  canStartNow(): boolean;
114
- start(): StateFlowTelegramControlResult;
115
- stop(): StateFlowTelegramControlResult;
117
+ /** Select the current session's mode through the same lifecycle owners as the terminal commands. */
118
+ select(mode: StateFlowMode): StateFlowTelegramControlResult;
116
119
  deferStart(): void;
117
120
  cancelStart(): void;
118
121
  }
119
122
 
120
- export interface StateFlowTelegramInspectionPort extends Omit<StateFlowTelegramPort, "state" | "revisions" | "start" | "stop"> {
123
+ export interface StateFlowTelegramInspectionPort extends Omit<StateFlowTelegramPort, "state" | "revisions" | "select"> {
121
124
  inspect(scope: StateFlowTelegramScope): StateFlowTelegramInspection | Promise<StateFlowTelegramInspection>;
122
- start(): StateFlowTelegramControlResult | Promise<StateFlowTelegramControlResult>;
123
- stop(): StateFlowTelegramControlResult | Promise<StateFlowTelegramControlResult>;
125
+ select(mode: StateFlowMode): StateFlowTelegramControlResult | Promise<StateFlowTelegramControlResult>;
124
126
  }
125
127
 
126
128
  export interface StateFlowTelegramAdapter {
@@ -128,41 +130,56 @@ export interface StateFlowTelegramAdapter {
128
130
  dispose(): void;
129
131
  }
130
132
 
131
- /** Main-menu section label doubles as the live status value: the spiral identity is constant, the value is not. */
133
+ /** Main-menu section label shows only the current session mode. */
132
134
  export function formatStateFlowSectionLabel(snapshot: StateFlowTelegramSnapshot): string {
133
- if (!snapshot.enabled) return "🌀 State Flow: off";
134
- return `🌀 State Flow: ${snapshot.revisions ? formatScopeRevisionVector(snapshot.revisions) : `#${snapshot.step}`}`;
135
+ return `🌀 State Flow: ${snapshot.mode}`;
135
136
  }
136
137
 
137
- /** Shared live value: plain in the button label, monospaced in the submenu state line. */
138
- function stateFlowLabelValue(snapshot: StateFlowTelegramSnapshot): string {
139
- if (!snapshot.enabled) return "off";
140
- return snapshot.revisions ? formatScopeRevisionVector(snapshot.revisions) : `#${snapshot.step}`;
141
- }
138
+ export const STATE_FLOW_MODES = ["off", "passive", "active"] as const satisfies readonly StateFlowMode[];
139
+ const STATE_FLOW_MODE_LABELS: Record<StateFlowMode, string> = { off: "Off", passive: "Passive", active: "Active" };
140
+ const STATE_FLOW_SELECTED_MARKERS: Record<StateFlowMode, string> = { off: "🟡", passive: "🟣", active: "🟢" };
142
141
 
143
- /** Submenu state line: the same identity as the button label, with the live value in monospace. */
144
- function formatStateFlowSectionHeader(snapshot: StateFlowTelegramSnapshot): string {
145
- return `<b>🌀 State Flow: <code>${stateFlowLabelValue(snapshot)}</code></b>`;
142
+ function isStateFlowModeAction(value: string): value is StateFlowMode {
143
+ return (STATE_FLOW_MODES as readonly string[]).includes(value);
146
144
  }
147
145
 
148
- /** Short help under the state line: what State Flow is and why its action button exists. */
149
- const STATE_FLOW_SECTION_HELP =
150
- "Accepted memory remains visible in active and passive modes. Start or Stop changes episode behavior, not state access.";
146
+ function modeReceiptNotice(mode: StateFlowMode, result: StateFlowTelegramControlResult): string | undefined {
147
+ if (result.ok && (result.message === `State Flow ${mode}` || result.message === `State Flow is already ${mode}`)) return undefined;
148
+ return result.message;
149
+ }
151
150
 
152
- /** The submenu header repeats the button's state line; the single action matches the current state. */
151
+ /** One radio-style mode row followed directly by read-only scope actions. */
153
152
  export function buildStateFlowSectionView(
154
153
  snapshot: StateFlowTelegramSnapshot,
155
- callbackData: (action: string) => string,
154
+ callbackData: (action: string, payload?: string) => string,
156
155
  ): StateFlowTelegramView {
157
- const action: StateFlowTelegramButton = snapshot.enabled
158
- ? { text: "⏹ Stop", callback_data: callbackData("stop") }
159
- : { text: "▶️ Start", callback_data: callbackData("start") };
156
+ const modes: StateFlowTelegramButton[] = STATE_FLOW_MODES.map((mode) => ({
157
+ text: `${mode === snapshot.mode ? STATE_FLOW_SELECTED_MARKERS[mode] : "⚫️"} ${STATE_FLOW_MODE_LABELS[mode]}`,
158
+ callback_data: callbackData(mode),
159
+ }));
160
160
  return {
161
- text: [formatStateFlowSectionHeader(snapshot), "", STATE_FLOW_SECTION_HELP].join("\n"),
161
+ text: [
162
+ `<b>🌀 State Flow:</b> <code>${snapshot.mode}</code>`,
163
+ "",
164
+ "<b>Mode</b> — choose a workflow (switching modes never erases stored memory):",
165
+ "",
166
+ "<code>-</code> <code>off</code> (default): regular chat without State Flow memory tools or context.",
167
+ "<code>-</code> <code>passive</code>: regular chat with memory tools; available combined memory enters the agent's context.",
168
+ "<code>-</code> <code>active</code>: same memory access; each completed answer closes a cycle, and the next request starts from saved state, not the full chat.",
169
+ "",
170
+ "<b>Inspect memory</b> — view stored state even in Off:",
171
+ "",
172
+ "<code>-</code> <code>global</code>: memory shared across projects and sessions.",
173
+ "<code>-</code> <code>cwd</code>: memory shared by sessions in this directory.",
174
+ "<code>-</code> <code>session</code>: private memory for this session, kept on resume.",
175
+ "<code>-</code> <code>effective</code>: merged Global, CWD and Session state; the agent can use it when memory is enabled and available.",
176
+ ].join("\n"),
162
177
  parseMode: "html",
163
178
  replyMarkup: { inline_keyboard: [
164
- [action],
165
- [{ text: "👁 Show state", callback_data: callbackData("show-state") }],
179
+ modes,
180
+ ...([["global", "cwd"], ["session", "effective"]] as const).map((row) => row.map((scope) => ({
181
+ text: STATE_FLOW_SCOPE_LABELS[scope], callback_data: callbackData("inspect", scope),
182
+ }))),
166
183
  ] },
167
184
  };
168
185
  }
@@ -174,18 +191,6 @@ const STATE_FLOW_SCOPE_LABELS: Record<StateFlowTelegramScope, string> = {
174
191
  effective: "🧬 Effective",
175
192
  };
176
193
 
177
- export function buildStateFlowScopeChooser(callbackData: (action: string, payload?: string) => string): StateFlowTelegramView {
178
- return {
179
- text: "<b>👁 Show state:</b>",
180
- parseMode: "html",
181
- replyMarkup: { inline_keyboard: [
182
- ...(["global", "cwd", "session", "effective"] as const).map((scope) => [
183
- { text: STATE_FLOW_SCOPE_LABELS[scope], callback_data: callbackData("inspect", scope) },
184
- ]),
185
- ] },
186
- };
187
- }
188
-
189
194
  // The complete message serializes each preformatted field one additional time;
190
195
  // 3,000 leaves safe headroom for worst-case JSON escaping across all four fields.
191
196
  const STATE_FLOW_TELEGRAM_FIELD_MAX_CHARS = 3_000;
@@ -230,10 +235,10 @@ export function renderStateFlowRichState(scope: StateFlowTelegramScope, revision
230
235
  text: [`${STATE_FLOW_SCOPE_LABELS[scope]}: `, { type: "code", text: revision }],
231
236
  size: 3,
232
237
  },
233
- ...fields.map((field) => ({
238
+ ...fields.filter((field) => state[field] !== undefined && state[field] !== "").map((field) => ({
234
239
  type: "details" as const,
235
240
  summary: { type: "code" as const, text: field },
236
- blocks: [{ type: "pre" as const, language: "json", text: renderStateFlowTelegramField(state[field] ?? {}) }],
241
+ blocks: [{ type: "pre" as const, language: "json", text: renderStateFlowTelegramField(state[field]) }],
237
242
  })),
238
243
  ],
239
244
  skip_entity_detection: true,
@@ -251,19 +256,15 @@ function buildStateFlowTelegramSection(port: StateFlowTelegramPort | StateFlowTe
251
256
  label: "🌀 State Flow",
252
257
  getLabel: () => formatStateFlowSectionLabel(port.snapshot()),
253
258
  render: (ctx: StateFlowTelegramSectionContext) =>
254
- buildStateFlowSectionView(port.snapshot(), (action) => ctx.callbackData(action)),
259
+ buildStateFlowSectionView(port.snapshot(), (action, payload) => ctx.callbackData(action, payload)),
255
260
  handleCallback: async (ctx: StateFlowTelegramCallbackContext) => {
256
- // cancel/refresh remain routable for keyboards sent by earlier versions.
257
- if (ctx.action !== "start" && ctx.action !== "stop" && ctx.action !== "cancel" && ctx.action !== "refresh" && ctx.action !== "show-state" && ctx.action !== "inspect" && ctx.action !== "back") return "pass" as const;
261
+ // Keyboards sent by earlier versions re-render (start/stop/refresh) or withdraw deferral (cancel); they change no mode.
262
+ const legacy = ctx.action === "start" || ctx.action === "stop" || ctx.action === "cancel" || ctx.action === "refresh";
263
+ if (!isStateFlowModeAction(ctx.action) && !legacy && ctx.action !== "show-state" && ctx.action !== "inspect" && ctx.action !== "back") return "pass" as const;
258
264
  const request = ++interaction;
259
265
  let notice: string | undefined;
260
266
  let acknowledged = false;
261
267
  try {
262
- if (ctx.action === "show-state") {
263
- await ctx.answerCallback();
264
- await ctx.edit(buildStateFlowScopeChooser((action, payload) => ctx.callbackData(action, payload)));
265
- return "handled" as const;
266
- }
267
268
  if (ctx.action === "inspect") {
268
269
  if (!isStateFlowTelegramScope(ctx.payload)) throw new Error("Unknown State Flow scope");
269
270
  let observation: StateFlowTelegramInspection;
@@ -282,21 +283,21 @@ function buildStateFlowTelegramSection(port: StateFlowTelegramPort | StateFlowTe
282
283
  if (!acknowledged) await ctx.answerCallback();
283
284
  return "handled" as const;
284
285
  }
285
- const action = ctx.action === "stop" || (ctx.action === "start" && port.canStartNow()) ? ctx.action : undefined;
286
- if (action) {
286
+ const mode = isStateFlowModeAction(ctx.action) && (ctx.action !== "active" || port.canStartNow()) ? ctx.action : undefined;
287
+ if (mode) {
287
288
  if ("inspect" in port) {
288
289
  acknowledged = true;
289
- // Start the control immediately and acknowledge in parallel; neither promise can reject unobserved.
290
+ // Apply the control immediately and acknowledge in parallel; neither promise can reject unobserved.
290
291
  const [, result] = await Promise.all([
291
- ctx.answerCallback(action === "stop" ? "Stopping State Flow" : "Starting State Flow"),
292
- Promise.resolve().then(() => port[action]()),
292
+ ctx.answerCallback(`Switching State Flow to ${mode}`),
293
+ Promise.resolve().then(() => port.select(mode)),
293
294
  ]);
294
295
  if (result.signal?.aborted) return "handled" as const;
295
- notice = result.message;
296
- } else notice = port[action]().message;
297
- } else if (ctx.action === "start") {
296
+ notice = modeReceiptNotice(mode, result);
297
+ } else notice = modeReceiptNotice(mode, port.select(mode));
298
+ } else if (ctx.action === "active") {
298
299
  port.deferStart();
299
- notice = "State Flow will start after the current turn";
300
+ notice = "State Flow will become active after the current turn";
300
301
  } else if (ctx.action === "cancel") {
301
302
  port.cancelStart();
302
303
  notice = "Pending start cancelled";
@@ -306,7 +307,7 @@ function buildStateFlowTelegramSection(port: StateFlowTelegramPort | StateFlowTe
306
307
  }
307
308
  if (request !== interaction || !isActive()) return "handled" as const;
308
309
  const summary = notice === undefined ? undefined : conciseDiagnostic(notice, 200);
309
- const view = buildStateFlowSectionView(port.snapshot(), (action) => ctx.callbackData(action));
310
+ const view = buildStateFlowSectionView(port.snapshot(), (action, payload) => ctx.callbackData(action, payload));
310
311
  if (acknowledged && summary !== undefined) {
311
312
  // Callback queries can expire during storage waits; retain errors in the existing menu instead.
312
313
  view.text += `\n\n${summary.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")}`;
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_HISTORY_LIMIT, MAX_HISTORY_LIMIT, validateRecentTransition, type RecentScopePatch } from "./history.ts";
2
2
  import { applyPatch, containsNull, isJsonValue, isObject, sameJson, type JsonObject } from "./json.ts";
3
- import { isMaterializedState, overlayStates, type MaterializedState, type ScopedStates, type StateScope } from "./state.ts";
3
+ import { emptyState, isSemanticState, overlayStates, projectSemanticState, type MaterializedState, type SemanticState, type ScopedSemanticStates, type StateScope } from "./state.ts";
4
4
 
5
5
  /** Owns hot temporal algebra; excludes filesystem, Git, identity allocation, and Pi lifecycle. */
6
6
  export interface TransitionBoundary {
@@ -12,7 +12,7 @@ export interface TransitionBoundary {
12
12
 
13
13
  export interface ScopeCheckpoint {
14
14
  through: TransitionBoundary;
15
- state: MaterializedState;
15
+ state: SemanticState;
16
16
  }
17
17
 
18
18
  export interface TemporalPatch {
@@ -52,15 +52,20 @@ function validateBoundary(boundary: TransitionBoundary): void {
52
52
  }
53
53
  }
54
54
 
55
- function validateState(state: MaterializedState): void {
56
- if (!isJsonValue(state) || !isMaterializedState(state) || containsNull(state)) {
57
- throw new Error("Invalid temporal materialized semantic state");
58
- }
55
+ function validateState(state: JsonObject, location?: string): asserts state is SemanticState {
56
+ const json = isJsonValue(state);
57
+ const hasNull = json && isObject(state) && Object.keys(emptyState()).some((key) => containsNull(state[key]));
58
+ if (json && isSemanticState(state) && !hasNull) return;
59
+ const reason = !json ? "expected finite, acyclic JSON data"
60
+ : !isObject(state) ? "expected a semantic object"
61
+ : hasNull ? "null is not allowed"
62
+ : "invalid semantic fields or artifact metadata";
63
+ throw new Error(`Invalid temporal materialized semantic state${location ? ` in ${location}` : ""}: ${reason}`);
59
64
  }
60
65
 
61
- function apply(state: MaterializedState, patch: TemporalPatch["patch"]): MaterializedState {
62
- const next = applyPatch(state, patch as JsonObject) as MaterializedState;
63
- validateState(next);
66
+ function apply(state: SemanticState, patch: TemporalPatch["patch"], location?: string): SemanticState {
67
+ const next = applyPatch(state, patch as JsonObject);
68
+ validateState(next, location);
64
69
  return next;
65
70
  }
66
71
 
@@ -80,7 +85,7 @@ export function validateScopeStream(value: unknown, scope: StateScope, historyLi
80
85
  }
81
86
  const stream = value as unknown as ScopeStream;
82
87
  validateBoundary(stream.checkpoint.through);
83
- validateState(stream.checkpoint.state);
88
+ validateState(stream.checkpoint.state, `${scope} checkpoint`);
84
89
  if (stream.patches.length > historyLimit) throw new Error(`Temporal scope tail exceeds configured history limit ${historyLimit}`);
85
90
  if (stream.revision < stream.patches.length) throw new Error("Temporal scope revision predates its retained patch tail");
86
91
  let previous = stream.checkpoint.through;
@@ -98,8 +103,7 @@ export function validateScopeStream(value: unknown, scope: StateScope, historyLi
98
103
  throw new Error("Disconnected State Flow temporal ancestry");
99
104
  }
100
105
  validateRecentTransition({ id: record.transition.id, at: 0, transitions: [{ scope, patch: record.patch }] });
101
- const next = apply(state, record.patch);
102
- if (sameJson(next, state)) throw new Error("Temporal scope tail contains a semantic no-op");
106
+ const next = apply(state, record.patch, `${scope} tail`);
103
107
  state = next;
104
108
  previous = record.transition;
105
109
  identities.add(previous.id);
@@ -193,7 +197,7 @@ export function adoptTemporalStreams(scopes: Record<StateScope, ScopeStream>, id
193
197
  }
194
198
 
195
199
  /** New or migrated state starts at a proven current boundary, with no invented past. */
196
- export function createTemporalState(states: ScopedStates, id: string, historyLimit = DEFAULT_HISTORY_LIMIT): TemporalState {
200
+ export function createTemporalState(states: ScopedSemanticStates, id: string, historyLimit = DEFAULT_HISTORY_LIMIT): TemporalState {
197
201
  const through: TransitionBoundary = { id, position: 0, parent: null };
198
202
  const stream = (scope: StateScope): ScopeStream => ({
199
203
  revision: 0,
@@ -205,7 +209,7 @@ export function createTemporalState(states: ScopedStates, id: string, historyLim
205
209
  return view;
206
210
  }
207
211
 
208
- function scopeAt(stream: ScopeStream, boundary: TransitionBoundary): MaterializedState {
212
+ function scopeAt(stream: ScopeStream, boundary: TransitionBoundary): SemanticState {
209
213
  let state = structuredClone(stream.checkpoint.state);
210
214
  for (const record of stream.patches) {
211
215
  if (record.transition.position > boundary.position) break;
@@ -280,18 +284,34 @@ export function temporalScopeRevisions(view: TemporalState): ScopeRevisions {
280
284
  return revisions;
281
285
  }
282
286
 
283
- /** Lazy scope/effective read at one shared transition boundary, never by local patch count. */
284
- export function readTemporalState(view: TemporalState, offset = 0, scope?: StateScope, historyLimit = DEFAULT_HISTORY_LIMIT): MaterializedState {
287
+ function readBoundary(view: TemporalState, offset: number, historyLimit: number): TransitionBoundary {
285
288
  validateHistoryLimit(historyLimit);
286
289
  if (!Number.isSafeInteger(offset) || offset < 0 || offset > historyLimit) {
287
290
  throw new Error(`State Flow hot-history offset must be an integer from 0 to ${historyLimit}`);
288
291
  }
289
- if (scope !== undefined && !SCOPES.includes(scope)) throw new Error("Unknown temporal scope");
290
292
  validateTemporalState(view, historyLimit);
291
293
  const boundary = view.lineage[view.lineage.length - 1 - offset];
292
294
  if (!boundary) throw new Error("Requested history predates the proven temporal origin");
293
- if (scope !== undefined) return scopeAt(view.scopes[scope], boundary);
294
- return overlayStates(...SCOPES.map((owner) => scopeAt(view.scopes[owner], boundary)));
295
+ return boundary;
296
+ }
297
+
298
+ /** Exact scope semantics for authored staging; defaults must never become implicit writes. */
299
+ export function readTemporalScopes(view: TemporalState, offset = 0, historyLimit = DEFAULT_HISTORY_LIMIT): ScopedSemanticStates {
300
+ const boundary = readBoundary(view, offset, historyLimit);
301
+ return { global: scopeAt(view.scopes.global, boundary), cwd: scopeAt(view.scopes.cwd, boundary), session: scopeAt(view.scopes.session, boundary) };
302
+ }
303
+
304
+ /** Sparse current/historical view: unknown planes and absent values never become effective data. */
305
+ export function readTemporalView(view: TemporalState, offset = 0, scope?: StateScope, historyLimit = DEFAULT_HISTORY_LIMIT): SemanticState {
306
+ if (scope !== undefined && !SCOPES.includes(scope)) throw new Error("Unknown temporal scope");
307
+ const boundary = readBoundary(view, offset, historyLimit);
308
+ const owners = scope === undefined ? SCOPES : [scope];
309
+ return owners.reduce<SemanticState>((state, owner) => applyPatch(state, projectSemanticState(scopeAt(view.scopes[owner], boundary))), {});
310
+ }
311
+
312
+ /** Internal defaulted materialization for consumers that require object registries. */
313
+ export function readTemporalState(view: TemporalState, offset = 0, scope?: StateScope, historyLimit = DEFAULT_HISTORY_LIMIT): MaterializedState {
314
+ return overlayStates(readTemporalView(view, offset, scope, historyLimit));
295
315
  }
296
316
 
297
317
  /** Allocate the identity outside this algebra; only materially effective patches accept it. */
@@ -12,12 +12,13 @@ import { createAcceptedTransition, type AcceptedTransition } from "./history.ts"
12
12
  import { applyPatch, containsNull, hashJson, isObject, validatePatch, type JsonObject } from "./json.ts";
13
13
  import { hasCompiledSkillArtifact, SKILL_ARTIFACT_COMPILER, type SuccessfulSkillRead } from "./skills.ts";
14
14
  import type { Snapshot } from "./snapshot.ts";
15
+ import { emptyState } from "./state.ts";
15
16
  import type {
16
17
  AtomicScopePatches,
17
- MaterializedState,
18
+ SemanticState,
18
19
  ScopePatch,
19
20
  ScopedPatch,
20
- ScopedStates,
21
+ ScopedSemanticStates,
21
22
  SemanticTransition,
22
23
  StateDocument,
23
24
  StatePatch,
@@ -26,7 +27,7 @@ import type {
26
27
  } from "./state.ts";
27
28
 
28
29
  export interface StagedScopedTransition {
29
- nextStates: ScopedStates;
30
+ nextStates: ScopedSemanticStates;
30
31
  stateHashes: Record<StateScope, string>;
31
32
  /** Fresh runtime-owned provenance for artifacts compiled in this transition. */
32
33
  provenanceUpdates: Record<StateScope, Record<string, ArtifactProvenance>>;
@@ -126,7 +127,7 @@ function compileReadSkills(
126
127
  }
127
128
 
128
129
  function validateMaterializedTransition(nextState: StateDocument, scope: StateScope): void {
129
- if (containsNull(nextState)) {
130
+ if (Object.keys(emptyState()).some((key) => containsNull(nextState[key]))) {
130
131
  throw new Error("Materialized state cannot contain null; use null only as an object-key deletion marker");
131
132
  }
132
133
  validateArtifactRegistry(nextState.artifacts, `${scope}.artifacts`);
@@ -156,20 +157,9 @@ function validateScopePatch(scope: unknown, patch: unknown): asserts patch is Sc
156
157
  if (isObject(patch.artifacts)) validateModelArtifactPatch(patch.artifacts, `${scope}.artifacts`);
157
158
  }
158
159
 
159
- function completePatch(patch: ScopePatch, response: string): StatePatch {
160
- return {
161
- artifacts: patch.artifacts ?? {},
162
- contract: patch.contract ?? {},
163
- working: patch.working ?? {},
164
- intents: patch.intents ?? {},
165
- response,
166
- lazy: structuredClone(patch.lazy ?? {}),
167
- };
168
- }
169
-
170
160
  /** Stage all scope updates against one immutable basis before any state is published. */
171
161
  function stageScopedSemanticTransition(
172
- currentStates: ScopedStates,
162
+ currentStates: ScopedSemanticStates,
173
163
  transition: SemanticTransition,
174
164
  successfulSkillReads: Iterable<SuccessfulSkillRead>,
175
165
  causalBasis: string,
@@ -197,19 +187,21 @@ function stageScopedSemanticTransition(
197
187
  const provenanceUpdates: Record<StateScope, Record<string, ArtifactProvenance>> = { global: {}, cwd: {}, session: {} };
198
188
  for (const scope of SCOPES) {
199
189
  const authored = patches.get(scope) ?? {};
200
- const response = scope === "session" && acceptedResponse !== undefined
201
- ? acceptedResponse
202
- : currentStates[scope].response;
203
- const patch = completePatch(authored, response);
204
- const nextState = applyPatch(currentStates[scope], patch) as MaterializedState;
190
+ const patch = { ...authored, ...(scope === "session" && acceptedResponse !== undefined ? { response: acceptedResponse } : {}) };
191
+ const materialized = applyPatch({ ...emptyState(), ...currentStates[scope] }, patch) as StateDocument;
205
192
  compileReadArtifacts(
206
- nextState,
193
+ materialized,
207
194
  { artifacts: authored.artifacts ?? {} },
208
195
  artifactReads.filter((read) => (read.scope ?? "global") === scope),
209
196
  provenanceUpdates[scope],
210
197
  );
211
- compileReadSkills(scope, nextState, { artifacts: authored.artifacts ?? {} }, skillReads.filter((read) => read.scope === scope), provenanceUpdates[scope]);
212
- validateMaterializedTransition(nextState, scope);
198
+ compileReadSkills(scope, materialized, { artifacts: authored.artifacts ?? {} }, skillReads.filter((read) => read.scope === scope), provenanceUpdates[scope]);
199
+ validateMaterializedTransition(materialized, scope);
200
+ const nextState: SemanticState = materialized;
201
+ for (const key of Object.keys(emptyState())) {
202
+ if (!Object.hasOwn(currentStates[scope], key) && !Object.hasOwn(patch, key)
203
+ && !(key === "artifacts" && Object.keys(provenanceUpdates[scope]).length > 0)) delete nextState[key];
204
+ }
213
205
  nextStates[scope] = nextState;
214
206
  }
215
207
  return {
@@ -227,7 +219,7 @@ function stageScopedSemanticTransition(
227
219
 
228
220
  /** Stage one canonical atomic scope cohort without changing the finalized response. */
229
221
  export function stageAtomicScopePatches(
230
- currentStates: ScopedStates,
222
+ currentStates: ScopedSemanticStates,
231
223
  patches: AtomicScopePatches,
232
224
  successfulSkillReads: Iterable<SuccessfulSkillRead>,
233
225
  causalBasis: string,
@@ -251,7 +243,7 @@ export function stageAtomicScopePatches(
251
243
  }
252
244
 
253
245
  export function stageScopedTransition(
254
- currentStates: ScopedStates,
246
+ currentStates: ScopedSemanticStates,
255
247
  transition: TerminalTransition,
256
248
  successfulSkillReads: Iterable<SuccessfulSkillRead>,
257
249
  causalBasis: string,
@@ -278,7 +270,7 @@ export interface CommitScopedTransitionOptions {
278
270
 
279
271
  export function commitScopedTransition(
280
272
  snapshot: Snapshot,
281
- states: ScopedStates,
273
+ states: ScopedSemanticStates,
282
274
  stage: StagedScopedTransition,
283
275
  publishDurable: (accepted: AcceptedTransition | undefined, nextSnapshot: Snapshot) => void,
284
276
  causalBasis: string,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-state-flow",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "private": false,
5
5
  "description": "Incremental scoped state/context/memory compiler for Pi, inspired by SKILL.state",
6
6
  "keywords": [
@@ -76,7 +76,7 @@
76
76
  "@earendil-works/pi-ai": "0.87.0",
77
77
  "@earendil-works/pi-coding-agent": "0.87.0",
78
78
  "@earendil-works/pi-tui": "0.87.0",
79
- "@types/node": "latest",
80
- "typescript": "latest"
79
+ "@types/node": "^26.4.0",
80
+ "typescript": "^7.0.2"
81
81
  }
82
82
  }
@@ -15,7 +15,7 @@ State Flow's on-demand operational reference. Resolve the usage question or iden
15
15
 
16
16
  Passive tools access memory without starting an episode. Missing tools or storage are blockers, not permission to enable an episode or bypass storage; explanation alone remains possible.
17
17
 
18
- Operator commands: `/state-flow-status` inspects; `/state-flow-start` enables the current branch; `/state-flow-stop` ends active semantics without erasing memory or necessarily disabling passive tools.
18
+ Operator commands: `/state-flow-status` inspects; `/state-flow-active` selects state-driven episodes; `/state-flow-passive` selects ordinary conversation with both memory tools and existing-state projection; `/state-flow-off` removes both tools and all State Flow context, including frozen handoffs, without deleting memory. Commands and Telegram change only the current session's `mode`. Global `mode` defaults to Off for new sessions and never overrides retained choices. Do not change mode without operator authorization.
19
19
 
20
20
  ## Map
21
21
 
@@ -56,7 +56,7 @@ Call `patch_state` alone per assistant response; await acceptance before depende
56
56
 
57
57
  The runtime waits cancelably for publication ownership, then applies authored Global/CWD operations to current canonical values. Untouched fields survive; overlapping targets follow successful acceptance order. Correct repeats succeed as `State already current.` without another semantic revision. Do not repeat external actions during a memory wait, or rebuild an entire scope from an older snapshot. Session ownership/history fences remain private, not a universal merge.
58
58
 
59
- Semantic planes `intents`, `contract`, `working`, `artifacts`, and the required `lazy` root are objects; nested lazy values may contain ordinary JSON without stored nulls. Objects merge, arrays/scalars replace, omitted fields persist. Nested `null` removes an owned object key; inherited content may reappear. Canonical `"[N]"` keys patch array elements; indexed deletion is forbidden.
59
+ When present, semantic planes `intents`, `contract`, `working`, `artifacts`, and `lazy` are objects; nested lazy values may contain ordinary JSON without stored nulls. Stored checkpoints and patches may omit any documented plane. Current and historical views assemble only known fields present in the selected scopes. Absent fields and empty responses are omitted from views. Checkpoint/tail readers ignore unknown top-level fields, and writers emit only known fields. Nested data within known planes remains intact. Explicit value reads of an absent documented top-level field return `null`. Authored `patch_state` keeps its documented field grammar. Objects merge, arrays/scalars replace, omitted fields persist. Nested `null` removes an owned object key; inherited content may reappear. Canonical `"[N]"` keys patch array elements; indexed deletion is forbidden.
60
60
 
61
61
  Illustrative deletion, only for an actually completed intent and after satisfying pending acquisitions:
62
62