@averyyy/pi-server 0.85.1-piclient.2 → 0.85.1-piclient.3

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/dist/server.js CHANGED
@@ -15,6 +15,10 @@ const packageMetadata = createRequire(import.meta.url)("../package.json");
15
15
  const PI_SERVER_VERSION = packageMetadata.version;
16
16
  function createRequestModels(model, options) {
17
17
  const models = createModels();
18
+ const requestStream = (requestModel, context, streamOptions) => streamSimple(requestModel, context, {
19
+ ...streamOptions,
20
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
21
+ });
18
22
  models.setProvider(createProvider({
19
23
  id: model.provider,
20
24
  name: model.provider,
@@ -26,8 +30,8 @@ function createRequestModels(model, options) {
26
30
  },
27
31
  },
28
32
  api: {
29
- stream: (requestModel, context, streamOptions) => streamSimple(requestModel, context, streamOptions),
30
- streamSimple: (requestModel, context, streamOptions) => streamSimple(requestModel, context, streamOptions),
33
+ stream: requestStream,
34
+ streamSimple: requestStream,
31
35
  },
32
36
  }));
33
37
  return models;
@@ -91,27 +95,64 @@ function runKey(sessionId, runId) {
91
95
  function getStreamRun(sessionId, runId) {
92
96
  return streamRuns.get(runKey(sessionId, runId));
93
97
  }
94
- function startStreamRun(sessionId, runId) {
95
- const existing = getStreamRun(sessionId, runId);
96
- if (existing?.status === "completed")
97
- return existing;
98
- const now = Date.now();
99
- const run = existing ?? {
98
+ function createRunRecord(sessionId, runId, kind) {
99
+ let resolveSettled;
100
+ const settled = new Promise((resolve) => {
101
+ resolveSettled = resolve;
102
+ });
103
+ return {
100
104
  sessionId,
101
105
  runId,
106
+ kind,
102
107
  status: "running",
103
- createdAt: now,
104
- updatedAt: now,
108
+ createdAt: Date.now(),
109
+ updatedAt: Date.now(),
110
+ cancelRequested: false,
111
+ controller: new AbortController(),
112
+ settled,
113
+ resolveSettled,
105
114
  };
115
+ }
116
+ function settleRun(run) {
117
+ if (!run)
118
+ return;
119
+ const resolve = run.resolveSettled;
120
+ run.resolveSettled = undefined;
121
+ resolve?.();
122
+ }
123
+ function startStreamRun(sessionId, runId, kind = "stream") {
124
+ const existing = getStreamRun(sessionId, runId);
125
+ if (existing?.status === "completed" || existing?.status === "aborted")
126
+ return existing;
127
+ const run = existing ?? createRunRecord(sessionId, runId, kind);
106
128
  if (existing) {
107
129
  run.message = undefined;
108
130
  run.errorMessage = undefined;
131
+ run.cancelRequested = false;
132
+ run.controller = new AbortController();
133
+ let resolveSettled;
134
+ run.settled = new Promise((resolve) => {
135
+ resolveSettled = resolve;
136
+ });
137
+ run.resolveSettled = resolveSettled;
109
138
  }
139
+ run.kind = kind;
110
140
  run.status = "running";
111
- run.updatedAt = now;
141
+ run.updatedAt = Date.now();
112
142
  streamRuns.set(runKey(sessionId, runId), run);
113
143
  return run;
114
144
  }
145
+ function streamRunResponseBody(run) {
146
+ return {
147
+ sessionId: run.sessionId,
148
+ runId: run.runId,
149
+ status: run.status,
150
+ createdAt: run.createdAt,
151
+ updatedAt: run.updatedAt,
152
+ ...(run.message ? { message: run.message } : {}),
153
+ ...(run.errorMessage ? { errorMessage: run.errorMessage } : {}),
154
+ };
155
+ }
115
156
  function replayStreamRun(run) {
116
157
  if (!run.message)
117
158
  return [];
@@ -149,6 +190,13 @@ function replayStreamRun(run) {
149
190
  function completeStreamRun(run, message) {
150
191
  if (!run)
151
192
  return;
193
+ if (run.cancelRequested) {
194
+ run.status = "aborted";
195
+ run.message = undefined;
196
+ run.errorMessage = "Stream run aborted";
197
+ run.updatedAt = Date.now();
198
+ return;
199
+ }
152
200
  run.status = "completed";
153
201
  run.message = message;
154
202
  run.errorMessage = undefined;
@@ -157,6 +205,12 @@ function completeStreamRun(run, message) {
157
205
  function failStreamRun(run, errorMessage) {
158
206
  if (!run)
159
207
  return;
208
+ if (run.cancelRequested) {
209
+ run.status = "aborted";
210
+ run.errorMessage = "Stream run aborted";
211
+ run.updatedAt = Date.now();
212
+ return;
213
+ }
160
214
  run.status = "failed";
161
215
  run.errorMessage = errorMessage;
162
216
  run.updatedAt = Date.now();
@@ -377,17 +431,51 @@ function prepareSessionCompact(body) {
377
431
  const options = body.options ?? {};
378
432
  return {
379
433
  session,
434
+ sessionId: session.sessionId,
435
+ revision: session.revision,
436
+ treeHash: session.treeHash,
437
+ leafId: session.leafId,
438
+ entryCount: session.entries.length,
380
439
  preparation: preparationResult.value,
381
440
  options,
382
441
  };
383
442
  }
384
- async function completeSessionCompact(config, body, prepared) {
385
- const result = await compactLegacy(prepared.preparation, createRequestModels(body.model, prepared.options), body.model, body.customInstructions, undefined, prepared.options.reasoning);
443
+ function sessionCompactConflict(body, prepared, currentSession) {
444
+ return {
445
+ status: 409,
446
+ body: {
447
+ error: "Session changed while compaction was running",
448
+ code: PiServerErrorCode.SESSION_STATE_CONFLICT,
449
+ details: {
450
+ sessionId: body.sessionId,
451
+ expectedRevision: prepared.revision,
452
+ expectedTreeHash: prepared.treeHash,
453
+ expectedLeafId: prepared.leafId,
454
+ actualRevision: currentSession?.revision ?? null,
455
+ actualTreeHash: currentSession?.treeHash ?? null,
456
+ actualLeafId: currentSession?.leafId ?? null,
457
+ },
458
+ },
459
+ };
460
+ }
461
+ async function completeSessionCompact(config, body, prepared, run) {
462
+ const result = await compactLegacy(prepared.preparation, createRequestModels(body.model, prepared.options), body.model, body.customInstructions, run?.controller.signal, prepared.options.reasoning);
386
463
  if (!result.ok) {
387
464
  return { status: 500, body: { error: result.error.message, code: PiServerErrorCode.INTERNAL_ERROR } };
388
465
  }
389
- const baseTreeHash = prepared.session.treeHash;
390
- const baseEntryCount = prepared.session.entries.length;
466
+ if (run?.cancelRequested || run?.controller.signal.aborted) {
467
+ return { status: 409, body: { error: "Compaction aborted", code: PiServerErrorCode.INVALID_REQUEST } };
468
+ }
469
+ const currentSession = getSession(prepared.sessionId);
470
+ if (!currentSession ||
471
+ currentSession !== prepared.session ||
472
+ currentSession.revision !== prepared.revision ||
473
+ currentSession.treeHash !== prepared.treeHash ||
474
+ currentSession.leafId !== prepared.leafId) {
475
+ return sessionCompactConflict(body, prepared, currentSession);
476
+ }
477
+ const baseTreeHash = prepared.treeHash;
478
+ const baseEntryCount = prepared.entryCount;
391
479
  const compaction = result.value;
392
480
  const { session: updatedSession, entry: compactionEntry } = appendCompactionEntry(body.sessionId, compaction);
393
481
  persistSession(config, updatedSession);
@@ -427,7 +515,7 @@ function writeServerSentEvent(res, event, body) {
427
515
  res.write(`event: ${event}\n`);
428
516
  res.write(`data: ${JSON.stringify(body)}\n\n`);
429
517
  }
430
- async function handleSessionCompactStream(config, body, prepared, res) {
518
+ async function handleSessionCompactStream(config, body, prepared, res, run) {
431
519
  res.writeHead(200, {
432
520
  "Content-Type": "text/event-stream",
433
521
  "Cache-Control": "no-cache",
@@ -436,25 +524,33 @@ async function handleSessionCompactStream(config, body, prepared, res) {
436
524
  res.flushHeaders();
437
525
  res.write(STREAM_HEARTBEAT);
438
526
  const heartbeat = setInterval(() => {
527
+ touchStreamRun(run);
439
528
  if (!res.writableEnded) {
440
529
  res.write(STREAM_HEARTBEAT);
441
530
  }
442
531
  }, STREAM_HEARTBEAT_INTERVAL_MS);
443
532
  heartbeat.unref();
444
533
  try {
445
- const result = await completeSessionCompact(config, body, prepared);
534
+ const result = await completeSessionCompact(config, body, prepared, run);
446
535
  writeServerSentEvent(res, result.status >= 400 ? "error" : "result", result.body);
536
+ finishCompactRun(run, result);
447
537
  }
448
538
  catch (err) {
449
539
  const message = err instanceof Error ? err.message : String(err);
540
+ if (run) {
541
+ run.status = run.cancelRequested ? "aborted" : "failed";
542
+ run.errorMessage = message;
543
+ run.updatedAt = Date.now();
544
+ }
450
545
  writeServerSentEvent(res, "error", { error: message });
451
546
  }
452
547
  finally {
453
548
  clearInterval(heartbeat);
549
+ settleRun(run);
454
550
  res.end();
455
551
  }
456
552
  }
457
- async function handleSessionCompactJsonStream(config, body, prepared, res) {
553
+ async function handleSessionCompactJsonStream(config, body, prepared, res, run) {
458
554
  res.writeHead(200, {
459
555
  "Content-Type": "application/json",
460
556
  "Cache-Control": "no-cache",
@@ -463,35 +559,71 @@ async function handleSessionCompactJsonStream(config, body, prepared, res) {
463
559
  res.flushHeaders();
464
560
  res.write(JSON_HEARTBEAT);
465
561
  const heartbeat = setInterval(() => {
562
+ touchStreamRun(run);
466
563
  if (!res.writableEnded) {
467
564
  res.write(JSON_HEARTBEAT);
468
565
  }
469
566
  }, STREAM_HEARTBEAT_INTERVAL_MS);
470
567
  heartbeat.unref();
471
568
  try {
472
- const result = await completeSessionCompact(config, body, prepared);
569
+ const result = await completeSessionCompact(config, body, prepared, run);
473
570
  res.write(JSON.stringify(result.body));
571
+ finishCompactRun(run, result);
474
572
  }
475
573
  catch (err) {
476
574
  const message = err instanceof Error ? err.message : String(err);
575
+ if (run) {
576
+ run.status = run.cancelRequested ? "aborted" : "failed";
577
+ run.errorMessage = message;
578
+ run.updatedAt = Date.now();
579
+ }
477
580
  res.write(JSON.stringify({ error: message }));
478
581
  }
479
582
  finally {
480
583
  clearInterval(heartbeat);
584
+ settleRun(run);
481
585
  res.end();
482
586
  }
483
587
  }
588
+ function finishCompactRun(run, result) {
589
+ if (!run)
590
+ return;
591
+ if (run.cancelRequested || run.controller.signal.aborted) {
592
+ run.status = "aborted";
593
+ run.errorMessage = "Compaction aborted";
594
+ }
595
+ else {
596
+ run.status = result.status >= 400 ? "failed" : "completed";
597
+ if (result.status >= 400 && "error" in result.body) {
598
+ run.errorMessage = result.body.error;
599
+ }
600
+ }
601
+ run.updatedAt = Date.now();
602
+ }
484
603
  async function handleSessionCompact(config, body, res) {
485
604
  const prepared = prepareSessionCompact(body);
486
605
  if ("status" in prepared) {
487
606
  sendJson(res, prepared.status, prepared.body);
488
607
  return;
489
608
  }
609
+ let run;
610
+ if (body.runId) {
611
+ const existingRun = getStreamRun(body.sessionId, body.runId);
612
+ if (existingRun?.status === "aborted") {
613
+ sendJson(res, 409, { error: "Compaction run was aborted", code: PiServerErrorCode.INVALID_REQUEST });
614
+ return;
615
+ }
616
+ if (existingRun?.status === "running") {
617
+ sendError(res, 409, "A run with this runId is already in progress", PiServerErrorCode.RUN_IN_PROGRESS);
618
+ return;
619
+ }
620
+ run = startStreamRun(body.sessionId, body.runId, "compact");
621
+ }
490
622
  if (body.streamResponse) {
491
- await handleSessionCompactStream(config, body, prepared, res);
623
+ await handleSessionCompactStream(config, body, prepared, res, run);
492
624
  return;
493
625
  }
494
- await handleSessionCompactJsonStream(config, body, prepared, res);
626
+ await handleSessionCompactJsonStream(config, body, prepared, res, run);
495
627
  }
496
628
  function handleSessionHistory(sessionId, from, entriesFrom, revision, baseTreeHash, res) {
497
629
  const session = getSession(sessionId);
@@ -515,7 +647,35 @@ function handleSessionRun(sessionId, runId, res) {
515
647
  sendError(res, 404, "run not found", PiServerErrorCode.RUN_NOT_FOUND);
516
648
  return;
517
649
  }
518
- sendJson(res, 200, run);
650
+ sendJson(res, 200, streamRunResponseBody(run));
651
+ }
652
+ async function handleSessionRunAbort(sessionId, runId, res) {
653
+ if (!sessionId || !runId) {
654
+ sendError(res, 400, "sessionId and runId are required", PiServerErrorCode.REQUIRED_FIELD_MISSING);
655
+ return;
656
+ }
657
+ let run = getStreamRun(sessionId, runId);
658
+ if (!run) {
659
+ run = createRunRecord(sessionId, runId, "stream");
660
+ run.status = "aborted";
661
+ run.cancelRequested = true;
662
+ run.errorMessage = "Stream run aborted before it started";
663
+ run.updatedAt = Date.now();
664
+ settleRun(run);
665
+ streamRuns.set(runKey(sessionId, runId), run);
666
+ sendJson(res, 200, streamRunResponseBody(run));
667
+ return;
668
+ }
669
+ if (run.status !== "running") {
670
+ await run.settled;
671
+ sendJson(res, 200, streamRunResponseBody(run));
672
+ return;
673
+ }
674
+ run.cancelRequested = true;
675
+ run.updatedAt = Date.now();
676
+ run.controller.abort();
677
+ await run.settled;
678
+ sendJson(res, 200, streamRunResponseBody(run));
519
679
  }
520
680
  export function buildStreamContext(session, body) {
521
681
  const messages = body.contextOverlay ?? [...session.messages, ...(body.ephemeralMessages ?? [])];
@@ -555,7 +715,6 @@ function handleStream(config, body, res) {
555
715
  const context = buildStreamContext(session, body);
556
716
  const existingRun = body.runId ? getStreamRun(body.sessionId, body.runId) : undefined;
557
717
  const resolvedModel = body.model;
558
- const streamOptions = { ...(body.options ?? {}) };
559
718
  if (existingRun?.status === "completed") {
560
719
  res.writeHead(200, {
561
720
  "Content-Type": "text/event-stream",
@@ -570,10 +729,19 @@ function handleStream(config, body, res) {
570
729
  endStreamResponse(res);
571
730
  return;
572
731
  }
732
+ if (existingRun?.status === "aborted") {
733
+ sendError(res, 409, "The stream run was aborted", PiServerErrorCode.INVALID_REQUEST);
734
+ return;
735
+ }
573
736
  if (existingRun?.status === "running") {
574
737
  sendError(res, 409, "A stream with this runId is already in progress", PiServerErrorCode.RUN_IN_PROGRESS);
575
738
  return;
576
739
  }
740
+ const run = body.runId ? startStreamRun(body.sessionId, body.runId) : undefined;
741
+ const streamOptions = {
742
+ ...(body.options ?? {}),
743
+ ...(run ? { signal: run.controller.signal } : {}),
744
+ };
577
745
  res.writeHead(200, {
578
746
  "Content-Type": "text/event-stream",
579
747
  "Cache-Control": "no-cache",
@@ -581,7 +749,6 @@ function handleStream(config, body, res) {
581
749
  });
582
750
  res.flushHeaders();
583
751
  res.write(STREAM_HEARTBEAT);
584
- const run = body.runId ? startStreamRun(body.sessionId, body.runId) : undefined;
585
752
  const heartbeat = setInterval(() => {
586
753
  touchStreamRun(run);
587
754
  if (!res.writableEnded && !res.destroyed) {
@@ -597,11 +764,12 @@ function handleStream(config, body, res) {
597
764
  clearInterval(heartbeat);
598
765
  const message = err instanceof Error ? err.message : String(err);
599
766
  failStreamRun(run, message);
767
+ settleRun(run);
600
768
  writeStreamError(res, message);
601
769
  endStreamResponse(res);
602
770
  return;
603
771
  }
604
- (async () => {
772
+ void (async () => {
605
773
  try {
606
774
  for await (const event of stream) {
607
775
  const proxyEvent = toProxyEvent(event);
@@ -617,17 +785,20 @@ function handleStream(config, body, res) {
617
785
  }
618
786
  }
619
787
  }
788
+ catch (err) {
789
+ const message = err instanceof Error ? err.message : String(err);
790
+ failStreamRun(run, message);
791
+ writeStreamError(res, message);
792
+ }
620
793
  finally {
621
794
  clearInterval(heartbeat);
795
+ if (run?.status === "running") {
796
+ failStreamRun(run, run.cancelRequested ? "Stream run aborted" : "Stream ended before a terminal event");
797
+ }
798
+ settleRun(run);
799
+ endStreamResponse(res);
622
800
  }
623
- endStreamResponse(res);
624
- })().catch((err) => {
625
- clearInterval(heartbeat);
626
- const message = err instanceof Error ? err.message : String(err);
627
- failStreamRun(run, message);
628
- writeStreamError(res, message);
629
- endStreamResponse(res);
630
- });
801
+ })();
631
802
  }
632
803
  async function handlePostRequest(config, pathname, body, res) {
633
804
  if (pathname === "/api/receive") {
@@ -683,7 +854,7 @@ export function createPiServer(configOverride) {
683
854
  const config = loadConfig(configOverride);
684
855
  loadPersistedSessions(config.sessionStoreDir);
685
856
  const server = createServer(async (req, res) => {
686
- const url = new URL(req.url ?? "/", `http://${config.host}:${config.port}`);
857
+ const url = new URL(req.url ?? "/", "http://localhost");
687
858
  if (req.method === "GET" && url.pathname === "/health") {
688
859
  sendJson(res, 200, { status: "ok" });
689
860
  return;
@@ -704,6 +875,12 @@ export function createPiServer(configOverride) {
704
875
  handleSessionRun(decodeURIComponent(runMatch[1]), decodeURIComponent(runMatch[2]), res);
705
876
  return;
706
877
  }
878
+ const runAbortMatch = /^\/api\/session\/([^/]+)\/runs\/([^/]+)\/abort$/.exec(url.pathname);
879
+ if (req.method === "POST" && runAbortMatch) {
880
+ await readBody(req);
881
+ await handleSessionRunAbort(decodeURIComponent(runAbortMatch[1]), decodeURIComponent(runAbortMatch[2]), res);
882
+ return;
883
+ }
707
884
  if (req.method === "GET" && url.pathname.startsWith("/api/session/") && url.pathname.endsWith("/history")) {
708
885
  const encodedSessionId = url.pathname.slice("/api/session/".length, -"/history".length);
709
886
  const fromParam = url.searchParams.get("from");
@@ -835,6 +1012,7 @@ function toProxyEvent(event) {
835
1012
  reason: event.reason,
836
1013
  usage: event.message.usage,
837
1014
  deferred: event.message.deferred,
1015
+ message: event.message,
838
1016
  };
839
1017
  case "error":
840
1018
  return {
@@ -851,7 +1029,10 @@ export function startServer(configOverride) {
851
1029
  const config = loadConfig(configOverride);
852
1030
  const server = createPiServer(configOverride);
853
1031
  server.listen(config.port, config.host, () => {
854
- console.log(`pi-server listening on ${config.host}:${config.port}`);
1032
+ const address = server.address();
1033
+ const port = typeof address === "object" && address !== null ? address.port : config.port;
1034
+ const host = config.host.includes(":") && !config.host.startsWith("[") ? `[${config.host}]` : config.host;
1035
+ console.log(`pi-server listening on ${host}:${port}`);
855
1036
  });
856
1037
  return server;
857
1038
  }