@abelxiaoxing/cadence 1.0.1 → 1.0.2

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.
@@ -1,10 +1,17 @@
1
1
  // File-level snapshots: content hashes for observed files, deterministic
2
- // directory-entry manifests for observed directories, and explicit absent
2
+ // recursive content manifests for observed directories, and explicit absent
3
3
  // markers for proposed new paths. Currency is decided per bound path; no
4
4
  // global workspace revision participates.
5
5
  import { createHash } from "node:crypto";
6
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
7
- import { resolve } from "node:path";
6
+ import {
7
+ existsSync,
8
+ lstatSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ readlinkSync,
12
+ statSync,
13
+ } from "node:fs";
14
+ import { join, resolve } from "node:path";
8
15
 
9
16
  export interface FileBound {
10
17
  kind: "file";
@@ -37,7 +44,57 @@ export function snapshotFile(root: string, relPath: string): FileBound | null {
37
44
  return { sha256: sha256(bytes), bytes: bytes.length, kind: "file" };
38
45
  }
39
46
 
40
- /** Deterministic directory-entry manifest: hash of sorted name/type entries. */
47
+ function updateManifest(
48
+ hash: ReturnType<typeof createHash>,
49
+ ...fields: Array<string | Buffer>
50
+ ): void {
51
+ for (const field of fields) {
52
+ const bytes = typeof field === "string" ? Buffer.from(field) : field;
53
+ hash.update(`${bytes.length}:`);
54
+ hash.update(bytes);
55
+ }
56
+ }
57
+
58
+ function recursiveDirectoryManifest(abs: string): string {
59
+ const hash = createHash("sha256");
60
+ updateManifest(hash, "cadence-directory-manifest-v2");
61
+ const visit = (directory: string, prefix: string): void => {
62
+ const names = readdirSync(directory).sort();
63
+ for (const name of names) {
64
+ const relative = prefix ? `${prefix}/${name}` : name;
65
+ const current = join(directory, name);
66
+ const stat = lstatSync(current);
67
+ if (stat.isDirectory()) {
68
+ updateManifest(hash, "dir", relative, String(stat.mode & 0o777));
69
+ visit(current, relative);
70
+ } else if (stat.isFile()) {
71
+ const bytes = readFileSync(current);
72
+ updateManifest(
73
+ hash,
74
+ "file",
75
+ relative,
76
+ String(stat.mode & 0o777),
77
+ String(bytes.length),
78
+ sha256(bytes),
79
+ );
80
+ } else if (stat.isSymbolicLink()) {
81
+ updateManifest(hash, "symlink", relative, readlinkSync(current));
82
+ } else {
83
+ updateManifest(
84
+ hash,
85
+ "special",
86
+ relative,
87
+ String(stat.mode),
88
+ String(stat.size),
89
+ );
90
+ }
91
+ }
92
+ };
93
+ visit(abs, "");
94
+ return hash.digest("hex");
95
+ }
96
+
97
+ /** Deterministic recursive directory manifest bound to installed content. */
41
98
  export function snapshotDirManifest(
42
99
  root: string,
43
100
  relDir: string,
@@ -45,10 +102,7 @@ export function snapshotDirManifest(
45
102
  const abs = resolve(root, relDir);
46
103
  const st = statSync(abs, { throwIfNoEntry: false });
47
104
  if (!st?.isDirectory()) return null;
48
- const entries = readdirSync(abs, { withFileTypes: true })
49
- .map((e) => `${e.name}:${e.isDirectory() ? "dir" : "file"}`)
50
- .sort();
51
- return { manifest: sha256(Buffer.from(entries.join("\n"))), kind: "dir" };
105
+ return { manifest: recursiveDirectoryManifest(abs), kind: "dir" };
52
106
  }
53
107
 
54
108
  /** Build a bound map for the given file paths, observed directories, and absent markers. */
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ import type {
15
15
  } from "@earendil-works/pi-coding-agent";
16
16
  import { type Activation, activateTool, deactivateTool } from "./activation.ts";
17
17
  import { ACTIONS } from "./contracts.ts";
18
+ import { ParentPayloadBridge } from "./parent-payload-bridge.ts";
18
19
  import { Runtime } from "./runtime.ts";
19
20
  import {
20
21
  ACTIVITY_DETAILS_KEY,
@@ -68,20 +69,27 @@ function hasExpandedPromptMarker(
68
69
  );
69
70
  }
70
71
 
72
+ function isVerifiedStageInvocation(
73
+ pi: ExtensionAPI,
74
+ activation: Activation,
75
+ name: EligiblePrompt,
76
+ prompt: string,
77
+ ): boolean {
78
+ return (
79
+ hasPackageProvenance(pi, name) &&
80
+ hasExpandedPromptMarker(prompt, name) &&
81
+ (activation.isActive() || activation.state === "inactive")
82
+ );
83
+ }
84
+
71
85
  function activateDispatcher(
72
86
  pi: ExtensionAPI,
73
87
  activation: Activation,
74
88
  name: EligiblePrompt,
75
89
  prompt: string,
76
90
  ): void {
77
- if (
78
- !hasPackageProvenance(pi, name) ||
79
- !hasExpandedPromptMarker(prompt, name)
80
- ) {
81
- return;
82
- }
91
+ if (!isVerifiedStageInvocation(pi, activation, name, prompt)) return;
83
92
  if (!activation.isActive()) {
84
- if (activation.state !== "inactive") return;
85
93
  activation.request();
86
94
  activation.activate();
87
95
  }
@@ -92,7 +100,8 @@ function activateDispatcher(
92
100
  }
93
101
 
94
102
  export default function register(pi: ExtensionAPI): void {
95
- const runtime = new Runtime();
103
+ const parentPayloadBridge = new ParentPayloadBridge();
104
+ const runtime = new Runtime({ parentPayloadBridge });
96
105
  const activity = new ActivityController();
97
106
 
98
107
  pi.registerTool({
@@ -152,7 +161,7 @@ export default function register(pi: ExtensionAPI): void {
152
161
  };
153
162
  }
154
163
  return {
155
- content: [{ type: "text", text: result.error }],
164
+ content: [{ type: "text", text: JSON.stringify(result) }],
156
165
  details,
157
166
  isError: true,
158
167
  };
@@ -177,14 +186,37 @@ export default function register(pi: ExtensionAPI): void {
177
186
  return { action: "continue" };
178
187
  });
179
188
 
180
- pi.on("before_agent_start", (event) => {
189
+ pi.on("before_agent_start", (event, ctx) => {
181
190
  const prompt = pendingPrompt;
182
191
  pendingPrompt = undefined;
192
+ if (
193
+ prompt &&
194
+ isVerifiedStageInvocation(pi, runtime.activation, prompt, event.prompt)
195
+ ) {
196
+ const sessionId = ctx.sessionManager?.getSessionId?.();
197
+ if (typeof sessionId === "string") {
198
+ parentPayloadBridge.beginSession(sessionId);
199
+ } else {
200
+ parentPayloadBridge.clear();
201
+ }
202
+ }
203
+ if (ctx.model) {
204
+ parentPayloadBridge.install(ctx.model, ctx.modelRegistry);
205
+ }
183
206
  if (prompt)
184
207
  activateDispatcher(pi, runtime.activation, prompt, event.prompt);
185
208
  });
186
209
 
187
210
  pi.on("session_start", (_event, ctx) => {
211
+ const sessionId = ctx.sessionManager?.getSessionId?.();
212
+ if (typeof sessionId === "string") {
213
+ parentPayloadBridge.beginSession(sessionId);
214
+ if (ctx.model) {
215
+ parentPayloadBridge.install(ctx.model, ctx.modelRegistry);
216
+ }
217
+ } else {
218
+ parentPayloadBridge.clear();
219
+ }
188
220
  pendingPrompt = undefined;
189
221
  activity.detach();
190
222
  if (ctx.mode === "tui") activity.attach(ctx.ui);
@@ -196,6 +228,19 @@ export default function register(pi: ExtensionAPI): void {
196
228
  }
197
229
  });
198
230
 
231
+ pi.on("model_select", (event, ctx) => {
232
+ const sessionId = ctx.sessionManager?.getSessionId?.();
233
+ if (typeof sessionId !== "string") {
234
+ parentPayloadBridge.clear();
235
+ return;
236
+ }
237
+ parentPayloadBridge.beginSession(sessionId);
238
+ const model = event.model ?? ctx.model;
239
+ if (model) {
240
+ parentPayloadBridge.install(model, ctx.modelRegistry);
241
+ }
242
+ });
243
+
199
244
  pi.on("session_shutdown", async () => {
200
245
  activity.detach();
201
246
  await runtime.drain();
@@ -0,0 +1,442 @@
1
+ import {
2
+ type AssistantMessage,
3
+ type AssistantMessageEventStream,
4
+ type Context,
5
+ createAssistantMessageEventStream,
6
+ type Model,
7
+ type Provider,
8
+ } from "@earendil-works/pi-ai";
9
+
10
+ export type ParentPayloadCallback = (
11
+ payload: unknown,
12
+ model: Model<string>,
13
+ ) => unknown | undefined | Promise<unknown | undefined>;
14
+
15
+ export interface ParentModelKey {
16
+ provider: string;
17
+ id: string;
18
+ api: string;
19
+ baseUrl: string;
20
+ }
21
+
22
+ export interface ParentPayloadCapture {
23
+ readonly generation: number;
24
+ readonly sessionId: string;
25
+ readonly modelKey: Readonly<ParentModelKey>;
26
+ readonly delegate: Provider;
27
+ readonly onPayload: ParentPayloadCallback;
28
+ }
29
+
30
+ export interface ParentProviderRegistry {
31
+ getProvider(provider: string): Provider | undefined;
32
+ registerProvider(provider: Provider): void;
33
+ }
34
+
35
+ interface GenerationState {
36
+ readonly generation: number;
37
+ readonly sessionId: string;
38
+ active: boolean;
39
+ readonly ready: Map<string, ParentPayloadCapture>;
40
+ readonly installed: Map<string, Provider>;
41
+ tail: Promise<void>;
42
+ }
43
+
44
+ interface ProvisionalCapture {
45
+ readonly state: GenerationState;
46
+ readonly modelKey: ParentModelKey;
47
+ readonly delegate: Provider;
48
+ readonly onPayload: ParentPayloadCallback;
49
+ }
50
+
51
+ const PROVIDER_WRAPPER = Symbol("cadence-parent-payload-provider");
52
+
53
+ function streamFailure(model: Model<string>): AssistantMessage {
54
+ return {
55
+ role: "assistant",
56
+ content: [],
57
+ api: model.api,
58
+ provider: model.provider,
59
+ model: model.id,
60
+ usage: {
61
+ input: 0,
62
+ output: 0,
63
+ cacheRead: 0,
64
+ cacheWrite: 0,
65
+ totalTokens: 0,
66
+ cost: {
67
+ input: 0,
68
+ output: 0,
69
+ cacheRead: 0,
70
+ cacheWrite: 0,
71
+ total: 0,
72
+ },
73
+ },
74
+ stopReason: "error",
75
+ errorMessage: "parent provider stream failed",
76
+ timestamp: Date.now(),
77
+ };
78
+ }
79
+
80
+ function abortable<T>(
81
+ value: T | PromiseLike<T>,
82
+ signal?: AbortSignal,
83
+ ): Promise<T> {
84
+ if (!signal) return Promise.resolve(value);
85
+ signal.throwIfAborted();
86
+ return new Promise<T>((resolve, reject) => {
87
+ let settled = false;
88
+ const finish = (callback: () => void) => {
89
+ if (settled) return;
90
+ settled = true;
91
+ signal.removeEventListener("abort", onAbort);
92
+ callback();
93
+ };
94
+ const onAbort = () => {
95
+ try {
96
+ signal.throwIfAborted();
97
+ } catch (error) {
98
+ finish(() => reject(error));
99
+ }
100
+ };
101
+ signal.addEventListener("abort", onAbort, { once: true });
102
+ if (signal.aborted) onAbort();
103
+ Promise.resolve(value).then(
104
+ (result) => finish(() => resolve(result)),
105
+ (error) => finish(() => reject(error)),
106
+ );
107
+ });
108
+ }
109
+
110
+ function modelKeyFor(model: Model<string>): ParentModelKey {
111
+ return {
112
+ provider: model.provider,
113
+ id: model.id,
114
+ api: model.api,
115
+ baseUrl: model.baseUrl,
116
+ };
117
+ }
118
+
119
+ function modelKeyId(modelKey: Readonly<ParentModelKey>): string {
120
+ return JSON.stringify([
121
+ modelKey.provider,
122
+ modelKey.id,
123
+ modelKey.api,
124
+ modelKey.baseUrl,
125
+ ]);
126
+ }
127
+
128
+ function sameModelKey(
129
+ left: Readonly<ParentModelKey>,
130
+ right: Readonly<ParentModelKey>,
131
+ ): boolean {
132
+ return (
133
+ left.provider === right.provider &&
134
+ left.id === right.id &&
135
+ left.api === right.api &&
136
+ left.baseUrl === right.baseUrl
137
+ );
138
+ }
139
+
140
+ function requireSafePayload(
141
+ payload: unknown,
142
+ ): asserts payload is Record<string, unknown> {
143
+ if (
144
+ typeof payload !== "object" ||
145
+ payload === null ||
146
+ Array.isArray(payload)
147
+ ) {
148
+ throw new Error("parent payload bridge produced an unsafe payload");
149
+ }
150
+ }
151
+
152
+ export class ParentPayloadBridge {
153
+ private nextGeneration = 0;
154
+ private state: GenerationState | undefined;
155
+ private readonly captureStates = new WeakMap<
156
+ ParentPayloadCapture,
157
+ GenerationState
158
+ >();
159
+
160
+ beginSession(sessionId: string): number {
161
+ this.invalidate();
162
+ const state: GenerationState = {
163
+ generation: ++this.nextGeneration,
164
+ sessionId,
165
+ active: true,
166
+ ready: new Map(),
167
+ installed: new Map(),
168
+ tail: Promise.resolve(),
169
+ };
170
+ this.state = state;
171
+ return state.generation;
172
+ }
173
+
174
+ clear(): void {
175
+ this.invalidate();
176
+ this.nextGeneration++;
177
+ this.state = undefined;
178
+ }
179
+
180
+ install(
181
+ model: Model<string>,
182
+ registry: ParentProviderRegistry,
183
+ ): Provider | undefined {
184
+ const current = registry.getProvider(model.provider);
185
+ if (!current) return undefined;
186
+ const delegate = this.unwrapProvider(current);
187
+ const state = this.state;
188
+ if (state?.active) {
189
+ const key = modelKeyId(modelKeyFor(model));
190
+ state.installed.set(key, delegate);
191
+ const ready = state.ready.get(key);
192
+ if (ready && ready.delegate !== delegate) {
193
+ state.ready.delete(key);
194
+ }
195
+ }
196
+ const wrapped = this.wrapProvider(delegate);
197
+ registry.registerProvider(wrapped);
198
+ return wrapped;
199
+ }
200
+
201
+ capture(
202
+ modelKey: ParentModelKey,
203
+ registry?: Pick<ParentProviderRegistry, "getProvider">,
204
+ ): ParentPayloadCapture | undefined {
205
+ const state = this.state;
206
+ if (!state?.active) return undefined;
207
+ const capture = state.ready.get(modelKeyId(modelKey));
208
+ if (!capture || !sameModelKey(capture.modelKey, modelKey)) {
209
+ return undefined;
210
+ }
211
+ if (registry) {
212
+ const current = registry.getProvider(modelKey.provider);
213
+ if (!current || this.unwrapProvider(current) !== capture.delegate) {
214
+ return undefined;
215
+ }
216
+ }
217
+ return capture;
218
+ }
219
+
220
+ wrapProvider(delegate: Provider): Provider {
221
+ const original = this.unwrapProvider(delegate);
222
+ const stream = (
223
+ model: Model<string>,
224
+ context: Context,
225
+ options?: unknown,
226
+ ) => this.forwardProvider(original, "stream", model, context, options);
227
+ const streamSimple = (
228
+ model: Model<string>,
229
+ context: Context,
230
+ options?: unknown,
231
+ ) =>
232
+ this.forwardProvider(original, "streamSimple", model, context, options);
233
+ const boundMethods = new Map<
234
+ PropertyKey,
235
+ {
236
+ source: (...args: never[]) => unknown;
237
+ bound: (...args: never[]) => unknown;
238
+ }
239
+ >();
240
+
241
+ return new Proxy(original, {
242
+ get(target, property) {
243
+ if (property === PROVIDER_WRAPPER) return original;
244
+ if (property === "stream") return stream;
245
+ if (property === "streamSimple") return streamSimple;
246
+ const value = Reflect.get(target, property, target) as unknown;
247
+ if (typeof value !== "function") return value;
248
+ const current = boundMethods.get(property);
249
+ if (current?.source === value) return current.bound;
250
+ const source = value as (...args: never[]) => unknown;
251
+ const bound = source.bind(target);
252
+ boundMethods.set(property, { source, bound });
253
+ return bound;
254
+ },
255
+ set(target, property, value) {
256
+ return Reflect.set(target, property, value, target);
257
+ },
258
+ });
259
+ }
260
+
261
+ async composePayload(
262
+ capture: ParentPayloadCapture | undefined,
263
+ payload: unknown,
264
+ model: Model<string>,
265
+ childOnPayload?: ParentPayloadCallback,
266
+ signal?: AbortSignal,
267
+ ): Promise<Record<string, unknown>> {
268
+ if (!capture) throw new Error("parent payload bridge is unavailable");
269
+ const requestedKey = modelKeyFor(model);
270
+ const state = this.requireCurrentCapture(capture, requestedKey);
271
+ signal?.throwIfAborted();
272
+
273
+ let current = payload;
274
+ if (childOnPayload) {
275
+ const childResult = await abortable(
276
+ childOnPayload(current, model),
277
+ signal,
278
+ );
279
+ if (childResult !== undefined) current = childResult;
280
+ }
281
+ requireSafePayload(current);
282
+ signal?.throwIfAborted();
283
+ this.requireCurrentCapture(capture, requestedKey);
284
+
285
+ const predecessor = state.tail;
286
+ let release = () => {};
287
+ const completed = new Promise<void>((resolve) => {
288
+ release = () => resolve();
289
+ });
290
+ state.tail = predecessor.then(
291
+ () => completed,
292
+ () => completed,
293
+ );
294
+ let parentSettlement: Promise<void> | undefined;
295
+
296
+ try {
297
+ await abortable(predecessor, signal);
298
+ signal?.throwIfAborted();
299
+ this.requireCurrentCapture(capture, requestedKey);
300
+
301
+ const parentCall = Promise.resolve(capture.onPayload(current, model));
302
+ parentSettlement = parentCall.then(
303
+ () => undefined,
304
+ () => undefined,
305
+ );
306
+ const parentResult = await abortable(parentCall, signal);
307
+ if (parentResult !== undefined) current = parentResult;
308
+ requireSafePayload(current);
309
+
310
+ let finalPayload: Record<string, unknown> = current;
311
+ if (model.api === "openai-responses") {
312
+ finalPayload = { ...current };
313
+ delete finalPayload.max_output_tokens;
314
+ }
315
+
316
+ signal?.throwIfAborted();
317
+ this.requireCurrentCapture(capture, requestedKey);
318
+ return finalPayload;
319
+ } finally {
320
+ if (parentSettlement) void parentSettlement.then(release);
321
+ else release();
322
+ }
323
+ }
324
+
325
+ private forwardProvider(
326
+ delegate: Provider,
327
+ method: "stream" | "streamSimple",
328
+ model: Model<string>,
329
+ context: Context,
330
+ options?: unknown,
331
+ ): AssistantMessageEventStream {
332
+ const state = this.state;
333
+ const candidate = (options as { onPayload?: unknown } | null | undefined)
334
+ ?.onPayload;
335
+ const onPayload =
336
+ typeof candidate === "function"
337
+ ? (candidate as ParentPayloadCallback)
338
+ : undefined;
339
+ const provisional: ProvisionalCapture | undefined =
340
+ state?.active && onPayload
341
+ ? {
342
+ state,
343
+ modelKey: modelKeyFor(model),
344
+ delegate,
345
+ onPayload,
346
+ }
347
+ : undefined;
348
+ const source =
349
+ method === "stream"
350
+ ? delegate.stream(model, context, options as never)
351
+ : delegate.streamSimple(model, context, options as never);
352
+ const output = createAssistantMessageEventStream();
353
+ void this.mirrorStream(source, output, model, provisional);
354
+ return output;
355
+ }
356
+
357
+ private async mirrorStream(
358
+ source: AssistantMessageEventStream,
359
+ output: AssistantMessageEventStream,
360
+ model: Model<string>,
361
+ provisional: ProvisionalCapture | undefined,
362
+ ): Promise<void> {
363
+ let successful = false;
364
+ try {
365
+ for await (const event of source) {
366
+ if (event.type === "done") successful = true;
367
+ output.push(event);
368
+ }
369
+ if (successful && provisional) this.commitCapture(provisional);
370
+ } catch {
371
+ const error = streamFailure(model);
372
+ output.push({ type: "error", reason: "error", error });
373
+ } finally {
374
+ output.end();
375
+ }
376
+ }
377
+
378
+ private commitCapture(provisional: ProvisionalCapture): void {
379
+ const { state } = provisional;
380
+ if (!state.active || this.state !== state) return;
381
+ const key = modelKeyId(provisional.modelKey);
382
+ if (
383
+ state.installed.has(key) &&
384
+ state.installed.get(key) !== provisional.delegate
385
+ ) {
386
+ return;
387
+ }
388
+ const capture: ParentPayloadCapture = Object.freeze({
389
+ generation: state.generation,
390
+ sessionId: state.sessionId,
391
+ modelKey: Object.freeze({ ...provisional.modelKey }),
392
+ delegate: provisional.delegate,
393
+ onPayload: provisional.onPayload,
394
+ });
395
+ state.ready.set(key, capture);
396
+ this.captureStates.set(capture, state);
397
+ }
398
+
399
+ private requireCurrentCapture(
400
+ capture: ParentPayloadCapture | undefined,
401
+ modelKey: ParentModelKey,
402
+ ): GenerationState {
403
+ const state = this.state;
404
+ if (
405
+ !capture ||
406
+ !state?.active ||
407
+ this.captureStates.get(capture) !== state ||
408
+ capture.generation !== state.generation ||
409
+ capture.sessionId !== state.sessionId ||
410
+ !sameModelKey(capture.modelKey, modelKey) ||
411
+ state.ready.get(modelKeyId(modelKey)) !== capture
412
+ ) {
413
+ throw new Error("parent payload bridge is unavailable");
414
+ }
415
+ return state;
416
+ }
417
+
418
+ private unwrapProvider(provider: Provider): Provider {
419
+ let current = provider;
420
+ const seen = new Set<Provider>();
421
+ while (!seen.has(current)) {
422
+ seen.add(current);
423
+ const candidate = Reflect.get(current, PROVIDER_WRAPPER) as unknown;
424
+ if (
425
+ typeof candidate !== "object" ||
426
+ candidate === null ||
427
+ seen.has(candidate as Provider)
428
+ ) {
429
+ return current;
430
+ }
431
+ current = candidate as Provider;
432
+ }
433
+ return current;
434
+ }
435
+
436
+ private invalidate(): void {
437
+ if (!this.state) return;
438
+ this.state.active = false;
439
+ this.state.ready.clear();
440
+ this.state.installed.clear();
441
+ }
442
+ }