@llblab/pi-kit 0.23.2 → 0.24.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.
- package/CHANGELOG.md +8 -0
- package/README.md +2 -2
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +2 -2
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +3 -3
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +12 -0
- package/node_modules/@llblab/pi-state-flow/README.md +1 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +50 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +248 -5
- package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +36 -27
- package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +1 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +3 -2
- package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
- package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +3 -1
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +6 -2
- package/node_modules/@llblab/pi-state-flow/docs/performance.md +48 -2
- package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +4 -3
- package/node_modules/@llblab/pi-state-flow/lib/context.ts +223 -6
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +37 -32
- package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +4 -2
- package/node_modules/@llblab/pi-state-flow/package.json +1 -1
- package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +3 -1
- package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +5 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +2 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +55 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +21 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +144 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +9 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +19 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +13 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +29 -6
- package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +16 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +5 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +6 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +7 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +13 -5
- package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +2 -2
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -1
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +79 -1
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +197 -0
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +33 -0
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +38 -6
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +15 -0
- package/node_modules/@llblab/pi-telegram/lib/locks.ts +6 -1
- package/node_modules/@llblab/pi-telegram/lib/polling.ts +10 -2
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +19 -5
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/package.json +3 -3
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
3
|
import { projectArtifactForModel, type ArtifactInvalidationNotice, type ArtifactModelHints } from "./artifact.ts";
|
|
3
4
|
import type { RecentTransitionWindow } from "./history.ts";
|
|
4
|
-
import { isObject, presentationJson, type JsonValue } from "./json.ts";
|
|
5
|
+
import { applyPatch, isObject, presentationJson, sameJson, type JsonValue } from "./json.ts";
|
|
5
6
|
import type { Snapshot } from "./snapshot.ts";
|
|
6
7
|
import type { RehydrationPhase } from "./rehydration.ts";
|
|
7
|
-
import { projectStateForModel, type MaterializedState, type ModelState } from "./state.ts";
|
|
8
|
+
import { projectStateForModel, type AtomicScopePatches, type MaterializedState, type ModelState, type StateScope } from "./state.ts";
|
|
8
9
|
|
|
9
10
|
/** Refresh only our section; Pi owns system frames, tools and forced-prompt precedence. */
|
|
10
11
|
export function projectSystemProtocol(messages: AgentMessage[], protocol: string | undefined): AgentMessage[] {
|
|
@@ -53,12 +54,222 @@ export function lazyNavigationHint(state: MaterializedState): { available: boole
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
|
|
57
|
+
export type ModelStateUpdate = { path: (string | number)[] } & ({ value: JsonValue } | { deleted: true });
|
|
58
|
+
|
|
59
|
+
/** Exact projected replacements, not authored merge patches; paths are unambiguous key/index segments. */
|
|
60
|
+
export function acceptedStateUpdates(before: MaterializedState, after: MaterializedState, patches: AtomicScopePatches) {
|
|
61
|
+
return projectedStateUpdates(projectStateForModel(before), projectStateForModel(after), patches, lazyNavigationHint(before), lazyNavigationHint(after));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function projectedStateUpdates(previous: ModelState, current: ModelState, patches: AtomicScopePatches,
|
|
65
|
+
beforeNavigation: ReturnType<typeof lazyNavigationHint> | undefined, navigation: ReturnType<typeof lazyNavigationHint> | undefined) {
|
|
66
|
+
const effective: ModelStateUpdate[] = [];
|
|
67
|
+
const prefix = (parent: readonly (string | number)[], child: readonly (string | number)[]) =>
|
|
68
|
+
parent.length <= child.length && parent.every((part, index) => part === child[index]);
|
|
69
|
+
const put = (path: (string | number)[], value: JsonValue | undefined) => {
|
|
70
|
+
if (effective.some((entry) => prefix(entry.path, path))) return;
|
|
71
|
+
for (let index = effective.length - 1; index >= 0; index--) {
|
|
72
|
+
if (prefix(path, effective[index]!.path)) effective.splice(index, 1);
|
|
73
|
+
}
|
|
74
|
+
effective.push({ path, ...(value === undefined ? { deleted: true as const } : { value: structuredClone(value) }) });
|
|
75
|
+
};
|
|
76
|
+
const child = (value: JsonValue | undefined, key: string | number): JsonValue | undefined =>
|
|
77
|
+
value !== null && typeof value === "object" && Object.hasOwn(value, key)
|
|
78
|
+
? (value as Record<string | number, JsonValue>)[key] : undefined;
|
|
79
|
+
const diff = (left: JsonValue | undefined, right: JsonValue | undefined, path: (string | number)[]) => {
|
|
80
|
+
if (left === undefined && right === undefined || left !== undefined && right !== undefined && sameJson(left, right)) return;
|
|
81
|
+
if (isObject(left) && isObject(right)) {
|
|
82
|
+
for (const key of new Set([...Object.keys(left), ...Object.keys(right)])) diff(child(left, key), child(right, key), [...path, key]);
|
|
83
|
+
} else if (Array.isArray(left) && Array.isArray(right) && left.length === right.length) {
|
|
84
|
+
for (let index = 0; index < right.length; index++) diff(left[index], right[index], [...path, index]);
|
|
85
|
+
} else put(path, right);
|
|
86
|
+
};
|
|
87
|
+
const touched = (patch: JsonValue, value: JsonValue | undefined, path: (string | number)[]) => {
|
|
88
|
+
if (!isObject(patch)) { put(path, value); return; }
|
|
89
|
+
const keys = Object.keys(patch);
|
|
90
|
+
if (keys.length === 0) return;
|
|
91
|
+
if (Array.isArray(value) && keys.every((key) => /^\[(0|[1-9]\d*)\]$/.test(key))) {
|
|
92
|
+
for (const key of keys) {
|
|
93
|
+
const index = Number(key.slice(1, -1));
|
|
94
|
+
// A higher-scope array may mask the patched array with a different length.
|
|
95
|
+
if (index >= value.length) { put(path, value); return; }
|
|
96
|
+
touched(patch[key]!, value[index], [...path, index]);
|
|
97
|
+
}
|
|
98
|
+
} else if (isObject(value)) {
|
|
99
|
+
for (const key of keys) touched(patch[key]!, child(value, key), [...path, key]);
|
|
100
|
+
} else put(path, value);
|
|
101
|
+
};
|
|
102
|
+
diff(previous, current, []);
|
|
103
|
+
for (const patch of Object.values(patches)) for (const [plane, value] of Object.entries(patch)) {
|
|
104
|
+
if (plane === "lazy") continue;
|
|
105
|
+
if (plane === "artifacts" && isObject(value)) {
|
|
106
|
+
for (const path of Object.keys(value)) put([plane, path], child(current.artifacts, path));
|
|
107
|
+
} else touched(value as JsonValue, child(current, plane), [plane]);
|
|
108
|
+
}
|
|
109
|
+
return { effective, ...(navigation !== undefined && (beforeNavigation === undefined || !sameJson(beforeNavigation, navigation)) ? { lazy_navigation: navigation } : {}) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface ContextView {
|
|
113
|
+
state: ModelState;
|
|
114
|
+
lazy_navigation?: ReturnType<typeof lazyNavigationHint>;
|
|
115
|
+
artifact_invalidations: readonly ArtifactInvalidationNotice[];
|
|
116
|
+
knowledge_rehydration: { phase: RehydrationPhase } | null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function contextView(state: MaterializedState, hints: ArtifactModelHints, invalidations: readonly ArtifactInvalidationNotice[], phase?: RehydrationPhase): ContextView {
|
|
120
|
+
return { state: projectStateForModel(state, hints), lazy_navigation: lazyNavigationHint(state),
|
|
121
|
+
artifact_invalidations: structuredClone(invalidations), knowledge_rehydration: phase === undefined ? null : { phase } };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Volatile model projection only. Native messages own trajectory; this cache owns no persistence or lifecycle. */
|
|
125
|
+
export class ContextProjection {
|
|
126
|
+
private identity = randomUUID();
|
|
127
|
+
private head: AgentMessage | undefined;
|
|
128
|
+
private view: ContextView | undefined;
|
|
129
|
+
private native: string[] = [];
|
|
130
|
+
private notices: Array<{ after: number; message: AgentMessage }> = [];
|
|
131
|
+
|
|
132
|
+
reset(): void {
|
|
133
|
+
this.identity = randomUUID();
|
|
134
|
+
this.head = undefined;
|
|
135
|
+
this.view = undefined;
|
|
136
|
+
this.native = [];
|
|
137
|
+
this.notices = [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Called only after successful publication and ancillary acceptance, immediately before returning the native result. */
|
|
141
|
+
acceptPatch(before: MaterializedState, after: MaterializedState, patches: AtomicScopePatches, hints: ArtifactModelHints) {
|
|
142
|
+
const state = projectStateForModel(after, hints);
|
|
143
|
+
const navigation = lazyNavigationHint(after);
|
|
144
|
+
const beforeNavigation = this.view?.lazy_navigation ?? lazyNavigationHint(before);
|
|
145
|
+
const updates = projectedStateUpdates(this.view?.state ?? projectStateForModel(before, hints), state, patches,
|
|
146
|
+
beforeNavigation, navigation);
|
|
147
|
+
// Suppress direct writes only when the accepted effective value matches.
|
|
148
|
+
// Overlap stays conservative except for explicit top-scope replacements:
|
|
149
|
+
// Session scalars/arrays mask every lower-scope value at that path.
|
|
150
|
+
const leaves: Array<{ scope: StateScope; path: (string | number)[]; value: JsonValue; artifact?: true }> = [];
|
|
151
|
+
const objects: typeof leaves = [];
|
|
152
|
+
const known = (path: readonly (string | number)[]): JsonValue | undefined => {
|
|
153
|
+
let value: JsonValue | undefined = this.view?.state;
|
|
154
|
+
for (const part of path) {
|
|
155
|
+
if (value === undefined || value === null || typeof value !== "object" || !Object.hasOwn(value, part)) return undefined;
|
|
156
|
+
value = (value as Record<string | number, JsonValue>)[part];
|
|
157
|
+
}
|
|
158
|
+
return value;
|
|
159
|
+
};
|
|
160
|
+
const visit = (scope: StateScope, value: JsonValue, path: (string | number)[]) => {
|
|
161
|
+
if (isObject(value) && Object.keys(value).length > 0) {
|
|
162
|
+
// Diff may coalesce a newly created/replaced object at this path.
|
|
163
|
+
// Keep its authored value without widening the overlap frontier.
|
|
164
|
+
objects.push({ scope, path, value });
|
|
165
|
+
const basis = known(path);
|
|
166
|
+
const entries = Object.entries(value);
|
|
167
|
+
const indexed = Array.isArray(basis) && entries.every(([key]) => {
|
|
168
|
+
if (!/^\[(0|[1-9]\d*)\]$/.test(key)) return false;
|
|
169
|
+
const index = Number(key.slice(1, -1));
|
|
170
|
+
return Number.isSafeInteger(index) && index < basis.length;
|
|
171
|
+
});
|
|
172
|
+
for (const [key, child] of entries) visit(scope, child,
|
|
173
|
+
[...path, indexed ? Number(key.slice(1, -1)) : key]);
|
|
174
|
+
} else leaves.push({ scope, path, value });
|
|
175
|
+
};
|
|
176
|
+
for (const scope of ["global", "cwd", "session"] as const) for (const [plane, value] of Object.entries(patches[scope] ?? {})) {
|
|
177
|
+
if (plane === "lazy") continue;
|
|
178
|
+
if (plane === "artifacts" && isObject(value)) {
|
|
179
|
+
for (const [path, card] of Object.entries(value)) leaves.push({ scope, path: ["artifacts", path], value: card, artifact: true });
|
|
180
|
+
} else visit(scope, value as JsonValue, [plane]);
|
|
181
|
+
}
|
|
182
|
+
const prefix = (a: readonly (string | number)[], b: readonly (string | number)[]) =>
|
|
183
|
+
a.length <= b.length && a.every((part, index) => part === b[index]);
|
|
184
|
+
updates.effective = updates.effective.filter((entry) => {
|
|
185
|
+
const matches = ({ path }: typeof leaves[number]) => path.length === entry.path.length && prefix(path, entry.path);
|
|
186
|
+
const authored = leaves.findLast(matches) ?? objects.findLast(matches);
|
|
187
|
+
if (!authored) return true;
|
|
188
|
+
const sessionReplacement = authored.scope === "session" && authored.value !== null && !isObject(authored.value);
|
|
189
|
+
if (!sessionReplacement && leaves.some(({ scope, path }) => scope !== authored.scope && (prefix(path, authored.path) || prefix(authored.path, path)))) return true;
|
|
190
|
+
if (authored.value !== null) {
|
|
191
|
+
if (authored.artifact) {
|
|
192
|
+
// Projected authored fields merge into the communicated card. Hints
|
|
193
|
+
// are not authored; keeping one is predictable, changing it is not.
|
|
194
|
+
const prior = known(entry.path);
|
|
195
|
+
const card = projectArtifactForModel(authored.value);
|
|
196
|
+
if (!isObject(card)) return true;
|
|
197
|
+
let expected: JsonValue = card;
|
|
198
|
+
if (isObject(prior)) {
|
|
199
|
+
try { expected = applyPatch(prior, card); }
|
|
200
|
+
catch {
|
|
201
|
+
// Canonical acceptance already succeeded. A masked effective
|
|
202
|
+
// array may reject an index valid in the authored scope.
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return !("value" in entry && sameJson(entry.value, expected));
|
|
207
|
+
}
|
|
208
|
+
return !("value" in entry && sameJson(entry.value, authored.value));
|
|
209
|
+
}
|
|
210
|
+
// A deletion cannot predict a fallback from effective state alone. It
|
|
211
|
+
// needs no echo only when the communicated and accepted values coincide.
|
|
212
|
+
if (!this.view) return true;
|
|
213
|
+
const before = known(entry.path);
|
|
214
|
+
return "value" in entry ? before === undefined || !sameJson(before, entry.value) : before !== undefined;
|
|
215
|
+
});
|
|
216
|
+
// A complete communicated key/kind catalog can predict non-deleting
|
|
217
|
+
// top-level lazy writes. Missing/over-budget catalogs, deletions and
|
|
218
|
+
// overlapping scopes cannot prove the post-patch navigation summary.
|
|
219
|
+
if (updates.lazy_navigation && this.view && (beforeNavigation.keys || !beforeNavigation.available) && navigation.keys) {
|
|
220
|
+
const expected = new Map(Object.entries(beforeNavigation.keys ?? {}));
|
|
221
|
+
let predictable = true;
|
|
222
|
+
const seen = new Set<string>();
|
|
223
|
+
for (const scope of ["global", "cwd", "session"] as const) for (const [key, value] of Object.entries(patches[scope]?.lazy ?? {})) {
|
|
224
|
+
if (seen.has(key) || value === null || isObject(value) && expected.get(key) === "array") predictable = false;
|
|
225
|
+
seen.add(key);
|
|
226
|
+
if (value !== null) expected.set(key, lazyValueKind(value));
|
|
227
|
+
}
|
|
228
|
+
if (predictable && seen.size > 0 && sameJson(Object.fromEntries(expected), navigation.keys)) delete updates.lazy_navigation;
|
|
229
|
+
}
|
|
230
|
+
if (this.view) this.view = { ...this.view, state, lazy_navigation: navigation };
|
|
231
|
+
return updates.effective.length || updates.lazy_navigation ? { projection: this.identity, ...updates } : undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
project(messages: AgentMessage[], current: ContextView, makeHead: () => AgentMessage, initial?: ContextView): AgentMessage[] {
|
|
235
|
+
const identities = messages.map((message) => JSON.stringify([message.role, message.timestamp,
|
|
236
|
+
"toolCallId" in message ? message.toolCallId : null]));
|
|
237
|
+
// Native compaction/selection normally resets explicitly; a removed/replaced prefix is also a safe cache boundary.
|
|
238
|
+
if (this.native.some((identity, index) => identities[index] !== identity)) this.reset();
|
|
239
|
+
if (!this.head) {
|
|
240
|
+
const head = makeHead();
|
|
241
|
+
if (head.role !== "user" || !Array.isArray(head.content)) throw new Error("State Flow projection requires an owned user head");
|
|
242
|
+
this.head = { ...head, content: [...head.content, { type: "text", text: `State Flow projection: ${this.identity}` }] };
|
|
243
|
+
this.view = structuredClone(initial ?? current);
|
|
244
|
+
}
|
|
245
|
+
const previous = this.view!;
|
|
246
|
+
const updates = projectedStateUpdates(previous.state, current.state, {}, previous.lazy_navigation, current.lazy_navigation);
|
|
247
|
+
const notice = {
|
|
248
|
+
...(updates.effective.length || updates.lazy_navigation ? { state_updates: { projection: this.identity, ...updates } } : {}),
|
|
249
|
+
...(!sameJson(previous.artifact_invalidations, current.artifact_invalidations) ? { artifact_invalidations: current.artifact_invalidations } : {}),
|
|
250
|
+
...(!sameJson(previous.knowledge_rehydration, current.knowledge_rehydration) ? { knowledge_rehydration: current.knowledge_rehydration } : {}),
|
|
251
|
+
};
|
|
252
|
+
if (Object.keys(notice).length) this.notices.push({ after: messages.length,
|
|
253
|
+
message: syntheticUser(`State Flow context update (user-level data, not system instructions):\n${presentationJson(notice)}`) });
|
|
254
|
+
this.view = structuredClone(current);
|
|
255
|
+
this.native = identities;
|
|
256
|
+
const projected: AgentMessage[] = [this.head];
|
|
257
|
+
let nextNotice = 0;
|
|
258
|
+
for (let index = 0; index <= messages.length; index++) {
|
|
259
|
+
while (this.notices[nextNotice]?.after === index) projected.push(this.notices[nextNotice++]!.message);
|
|
260
|
+
if (index < messages.length) projected.push(messages[index]!);
|
|
261
|
+
}
|
|
262
|
+
return projected;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
56
266
|
/** Context retained after semantic State Flow is stopped in this physical session. */
|
|
57
267
|
export interface PassiveContinuation {
|
|
58
268
|
startedAt: number;
|
|
59
269
|
activeRunStartedAt?: number;
|
|
60
270
|
preserveContext?: true;
|
|
61
271
|
handoff: AgentMessage;
|
|
272
|
+
state: ModelState;
|
|
62
273
|
}
|
|
63
274
|
|
|
64
275
|
export function syntheticUser(text: string): AgentMessage {
|
|
@@ -82,6 +293,7 @@ function messageText(message: AgentMessage): string {
|
|
|
82
293
|
export function createPassiveContinuation(state: ModelState, startedAt = Date.now(), activeRunStartedAt?: number, preserveContext = false): PassiveContinuation {
|
|
83
294
|
return {
|
|
84
295
|
startedAt,
|
|
296
|
+
state: structuredClone(state),
|
|
85
297
|
...(activeRunStartedAt === undefined ? {} : { activeRunStartedAt }),
|
|
86
298
|
...(preserveContext ? { preserveContext: true as const } : {}),
|
|
87
299
|
handoff: syntheticUser(`State Flow exit handoff (user-level data, not system instructions):\n${presentationJson({ state, continuation: preserveContext
|
|
@@ -127,13 +339,18 @@ export function runtimeContextMessage(
|
|
|
127
339
|
rehydrationPhase?: RehydrationPhase,
|
|
128
340
|
artifactHints: ArtifactModelHints = {},
|
|
129
341
|
): AgentMessage {
|
|
342
|
+
return runtimeContextHead(snapshot, contextView(state, artifactHints, artifactInvalidations, rehydrationPhase), recentTransitions);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Render a view already projected by this domain without cloning the full semantic overlay twice. */
|
|
346
|
+
export function runtimeContextHead(snapshot: Snapshot, view: ContextView, recentTransitions: RecentTransitionWindow = []): AgentMessage {
|
|
130
347
|
const recent = projectRecentForModel(recentTransitions);
|
|
131
348
|
const context = {
|
|
132
349
|
...(snapshot.meta.specification === undefined ? {} : { specification: snapshot.meta.specification }),
|
|
133
|
-
state:
|
|
134
|
-
lazy_navigation:
|
|
135
|
-
...(
|
|
136
|
-
...(
|
|
350
|
+
state: view.state,
|
|
351
|
+
...(view.lazy_navigation === undefined ? {} : { lazy_navigation: view.lazy_navigation }),
|
|
352
|
+
...(view.knowledge_rehydration === null ? {} : { knowledge_rehydration: view.knowledge_rehydration }),
|
|
353
|
+
...(view.artifact_invalidations.length === 0 ? {} : { artifact_invalidations: view.artifact_invalidations.map(({ path, scope, reason }) => ({ path, ...(scope === undefined ? {} : { scope }), reason })) }),
|
|
137
354
|
...(recent.length === 0 ? {} : { recent_transitions: recent }),
|
|
138
355
|
};
|
|
139
356
|
return syntheticUser(
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
} from "./artifact.ts";
|
|
17
17
|
import { hasCompactionSizedTranscript, planStateFlowCompaction, shouldRequestStateFlowCompaction, stateFlowCompactionResult, type StateFlowCompactionPlan } from "./compaction.ts";
|
|
18
18
|
import { loadStateFlowConfig } from "./config.ts";
|
|
19
|
-
import { createPassiveContinuation, currentRunTrajectory,
|
|
19
|
+
import { ContextProjection, contextView, createPassiveContinuation, currentRunTrajectory, passiveContinuationMessages, projectSystemProtocol, runtimeContextHead, syntheticUser, type PassiveContinuation } from "./context.ts";
|
|
20
20
|
import { readNativeSessionHeader } from "./continuation.ts";
|
|
21
21
|
import {
|
|
22
22
|
cwdScopeKey,
|
|
@@ -109,6 +109,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
109
109
|
const compactionMarker = `state-flow-boundary:${randomUUID()}`;
|
|
110
110
|
let passiveContinuation: PassiveContinuation | undefined;
|
|
111
111
|
let bootstrapContinuation: PassiveContinuation | undefined;
|
|
112
|
+
const contextProjection = new ContextProjection();
|
|
112
113
|
let inferencePreparation: InferencePreparation | undefined;
|
|
113
114
|
let runAnchorTimestamp: number | undefined;
|
|
114
115
|
let runtime: TemporalRuntime | undefined;
|
|
@@ -459,6 +460,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
459
460
|
|
|
460
461
|
/** Select the active native branch under one owned restoration lifetime; only current accepted work installs memory. */
|
|
461
462
|
function restoreActiveBranch(ctx: ExtensionContext, sessionStartReason?: unknown, notifyRecovery = true, startOwner?: AbortController): Promise<void> {
|
|
463
|
+
contextProjection.reset();
|
|
462
464
|
cancelBranchRestoration();
|
|
463
465
|
// Start-owned attachment/fork recovery keeps its owner; only accepted Start cancels Stop.
|
|
464
466
|
if (!startOwner) {
|
|
@@ -771,6 +773,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
771
773
|
const selected = runtime ??= createRuntime(ctx);
|
|
772
774
|
const acquiredArtifacts = structuredClone([...artifactReads.successful.values()]);
|
|
773
775
|
const acquiredSkills = structuredClone([...skillReads.successful.values()]);
|
|
776
|
+
const previousEffective = overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session);
|
|
774
777
|
return await selected.withPatchTransaction((transaction) => {
|
|
775
778
|
if (runtime !== selected) throw new Error("State Flow session selection changed while awaiting publication");
|
|
776
779
|
if (!passiveToolsAvailable()) throw new Error("State Flow tools are disabled by configuration");
|
|
@@ -800,9 +803,14 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
800
803
|
}
|
|
801
804
|
clearAcceptedAcquisitions(new Set(acquiredArtifacts.map(({ path }) => path)));
|
|
802
805
|
updateUi(ctx);
|
|
803
|
-
|
|
806
|
+
const updates = contextProjection.acceptPatch(previousEffective, overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session), patches, artifactHints);
|
|
807
|
+
const acknowledgement = changed
|
|
804
808
|
? `\nState materialized atomically at ${scopes.join("+")} scope${scopes.length === 1 ? "" : "s"}.`
|
|
805
|
-
: "\nState already current."
|
|
809
|
+
: "\nState already current.";
|
|
810
|
+
return { content: [
|
|
811
|
+
{ type: "text" as const, text: acknowledgement },
|
|
812
|
+
...(updates ? [{ type: "text" as const, text: `\n${presentationJson({ state_updates: updates })}` }] : []),
|
|
813
|
+
], details: { scopes, step: snapshot.meta.step, changed } };
|
|
806
814
|
}, signal);
|
|
807
815
|
} catch (error) {
|
|
808
816
|
let attempted: unknown;
|
|
@@ -908,6 +916,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
908
916
|
stopPersistenceError = undefined;
|
|
909
917
|
installScopeStates();
|
|
910
918
|
clearRunTransient();
|
|
919
|
+
contextProjection.reset();
|
|
911
920
|
passiveContinuation = undefined;
|
|
912
921
|
bootstrapContinuation = snapshot.meta.bootstrap ? continuation : undefined;
|
|
913
922
|
deferInferencePreparation();
|
|
@@ -993,6 +1002,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
993
1002
|
&& !shuttingDown && ctx.sessionManager.getSessionId() === owner && !snapshot.config.enabled;
|
|
994
1003
|
const superseded = (): StateFlowTelegramControlResult => ({ ok: false, message: "State Flow Stop was superseded" });
|
|
995
1004
|
const freezeHandoff = (): PassiveContinuation | undefined => {
|
|
1005
|
+
contextProjection.reset();
|
|
996
1006
|
const handoff = (current.config.enabled || stopPersistenceError) && selected?.view && current.meta.validation?.attempt !== 0
|
|
997
1007
|
? createPassiveContinuation(
|
|
998
1008
|
projectModelState(overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session)),
|
|
@@ -1112,6 +1122,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
1112
1122
|
(event.systemPromptOptions.sections ??= {}).state_flow = PASSIVE_MEMORY_PROTOCOL;
|
|
1113
1123
|
return;
|
|
1114
1124
|
}
|
|
1125
|
+
contextProjection.reset();
|
|
1115
1126
|
skillReads.clear();
|
|
1116
1127
|
artifactReads.clear();
|
|
1117
1128
|
cancelResponseReconciliation();
|
|
@@ -1129,40 +1140,30 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
1129
1140
|
|
|
1130
1141
|
function projectContext(messages: AgentMessage[]) {
|
|
1131
1142
|
if (runtime?.view) refreshArtifactHints();
|
|
1143
|
+
if (!snapshot.config.enabled && !passiveContinuation && (!config.passiveBootstrap || !runtime?.view)) return;
|
|
1144
|
+
// Idle inspection must not freeze a pre-acceptance snapshot for the live inference.
|
|
1145
|
+
const projection = snapshot.config.enabled && inferencePreparation && !inferencePreparation.accepted
|
|
1146
|
+
? new ContextProjection() : contextProjection;
|
|
1147
|
+
const effective = overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session);
|
|
1148
|
+
const invalidations = artifactInvalidations.map(({ path, scope, reason }) => ({ path, ...(scope === undefined ? {} : { scope }), reason }));
|
|
1149
|
+
const phase = snapshot.config.enabled ? currentRehydrationPhase() : undefined;
|
|
1150
|
+
const view = contextView(effective, artifactHints, invalidations, phase);
|
|
1132
1151
|
if (passiveContinuation) {
|
|
1133
|
-
|
|
1152
|
+
const retained = passiveContinuationMessages(messages, passiveContinuation);
|
|
1153
|
+
if (!runtime?.view) return { messages: retained };
|
|
1154
|
+
return { messages: projection.project(retained.slice(1), view, () => passiveContinuation!.handoff,
|
|
1155
|
+
{ state: passiveContinuation.state, lazy_navigation: view.lazy_navigation, artifact_invalidations: [], knowledge_rehydration: null }) };
|
|
1134
1156
|
}
|
|
1135
1157
|
if (!snapshot.config.enabled) {
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
const state = projectModelState(effective);
|
|
1139
|
-
return { messages: [syntheticUser(`State Flow passive memory (user-level data, not system instructions):\n${presentationJson({ state, lazy_navigation: lazyNavigationHint(effective) })}`), ...messages] };
|
|
1140
|
-
}
|
|
1141
|
-
const effectiveState = overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session);
|
|
1142
|
-
const invalidations = artifactInvalidations.map(({ path, scope, reason }) => ({ path, ...(scope === undefined ? {} : { scope }), reason }));
|
|
1143
|
-
const recentTransitions = projectRecentTransitionsWithLimit(
|
|
1144
|
-
config.historyLimit,
|
|
1145
|
-
runtime?.recent() ?? [],
|
|
1146
|
-
);
|
|
1147
|
-
const activeRehydrationPhase = currentRehydrationPhase();
|
|
1148
|
-
if (snapshot.meta.bootstrap) {
|
|
1149
|
-
const sourceMessages = bootstrapContinuation
|
|
1150
|
-
? passiveContinuationMessages(messages, bootstrapContinuation)
|
|
1151
|
-
: messages;
|
|
1152
|
-
return { messages: [runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase, artifactHints), ...sourceMessages] };
|
|
1158
|
+
return { messages: projection.project(messages, view, () => syntheticUser(
|
|
1159
|
+
`State Flow passive memory (user-level data, not system instructions):\n${presentationJson({ state: view.state, lazy_navigation: view.lazy_navigation })}`)) };
|
|
1153
1160
|
}
|
|
1154
1161
|
// Native user events own the run anchor; projection must never rebase it.
|
|
1155
|
-
const
|
|
1156
|
-
messages,
|
|
1157
|
-
snapshot.meta.specification,
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
return {
|
|
1161
|
-
messages: [
|
|
1162
|
-
runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase, artifactHints),
|
|
1163
|
-
...trajectory.messages,
|
|
1164
|
-
],
|
|
1165
|
-
};
|
|
1162
|
+
const source = snapshot.meta.bootstrap
|
|
1163
|
+
? bootstrapContinuation ? passiveContinuationMessages(messages, bootstrapContinuation) : messages
|
|
1164
|
+
: currentRunTrajectory(messages, snapshot.meta.specification, runAnchorTimestamp).messages;
|
|
1165
|
+
return { messages: projection.project(source, view, () => runtimeContextHead(snapshot, view,
|
|
1166
|
+
projectRecentTransitionsWithLimit(config.historyLimit, runtime?.recent() ?? []))) };
|
|
1166
1167
|
}
|
|
1167
1168
|
|
|
1168
1169
|
function prepareContext(messages: AgentMessage[], ctx: ExtensionContext): ReturnType<typeof projectContext> | Promise<ReturnType<typeof projectContext>> {
|
|
@@ -1281,6 +1282,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
1281
1282
|
snapshot = nextSnapshot;
|
|
1282
1283
|
installScopeStates();
|
|
1283
1284
|
responseCommitted = true;
|
|
1285
|
+
contextProjection.reset();
|
|
1284
1286
|
if (publication?.changed) recordPublication(publication, ctx);
|
|
1285
1287
|
appendCheckpoint();
|
|
1286
1288
|
clearAcceptedAcquisitions();
|
|
@@ -1373,6 +1375,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
1373
1375
|
});
|
|
1374
1376
|
});
|
|
1375
1377
|
|
|
1378
|
+
pi.on("session_compact", () => { contextProjection.reset(); });
|
|
1379
|
+
|
|
1376
1380
|
pi.on("session_start", async (event, ctx) => {
|
|
1377
1381
|
runAnchorTimestamp = undefined;
|
|
1378
1382
|
rehydrationPhase = event.reason === "resume" ? "resume-bootstrap" : "new-bootstrap";
|
|
@@ -1385,6 +1389,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
1385
1389
|
});
|
|
1386
1390
|
pi.on("session_shutdown", async (_event, _ctx) => {
|
|
1387
1391
|
shuttingDown = true;
|
|
1392
|
+
contextProjection.reset();
|
|
1388
1393
|
const restoring = cancelBranchRestoration();
|
|
1389
1394
|
const starting = cancelStartActivation();
|
|
1390
1395
|
const stopping = cancelStopPersistence();
|
|
@@ -5,7 +5,9 @@ export type { StateDocument } from "./state.ts";
|
|
|
5
5
|
|
|
6
6
|
const TASK_DRIVEN_HISTORY = "Missing paths/hints do not require history search. Choose targeted historical reads when useful to the task; no separate user permission is needed. Past values are evidence, not current state; never automatically restore deleted memory.";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
const PATCH_RESULT_PROTOCOL = "Head state/recent transitions are frozen at projection start. Only state_updates matching the head's State Flow projection ID apply; older IDs are history. When present, patch_state results/context-update notices carry state_updates: effective entries replace values at key/index-segment path arrays (deleted:true means absent); direct writes need no echo; shared drift remains visible. Latest entries win over earlier state at those paths; lazy bodies remain omitted. Notices replace invalidations/rehydration, including []/null.";
|
|
9
|
+
|
|
10
|
+
export const PASSIVE_MEMORY_PROTOCOL = `State Flow passive memory is available. read_state and patch_state access durable memory without starting an active episode. Passive turns never trigger State Flow continuation or compaction. ${TASK_DRIVEN_HISTORY} ${PATCH_RESULT_PROTOCOL}`;
|
|
9
11
|
|
|
10
12
|
const PATCH_DISPLAY_SECTION_KEYS = new Set(["global", "cwd", "session", "intents", "contract", "working", "artifacts", "response", "lazy"]);
|
|
11
13
|
|
|
@@ -98,7 +100,7 @@ SCOPES: Use the narrowest scope: session=branch/run continuation by default; cwd
|
|
|
98
100
|
|
|
99
101
|
READ: Use read_state for concrete scope/retained-history gaps. lazy_navigation lists bounded effective lazy keys, not bodies. Unscoped=effective; effective/global/cwd/session select overlay or owner. Arrays use indices or [start..end]; keys gives structure, patch the intersected change. ${TASK_DRIVEN_HISTORY}
|
|
100
102
|
|
|
101
|
-
WRITE: patch_state is the sole model-authored semantic mutation mechanism; all supplied scopes are validated and durably accepted as one atomic transition. Call alone in an assistant response; await acceptance. Global/CWD use current canonical values after cancelable lock waiting. Correct repeats succeed without new revisions.
|
|
103
|
+
WRITE: patch_state is the sole model-authored semantic mutation mechanism; all supplied scopes are validated and durably accepted as one atomic transition. Call alone in an assistant response; await acceptance. Global/CWD use current canonical values after cancelable lock waiting. Correct repeats succeed without new revisions. ${PATCH_RESULT_PROTOCOL}
|
|
102
104
|
|
|
103
105
|
PATCH: Use global/cwd/session object patches for material updates, not acknowledgments. Omit empty scopes. artifacts/contract/working/intents are objects; lazy is ordinary JSON. Omitted fields persist. Never patch runtime config/meta/response. Objects merge recursively; arrays/primitives replace. An object containing only canonical "[N]" keys recursively patches array elements. Indexed deletion is forbidden; nested object null deletes; materialized null is forbidden.
|
|
104
106
|
|
|
@@ -32,7 +32,9 @@ Scopes overlay `global → cwd → session`: cross-project, project, branch/run.
|
|
|
32
32
|
|
|
33
33
|
## Read
|
|
34
34
|
|
|
35
|
-
Reuse sufficient visible state. `
|
|
35
|
+
Reuse sufficient visible state. The memory head and its recent transitions are frozen at projection start. Apply later `state_updates` only when their `projection` matches the head's `State Flow projection:` ID; older native results remain historical. Effective update paths are key/index arrays whose values replace that path, while `deleted: true` means absence. Current notices can replace invalidation lists or rehydration phase, including clearing them with `[]` or `null`; lazy bodies still require explicit reads.
|
|
36
|
+
|
|
37
|
+
`read_state` accepts `path` or `paths`, never both. Multi-path reads succeed or fail together. Projections: `value` (default), `keys` (structure), `patch` (intersecting change at the selected boundary).
|
|
36
38
|
|
|
37
39
|
Example arguments:
|
|
38
40
|
|
|
@@ -125,7 +125,7 @@ The detailed map is canonical in [`docs/architecture.md`](./docs/architecture.md
|
|
|
125
125
|
- Command templates remain compact and shell-free. Use string leaves or ordered `template` arrays; shell operators are not an execution contract. Examples use portable executable placeholders, never machine-local paths.
|
|
126
126
|
- `telegram_attach` is the canonical file path and `telegram_message` the direct Markdown text/buttons path. Both require current direct or registered-follower authority and must not replace the normal active-turn reply.
|
|
127
127
|
- Inbound handlers transform text/media before queueing; outbound handlers precede programmatic/provider fallbacks. Public contracts and ordering live in `docs/inbound.md`, `docs/outbound.md`, and `docs/public-api.md`.
|
|
128
|
-
- Pi integration uses public hooks and APIs. Telegram `/new` is scheduled against the exact durable update, dispatched only after that update is removed from the journal, and then routed through the single `/telegram-internal` Pi gateway via `pi.sendUserMessage(..., { expandPromptTemplates: true })`; only a runtime-armed typed action may execute, manual invocation reports that the command cannot be run manually, and the handler receives the real `ExtensionCommandContext` before calling `ctx.newSession()`. Before replacement, CAS-publish one exact expiring handoff in the profile target snapshot. `workspace-thread` successors re-key the matching Workspace binding; `classic-chat` successors preserve Profile/CWD/session/chat continuity without creating a binding or invoking topic APIs. Both atomically claim the intent before one terminal result, and the old `withSession` path never publishes the same success. Never replace the session while inbound authority is unsettled, store stale command contexts, accept an expired or mismatched handoff, inject terminal input, spawn a shadow Pi process, or mutate session files.
|
|
128
|
+
- Pi integration uses public hooks and APIs. Telegram `/new` is scheduled against the exact durable update, dispatched only after that update is removed from the journal, and then routed through the single `/telegram-internal` Pi gateway via `pi.sendUserMessage(..., { expandPromptTemplates: true })`; only a runtime-armed typed action may execute, manual invocation reports that the command cannot be run manually, and the handler receives the real `ExtensionCommandContext` before calling `ctx.newSession()`. Before replacement, CAS-publish one exact expiring handoff in the profile target snapshot. `workspace-thread` successors re-key the matching Workspace binding; `classic-chat` successors preserve Profile/CWD/session/chat continuity without creating a binding or invoking topic APIs. Both atomically claim the intent before one terminal result, and the old `withSession` path never publishes the same success. A registered follower cannot persist leader-owned state: its Workspace Thread intent is published and claimed by the leader over capability-gated exact-generation bus RPC after leader-side binding validation, and records its source runtime instance so only that follower lineage may re-key or claim it. Never replace the session while inbound authority is unsettled, store stale command contexts, accept an expired or mismatched handoff, inject terminal input, spawn a shadow Pi process, or mutate session files.
|
|
129
129
|
|
|
130
130
|
## 7. Engineering Conventions
|
|
131
131
|
|
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.51.5: Follower Thread new-session hotfix
|
|
8
|
+
|
|
9
|
+
- `Follower Thread /new`: Telegram `/new` now starts a new session in a follower's Pi process and preserves its Thread binding. The leader publishes and later claims the durable replacement intent through capability-gated, generation-fenced bus requests, validating its own live registration and Workspace binding before accepting the follower's request. Incompatible or stale leaders fail closed rather than silently switching sessions.
|
|
10
|
+
- `Follower restore diagnostics`: Session startup now records when automatic follower restore lacks a session binding, Threaded Mode, or leader-side Workspace authority, or finishes without connecting. Restore-only refusal still cannot create a new Thread; `/telegram-connect` remains the explicit recovery path.
|
|
11
|
+
|
|
7
12
|
## 0.51.4: Resolver-owned Skills and drift-safe Git installs
|
|
8
13
|
|
|
9
14
|
- `Resolver-owned Skills`: Auto-discovered user/project checkouts now contribute source Skills even when Pi selects their compiled entrypoint, while manifest-loaded npm, Git, and Pi Kit packages retain `pi.skills` filters and package provenance. Resolver regressions cover checkout and filtered-package behavior; unsupported source manifest aliases are removed.
|
|
@@ -54,6 +54,8 @@ export interface TelegramBusFollowerRegistrationRuntime<TContext> {
|
|
|
54
54
|
threadId: number;
|
|
55
55
|
}) => Promise<string>;
|
|
56
56
|
setThreadDisplayMode?: (mode: TelegramThreadDisplayMode) => Promise<void>;
|
|
57
|
+
/** Leader-mediated durable publication or successor claim; true only after exact commit. */
|
|
58
|
+
requestSessionReplacement?: (operation: "publish" | "settle", intent: Threads.TelegramSessionReplacementIntent) => Promise<boolean>;
|
|
57
59
|
stop: () => void;
|
|
58
60
|
}
|
|
59
61
|
export interface TelegramBusFollowerSessionReplacementSuspenderDeps {
|
|
@@ -11,7 +11,7 @@ import * as Threads from "./threads.js";
|
|
|
11
11
|
import { parseTelegramUpdateJournalQueueOwner } from "./journal.js";
|
|
12
12
|
import { TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS, } from "./locks.js";
|
|
13
13
|
import { isTelegramApiMethodRetrySafe, TelegramApiCommitUnknownError, TelegramApiStaleTargetError, } from "./telegram-api.js";
|
|
14
|
-
import { createTelegramBusFollowerDeliveryIdentity, createTelegramBusFollowerTargetController, createTelegramBusForeignOwnedUpdateForwarder, createTelegramBusLocalServer, createTelegramBusRequestIdFactory, createUnauthorizedBusAck, getTelegramBusProtocolCompatibility, getTelegramBusSocketPath, hasTelegramBusCapability, isTelegramBusEnvelopeAuthorized, resolveTelegramBusSocketPath, sendTelegramBusLocalEnvelope, TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE, TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT, } from "./bus.js";
|
|
14
|
+
import { createTelegramBusFollowerDeliveryIdentity, createTelegramBusFollowerTargetController, createTelegramBusForeignOwnedUpdateForwarder, createTelegramBusLocalServer, createTelegramBusRequestIdFactory, createUnauthorizedBusAck, getTelegramBusProtocolCompatibility, getTelegramBusSocketPath, hasTelegramBusCapability, isTelegramBusEnvelopeAuthorized, resolveTelegramBusSocketPath, sendTelegramBusLocalEnvelope, TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE, TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT, TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT, } from "./bus.js";
|
|
15
15
|
import { getTelegramBusTransportRetryPolicy, TELEGRAM_BUS_REGISTRATION_RETRY, } from "./bus-transport.js";
|
|
16
16
|
import { createTelegramWorkspaceAdmissionOperationId, runWithTelegramWorkspaceAdmissionsAsync, } from "./workspace-admission.js";
|
|
17
17
|
export const TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS = 2_500;
|
|
@@ -1214,8 +1214,13 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
|
|
|
1214
1214
|
deps.setActiveAuthSecret?.(undefined);
|
|
1215
1215
|
await deps.stopReceiving?.();
|
|
1216
1216
|
if (registrationOptions?.restoreWorkspace &&
|
|
1217
|
-
response.error?.code === "workspace-binding-unavailable")
|
|
1217
|
+
response.error?.code === "workspace-binding-unavailable") {
|
|
1218
|
+
deps.recordRuntimeEvent?.("bus", "Telegram follower auto-connect refused by leader: Workspace binding unavailable.", {
|
|
1219
|
+
phase: "follower-auto-connect-skip",
|
|
1220
|
+
reason: "leader-binding-unavailable",
|
|
1221
|
+
});
|
|
1218
1222
|
return false;
|
|
1223
|
+
}
|
|
1219
1224
|
throw new Error(response.message ??
|
|
1220
1225
|
"Telegram bus follower registration was rejected.");
|
|
1221
1226
|
}
|
|
@@ -1426,6 +1431,54 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
|
|
|
1426
1431
|
}
|
|
1427
1432
|
return resetName;
|
|
1428
1433
|
},
|
|
1434
|
+
async requestSessionReplacement(operation, intent) {
|
|
1435
|
+
if (!activeLeaderSocketPath || !activeRegistrationGeneration) {
|
|
1436
|
+
throw new Error("Telegram follower is not registered with the leader.");
|
|
1437
|
+
}
|
|
1438
|
+
if (!hasTelegramBusCapability(deps.protocolIdentity, TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT) ||
|
|
1439
|
+
!hasTelegramBusCapability(deps.registrationState?.getLeaderProtocol(), TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT)) {
|
|
1440
|
+
throw new Error("The active Telegram leader does not support follower session replacement. Update or restart that Pi instance.");
|
|
1441
|
+
}
|
|
1442
|
+
const expectedLeaderSocketPath = activeLeaderSocketPath;
|
|
1443
|
+
const expectedRegistrationGeneration = activeRegistrationGeneration;
|
|
1444
|
+
const expectedAuthSecret = activeAuthSecret;
|
|
1445
|
+
const requestId = deps.createRequestId();
|
|
1446
|
+
const response = await sendTelegramBusLocalEnvelope({
|
|
1447
|
+
socketPath: expectedLeaderSocketPath,
|
|
1448
|
+
timeoutMs: registrationTimeoutMs,
|
|
1449
|
+
retry: getTelegramBusTransportRetryPolicy({
|
|
1450
|
+
endpoint: expectedLeaderSocketPath,
|
|
1451
|
+
operation: "operation",
|
|
1452
|
+
}),
|
|
1453
|
+
envelope: {
|
|
1454
|
+
kind: operation === "publish"
|
|
1455
|
+
? "follower.publishSessionReplacement"
|
|
1456
|
+
: "follower.settleSessionReplacement",
|
|
1457
|
+
requestId,
|
|
1458
|
+
auth: expectedAuthSecret,
|
|
1459
|
+
instanceId: deps.instanceId,
|
|
1460
|
+
registrationGeneration: expectedRegistrationGeneration,
|
|
1461
|
+
intent,
|
|
1462
|
+
sentAtMs: getNowMs(),
|
|
1463
|
+
},
|
|
1464
|
+
});
|
|
1465
|
+
if (response?.kind !== "bus.ack" || !response.ok ||
|
|
1466
|
+
response.requestId !== requestId ||
|
|
1467
|
+
!isRecord(response.result) || response.result.committed !== true) {
|
|
1468
|
+
throw new Error(response?.kind === "bus.ack"
|
|
1469
|
+
? (response.message ?? "Telegram session replacement was rejected.")
|
|
1470
|
+
: "Telegram session replacement was not acknowledged.");
|
|
1471
|
+
}
|
|
1472
|
+
if (activeLeaderSocketPath !== expectedLeaderSocketPath ||
|
|
1473
|
+
activeRegistrationGeneration !== expectedRegistrationGeneration ||
|
|
1474
|
+
activeAuthSecret !== expectedAuthSecret ||
|
|
1475
|
+
(deps.registrationState &&
|
|
1476
|
+
deps.registrationState.getGeneration() !==
|
|
1477
|
+
expectedRegistrationGeneration)) {
|
|
1478
|
+
throw new Error("Telegram session replacement completed for a stale follower registration.");
|
|
1479
|
+
}
|
|
1480
|
+
return true;
|
|
1481
|
+
},
|
|
1429
1482
|
async disconnectFromLeader() {
|
|
1430
1483
|
if (!activeLeaderSocketPath || !activeRegistrationGeneration) {
|
|
1431
1484
|
return false;
|
|
@@ -135,6 +135,23 @@ export interface TelegramBusLeaderRuntimeAssemblyDeps<TContext> {
|
|
|
135
135
|
runWorkspaceOperation?: TelegramBusWorkspaceAdmissionRunner;
|
|
136
136
|
workspaceRotation?: TelegramWorkspaceSlotRotationPorts;
|
|
137
137
|
}
|
|
138
|
+
export type TelegramBusFollowerSessionReplacementOperation = (follower: TelegramBusFollowerView, intent: Threads.TelegramSessionReplacementIntent, isCurrent: () => boolean) => Promise<boolean>;
|
|
139
|
+
/**
|
|
140
|
+
* Leader-owned durable session-replacement authority for registered followers.
|
|
141
|
+
* Followers cannot persist `state.json`; the leader validates the request
|
|
142
|
+
* against its live registry entry and authoritative Workspace binding before
|
|
143
|
+
* one CAS publication or claim. Follower memory is never accepted as binding
|
|
144
|
+
* evidence.
|
|
145
|
+
*/
|
|
146
|
+
export declare function createTelegramBusFollowerSessionReplacementAuthority(deps: {
|
|
147
|
+
store: Pick<Threads.TelegramTopicTargetStore, "load" | "getWorkspaceBindingByTarget" | "getSessionReplacementIntent" | "commitSessionReplacementIntent" | "removeSessionReplacementIntent">;
|
|
148
|
+
getTelegramProfile?: () => string | undefined;
|
|
149
|
+
getNowMs?: () => number;
|
|
150
|
+
ttlMs?: number;
|
|
151
|
+
}): {
|
|
152
|
+
publish: TelegramBusFollowerSessionReplacementOperation;
|
|
153
|
+
settle: TelegramBusFollowerSessionReplacementOperation;
|
|
154
|
+
};
|
|
138
155
|
export declare function createTelegramBusLeaderRuntimeAssembly<TContext>(deps: TelegramBusLeaderRuntimeAssemblyDeps<TContext>): TelegramBusLeaderRuntime<TContext> & {
|
|
139
156
|
renameLeaderThreadAdmitted: (threadName: string, expectedTarget?: Threads.TelegramTopicTargetRecord["target"]) => Promise<Threads.TelegramTopicTargetRecord>;
|
|
140
157
|
resetLeaderThreadName: (expectedTarget: Threads.TelegramTopicTargetRecord["target"]) => Promise<{
|
|
@@ -188,6 +205,8 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
|
|
|
188
205
|
}> | {
|
|
189
206
|
threadName: string;
|
|
190
207
|
};
|
|
208
|
+
publishFollowerSessionReplacement?: TelegramBusFollowerSessionReplacementOperation;
|
|
209
|
+
settleFollowerSessionReplacement?: TelegramBusFollowerSessionReplacementOperation;
|
|
191
210
|
getFollowerDisplayTitle?: (follower: TelegramBusFollowerView) => string | undefined;
|
|
192
211
|
onFollowerRegistered?: () => void;
|
|
193
212
|
applyThreadDisplayMode?: (mode: TelegramThreadDisplayMode, isCurrent: () => boolean) => Promise<void>;
|
|
@@ -273,6 +292,8 @@ export declare function createTelegramBusLeaderEnvelopeHandler(deps: {
|
|
|
273
292
|
}> | {
|
|
274
293
|
threadName: string;
|
|
275
294
|
};
|
|
295
|
+
publishFollowerSessionReplacement?: TelegramBusFollowerSessionReplacementOperation;
|
|
296
|
+
settleFollowerSessionReplacement?: TelegramBusFollowerSessionReplacementOperation;
|
|
276
297
|
getFollowerDisplayTitle?: (follower: TelegramBusFollowerView) => string | undefined;
|
|
277
298
|
onFollowerRegistered?: () => void;
|
|
278
299
|
applyThreadDisplayMode?: (mode: TelegramThreadDisplayMode, isCurrent: () => boolean) => Promise<void>;
|