@timurproko/a1 0.1.8-dev.b449b5c → 0.1.8-dev.c64be0e

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.
@@ -11,6 +11,12 @@ import { createPiRuntimeIntegration } from "./runtime-integration.js";
11
11
  import { PiSessionCommandIntegration } from "./session-integration.js";
12
12
  import { PiSettingsIntegration } from "./settings-integration.js";
13
13
  const execFileAsync = promisify(execFile);
14
+ /**
15
+ * Engine events delivered before the queue hands the event loop a turn. Small enough
16
+ * that a streaming burst never holds input, large enough that an ordinary turn is one
17
+ * batch.
18
+ */
19
+ const EVENT_DELIVERY_BATCH = 16;
14
20
  const DEFAULT_SURFACE = {
15
21
  columns: 100,
16
22
  rows: 32,
@@ -53,6 +59,8 @@ export class PiEngineAdapter {
53
59
  #activeCommandIds = [];
54
60
  #completedCommands = new Map();
55
61
  #transcript = [];
62
+ #transcriptIndex = new Map();
63
+ #transcriptSnapshot;
56
64
  #messageBlockIds = new WeakMap();
57
65
  #messageFallbackIds = new Map();
58
66
  #toolBlockIds = new Map();
@@ -61,6 +69,8 @@ export class PiEngineAdapter {
61
69
  #eventQueue = [];
62
70
  #eventQueueProcessing;
63
71
  #droppedEventCount = 0;
72
+ #agentRunActive = false;
73
+ #statusKind = null;
64
74
  #sessionCommands;
65
75
  #gitBranch = null;
66
76
  #extensionUi;
@@ -143,7 +153,7 @@ export class PiEngineAdapter {
143
153
  if (this.#disposed || updates.length === 0)
144
154
  return;
145
155
  const packages = updates.map(name => `- ${name}`).join("\n");
146
- this.#addDiagnostic("info", "package-updates", `Package updates are available. Run pi update --extensions\nPackages:\n${packages}`, true);
156
+ this.#addDiagnostic("info", "package-updates", `Package updates are available. Run ${PRODUCT_IDENTITY.commandName} pi update --extensions\nPackages:\n${packages}`, true);
147
157
  this.#emitView();
148
158
  }
149
159
  onEvent(listener) {
@@ -278,6 +288,7 @@ export class PiEngineAdapter {
278
288
  id: `extension-${resources.length}`,
279
289
  sourcePath: extension.path,
280
290
  resolvedPath: extension.resolvedPath,
291
+ sourceInfo: extensionSourceSummary(extension.sourceInfo),
281
292
  loaded: true,
282
293
  hidden: extension.hidden === true,
283
294
  diagnostic: null,
@@ -373,9 +384,10 @@ export class PiEngineAdapter {
373
384
  }
374
385
  const extensionCommands = this.#session?.extensionRunner?.getRegisteredCommands?.();
375
386
  if (Array.isArray(extensionCommands)) {
376
- for (const command of extensionCommands.filter(isRecord)) {
377
- const name = stringProperty(command, "name");
378
- if (!name || usedNames.has(name))
387
+ const registered = extensionCommands.filter(isRecord);
388
+ for (const command of registered) {
389
+ const name = stringProperty(command, "invocationName") ?? stringProperty(command, "name");
390
+ if (!name || usedNames.has(name) || isPiPrefixedCompatibilityAlias(command, registered))
379
391
  continue;
380
392
  commands.push({ name, description: stringProperty(command, "description") ?? "Extension command", source: "extension" });
381
393
  usedNames.add(name);
@@ -724,7 +736,7 @@ export class PiEngineAdapter {
724
736
  sessionId: this.#sessionId,
725
737
  revision: this.#viewRevision,
726
738
  lifecycle: this.#lifecycle,
727
- transcript: this.#transcript.map(block => ({ ...block })),
739
+ transcript: this.#transcriptSnapshot ??= Object.freeze([...this.#transcript]),
728
740
  editor: { ...this.#editor, queuedSubmissions: [...this.#editor.queuedSubmissions] },
729
741
  status: {
730
742
  ...this.#status,
@@ -1350,6 +1362,8 @@ export class PiEngineAdapter {
1350
1362
  submitEnabled: true,
1351
1363
  };
1352
1364
  this.#status = { ...this.#status, workingMessage: null, badges: [] };
1365
+ this.#statusKind = null;
1366
+ this.#agentRunActive = false;
1353
1367
  this.#activeModel = readModel(session.model);
1354
1368
  this.#reconcileActiveModelAvailability();
1355
1369
  this.#thinkingLevel = readThinkingLevel(session.thinkingLevel);
@@ -1402,28 +1416,76 @@ export class PiEngineAdapter {
1402
1416
  this.#emitView();
1403
1417
  }
1404
1418
  }
1419
+ /**
1420
+ * Replaces the transcript and the index that finds a block by its identifier. Every
1421
+ * lookup goes through the index, so streaming a chunk costs the same in a long session
1422
+ * as in a new one.
1423
+ */
1424
+ #setTranscript(blocks) {
1425
+ this.#transcript = blocks;
1426
+ this.#transcriptIndex.clear();
1427
+ for (const [index, block] of blocks.entries())
1428
+ this.#transcriptIndex.set(block.id, index);
1429
+ this.#transcriptSnapshot = undefined;
1430
+ }
1431
+ #transcriptBlock(id) {
1432
+ const index = this.#transcriptIndex.get(id);
1433
+ return index === undefined ? undefined : this.#transcript[index];
1434
+ }
1435
+ /** Shows the state named by `kind`, which becomes the state a later end can clear. */
1436
+ #enterWorkState(kind, message) {
1437
+ const wasBusy = this.#lifecycle === "busy";
1438
+ this.#statusKind = kind;
1439
+ this.#lifecycle = "busy";
1440
+ this.#status = { ...this.#status, workingMessage: message };
1441
+ if (!wasBusy)
1442
+ this.#emitEvent({ type: "session-lifecycle", lifecycle: "busy", reason: null });
1443
+ this.#emitEvent({ type: "status", status: this.#status });
1444
+ }
1445
+ /**
1446
+ * Ends one named state. A state the shell is not in is left alone, so a finished
1447
+ * compaction or retry cannot clear the working state it never replaced. While the run
1448
+ * continues, ending either of those returns to working rather than to idle.
1449
+ */
1450
+ #endWorkState(kind) {
1451
+ if (this.#statusKind !== kind)
1452
+ return;
1453
+ if (this.#agentRunActive) {
1454
+ this.#enterWorkState("working", "Working...");
1455
+ return;
1456
+ }
1457
+ this.#leaveWorkStates();
1458
+ }
1459
+ /** Leaves every work state and reports the session idle. */
1460
+ #leaveWorkStates() {
1461
+ this.#statusKind = null;
1462
+ this.#lifecycle = "ready";
1463
+ this.#status = { ...this.#status, workingMessage: null };
1464
+ this.#emitEvent({ type: "session-lifecycle", lifecycle: "ready", reason: null });
1465
+ this.#emitEvent({ type: "status", status: this.#status });
1466
+ }
1405
1467
  #handlePiEvent(event) {
1406
1468
  if (!isRecord(event) || typeof event.type !== "string")
1407
1469
  return;
1408
1470
  switch (event.type) {
1409
1471
  case "agent_start":
1410
- this.#lifecycle = "busy";
1411
- this.#status = { ...this.#status, workingMessage: "Working..." };
1412
- this.#emitEvent({ type: "session-lifecycle", lifecycle: "busy", reason: null });
1413
- this.#emitEvent({ type: "status", status: this.#status });
1472
+ this.#agentRunActive = true;
1473
+ this.#enterWorkState("working", "Working...");
1414
1474
  return;
1415
1475
  case "message_start":
1416
1476
  this.#upsertMessageBlock(event.message, "live");
1417
1477
  return;
1418
1478
  case "message_update": {
1419
- const block = this.#upsertMessageBlock(event.message, "live");
1420
- if (block && isRecord(event.assistantMessageEvent) && typeof event.assistantMessageEvent.delta === "string") {
1421
- this.#upsertTranscriptBlock({
1422
- ...block,
1423
- text: block.text.endsWith(event.assistantMessageEvent.delta)
1424
- ? block.text
1425
- : `${block.text}${event.assistantMessageEvent.delta}`,
1426
- });
1479
+ const delta = isRecord(event.assistantMessageEvent) && typeof event.assistantMessageEvent.delta === "string"
1480
+ ? event.assistantMessageEvent.delta
1481
+ : undefined;
1482
+ // The delta is folded in before the block is stored, so a chunk is one update to
1483
+ // one block rather than a store without the delta followed by a store with it.
1484
+ const blocks = this.#messageBlocks(event.message, "live", this.#transcript.length);
1485
+ for (const [index, block] of blocks.entries()) {
1486
+ this.#upsertTranscriptBlock(index === 0 && delta !== undefined && !block.text.endsWith(delta)
1487
+ ? { ...block, text: `${block.text}${delta}` }
1488
+ : block);
1427
1489
  }
1428
1490
  return;
1429
1491
  }
@@ -1452,11 +1514,18 @@ export class PiEngineAdapter {
1452
1514
  if (finalMessages.length > 0)
1453
1515
  this.#rebuildTranscript(finalMessages, "finalized");
1454
1516
  else
1455
- this.#transcript = this.#transcript.map(block => block.status === "live" ? { ...block, status: "finalized" } : block);
1456
- this.#lifecycle = "ready";
1457
- this.#status = { ...this.#status, workingMessage: null };
1458
- this.#emitEvent({ type: "session-lifecycle", lifecycle: "ready", reason: null });
1459
- this.#emitEvent({ type: "status", status: this.#status });
1517
+ this.#setTranscript(this.#transcript.map(block => block.status === "live" ? { ...block, status: "finalized" } : block));
1518
+ // Ending a turn leaves the working state, as the recorded pinned baseline does, but
1519
+ // it leaves only that state: a compaction or retry being shown outlives the turn
1520
+ // that ended under it. Settlement ends the run, and with it every state — the
1521
+ // engine ends a turn for each continuation it makes and settles once.
1522
+ if (event.type === "agent_settled") {
1523
+ this.#agentRunActive = false;
1524
+ this.#leaveWorkStates();
1525
+ }
1526
+ else if (this.#statusKind === null || this.#statusKind === "working") {
1527
+ this.#leaveWorkStates();
1528
+ }
1460
1529
  this.#emitView();
1461
1530
  return;
1462
1531
  }
@@ -1472,20 +1541,16 @@ export class PiEngineAdapter {
1472
1541
  return;
1473
1542
  }
1474
1543
  case "auto_retry_start":
1475
- this.#lifecycle = "busy";
1476
- this.#status = { ...this.#status, workingMessage: "Retrying…" };
1477
- this.#emitEvent({ type: "status", status: this.#status });
1544
+ this.#enterWorkState("retry", "Retrying…");
1478
1545
  return;
1479
1546
  case "auto_retry_end":
1480
- case "compaction_end":
1481
- this.#lifecycle = "ready";
1482
- this.#status = { ...this.#status, workingMessage: null };
1483
- this.#emitEvent({ type: "status", status: this.#status });
1547
+ this.#endWorkState("retry");
1484
1548
  return;
1485
1549
  case "compaction_start":
1486
- this.#lifecycle = "busy";
1487
- this.#status = { ...this.#status, workingMessage: "Compacting…" };
1488
- this.#emitEvent({ type: "status", status: this.#status });
1550
+ this.#enterWorkState("compaction", "Compacting…");
1551
+ return;
1552
+ case "compaction_end":
1553
+ this.#endWorkState("compaction");
1489
1554
  return;
1490
1555
  case "thinking_level_changed":
1491
1556
  this.#thinkingLevel = readThinkingLevel(event.level);
@@ -1575,7 +1640,13 @@ export class PiEngineAdapter {
1575
1640
  }
1576
1641
  }
1577
1642
  }
1578
- this.#transcript = blocks;
1643
+ // An authoritative rebuild restates most of what is already there. Reusing the block
1644
+ // that already says it keeps its revision, and with it the rows the shell rendered for
1645
+ // it — otherwise every turn that ends re-renders the whole session.
1646
+ this.#setTranscript(blocks.map(block => {
1647
+ const existing = this.#transcriptBlock(block.id);
1648
+ return existing !== undefined && sameBlockContent(existing, block) ? existing : block;
1649
+ }));
1579
1650
  }
1580
1651
  #upsertMessageBlock(message, status) {
1581
1652
  const blocks = this.#messageBlocks(message, status, this.#transcript.length);
@@ -1660,7 +1731,7 @@ export class PiEngineAdapter {
1660
1731
  : this.#toolBlockIds.get(toolCallId) ?? `tool-${toolCallId}`;
1661
1732
  if (toolCallId !== undefined)
1662
1733
  this.#toolBlockIds.set(toolCallId, blockId);
1663
- const existing = this.#transcript.find(block => block.id === blockId);
1734
+ const existing = this.#transcriptBlock(blockId);
1664
1735
  const existingPayload = isRecord(existing?.payload) ? existing.payload : undefined;
1665
1736
  return [{
1666
1737
  id: blockId,
@@ -1751,11 +1822,20 @@ export class PiEngineAdapter {
1751
1822
  });
1752
1823
  }
1753
1824
  #upsertTranscriptBlock(block) {
1754
- const index = this.#transcript.findIndex(existing => existing.id === block.id);
1755
- if (index >= 0)
1825
+ const index = this.#transcriptIndex.get(block.id);
1826
+ if (index !== undefined) {
1827
+ const existing = this.#transcript[index];
1828
+ // Nothing to tell the shell about a block that repeats itself, and keeping the
1829
+ // revision keeps the rows it already rendered.
1830
+ if (existing !== undefined && sameBlockContent(existing, block))
1831
+ return;
1756
1832
  this.#transcript[index] = block;
1757
- else
1833
+ }
1834
+ else {
1835
+ this.#transcriptIndex.set(block.id, this.#transcript.length);
1758
1836
  this.#transcript.push(block);
1837
+ }
1838
+ this.#transcriptSnapshot = undefined;
1759
1839
  this.#emitEvent({ type: "transcript-block", block });
1760
1840
  }
1761
1841
  #messageBlockId(message, fallbackIndex, status, occurrence) {
@@ -1773,7 +1853,7 @@ export class PiEngineAdapter {
1773
1853
  }
1774
1854
  }
1775
1855
  else {
1776
- id = [...cached].reverse().find(candidate => this.#transcript.some(block => block.id === candidate && block.status === "live"));
1856
+ id = [...cached].reverse().find(candidate => this.#transcriptBlock(candidate)?.status === "live");
1777
1857
  if (id === undefined && status === "finalized")
1778
1858
  id = cached.at(-1);
1779
1859
  if (id === undefined) {
@@ -1786,7 +1866,7 @@ export class PiEngineAdapter {
1786
1866
  return id;
1787
1867
  }
1788
1868
  #nextBlockRevision(id) {
1789
- const existing = this.#transcript.find(block => block.id === id);
1869
+ const existing = this.#transcriptBlock(id);
1790
1870
  if (existing)
1791
1871
  return existing.revision + 1;
1792
1872
  this.#nextBlockSequence += 1;
@@ -1847,6 +1927,7 @@ export class PiEngineAdapter {
1847
1927
  }
1848
1928
  async #processEventQueue() {
1849
1929
  try {
1930
+ let deliveredSinceYield = 0;
1850
1931
  while (this.#eventQueue.length > 0) {
1851
1932
  const event = this.#eventQueue.shift();
1852
1933
  if (!event)
@@ -1859,6 +1940,14 @@ export class PiEngineAdapter {
1859
1940
  this.#recordDiagnostic("warning", "event-listener", error instanceof Error ? error.message : String(error), true);
1860
1941
  }
1861
1942
  }
1943
+ deliveredSinceYield += 1;
1944
+ // A microtask chain runs to exhaustion before the loop turns, so a streaming
1945
+ // burst would hold typed input, pointer reports, and timed indicators until it
1946
+ // drained. Yielding on a macrotask hands those their turn between batches.
1947
+ if (deliveredSinceYield >= EVENT_DELIVERY_BATCH && this.#eventQueue.length > 0) {
1948
+ deliveredSinceYield = 0;
1949
+ await new Promise(resolve => { setImmediate(resolve); });
1950
+ }
1862
1951
  }
1863
1952
  }
1864
1953
  finally {
@@ -2245,11 +2334,26 @@ function extensionResourceDiagnostic(index, sourcePath, diagnostic) {
2245
2334
  id: `extension-diagnostic-${index}`,
2246
2335
  sourcePath,
2247
2336
  resolvedPath: null,
2337
+ sourceInfo: null,
2248
2338
  loaded: false,
2249
2339
  hidden: false,
2250
2340
  diagnostic,
2251
2341
  };
2252
2342
  }
2343
+ function extensionSourceSummary(value) {
2344
+ if (!isRecord(value))
2345
+ return null;
2346
+ const source = stringProperty(value, "source");
2347
+ const scope = value.scope;
2348
+ const origin = value.origin;
2349
+ const baseDir = value.baseDir;
2350
+ if (!source
2351
+ || (scope !== "user" && scope !== "project" && scope !== "temporary")
2352
+ || (origin !== "package" && origin !== "top-level")
2353
+ || (baseDir !== undefined && typeof baseDir !== "string"))
2354
+ return null;
2355
+ return { source, scope, origin, baseDir: baseDir ?? null };
2356
+ }
2253
2357
  function collectionResult(value, key) {
2254
2358
  if (!isRecord(value))
2255
2359
  return { values: [], diagnostics: [] };
@@ -2274,10 +2378,60 @@ function stringProperty(value, key) {
2274
2378
  const item = value[key];
2275
2379
  return typeof item === "string" && item.length > 0 ? item : undefined;
2276
2380
  }
2381
+ /**
2382
+ * Some ecosystem extensions retain a `pi-<name>` slash-command alias beside
2383
+ * their unprefixed command. A1 presents the product-neutral command once while
2384
+ * leaving Pi's runner free to accept the compatibility alias when typed.
2385
+ */
2386
+ function isPiPrefixedCompatibilityAlias(command, commands) {
2387
+ const name = stringProperty(command, "name");
2388
+ if (!name?.startsWith("pi-") || name.length === 3)
2389
+ return false;
2390
+ const canonicalName = name.slice(3);
2391
+ const description = stringProperty(command, "description");
2392
+ const sourcePath = extensionCommandSourcePath(command);
2393
+ return commands.some(candidate => candidate !== command
2394
+ && stringProperty(candidate, "name") === canonicalName
2395
+ && stringProperty(candidate, "description") === description
2396
+ && extensionCommandSourcePath(candidate) === sourcePath);
2397
+ }
2398
+ function extensionCommandSourcePath(command) {
2399
+ return isRecord(command) ? stringProperty(command.sourceInfo, "path") : undefined;
2400
+ }
2277
2401
  function compactResourceLabel(path) {
2278
2402
  const segments = path.replaceAll("\\", "/").split("/").filter(Boolean);
2279
2403
  return segments.at(-1) ?? path;
2280
2404
  }
2405
+ /**
2406
+ * Whether two blocks say the same thing. A block that says what it already said is not a
2407
+ * new revision: the shell renders a block once per revision, so bumping one it did not
2408
+ * need re-renders it for nothing.
2409
+ */
2410
+ function sameBlockContent(left, right) {
2411
+ return left.kind === right.kind
2412
+ && left.status === right.status
2413
+ && left.title === right.title
2414
+ && left.text === right.text
2415
+ && sameValue(left.payload, right.payload);
2416
+ }
2417
+ function sameValue(left, right) {
2418
+ if (left === right)
2419
+ return true;
2420
+ if (typeof left !== typeof right || left === null || right === null)
2421
+ return false;
2422
+ if (Array.isArray(left) || Array.isArray(right)) {
2423
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length)
2424
+ return false;
2425
+ return left.every((value, index) => sameValue(value, right[index]));
2426
+ }
2427
+ if (typeof left !== "object")
2428
+ return false;
2429
+ const leftKeys = Object.keys(left);
2430
+ const rightRecord = right;
2431
+ if (leftKeys.length !== Object.keys(rightRecord).length)
2432
+ return false;
2433
+ return leftKeys.every(key => Object.hasOwn(rightRecord, key) && sameValue(left[key], rightRecord[key]));
2434
+ }
2281
2435
  function textFromContent(content) {
2282
2436
  if (typeof content === "string")
2283
2437
  return content;
@@ -39,6 +39,12 @@ export declare class OwnedUiSessionShellRoot implements PiTuiComponentPort {
39
39
  readonly onDequeue?: () => void;
40
40
  }, startup?: PiShellHeaderOptions, agentDir?: string, extensionRenderers?: PiShellExtensionRendererResolver);
41
41
  update(view: OwnedUiSessionViewModel): void;
42
+ /**
43
+ * Applies one block: its component is created or updated in place and the order grows
44
+ * only when the block is new. A streamed chunk costs one component update rather than a
45
+ * walk of the whole transcript.
46
+ */
47
+ applyTranscriptBlock(block: OwnedUiSessionViewModel["transcript"][number]): void;
42
48
  render(width: number): readonly string[];
43
49
  layoutRoot(): PiTuiLayoutNode;
44
50
  transcriptComponent(id: string): PiShellTranscriptComponentPort | undefined;