@poncho-ai/harness 0.57.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
 
2
- > @poncho-ai/harness@0.57.0 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
2
+ > @poncho-ai/harness@0.58.0 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
3
3
  > node scripts/embed-docs.js && tsup src/index.ts --format esm --dts
4
4
 
5
5
  [embed-docs] Generated poncho-docs.ts with 4 topics
@@ -8,9 +8,9 @@
8
8
  CLI tsup v8.5.1
9
9
  CLI Target: es2022
10
10
  ESM Build start
11
- ESM dist/index.js 565.81 KB
11
+ ESM dist/index.js 567.32 KB
12
12
  ESM dist/isolate-F2PPSUL6.js 53.82 KB
13
- ESM ⚡️ Build success in 254ms
13
+ ESM ⚡️ Build success in 177ms
14
14
  DTS Build start
15
- DTS ⚡️ Build success in 7133ms
15
+ DTS ⚡️ Build success in 7320ms
16
16
  DTS dist/index.d.ts 104.68 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @poncho-ai/harness
2
2
 
3
+ ## 0.58.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#155](https://github.com/cesr/poncho-ai/pull/155) [`9939955`](https://github.com/cesr/poncho-ai/commit/9939955585f0ea204e070192827f0b213e84d283) Thanks [@cesr](https://github.com/cesr)! - Phase 3c read cutover: conversation reads rebuild from the append-only
8
+ `conversation_entries` log. Both engines' `get`/`getWithArchive` paths now
9
+ call `rebuildConversationFromEntries`, which overrides `_harnessMessages`
10
+ (via `buildLlmContext`), `messages` (via `buildDisplaySnapshot`, full
11
+ transcript), and `pendingSubagentResults` (via `getPendingSubagentResults`)
12
+ when the entry log is non-empty. Conversations that predate dual-write have
13
+ no entries and fall back to the mutable blob untouched — no migration
14
+ script needed. The rebuild is wrapped in try/catch and never throws on the
15
+ read path. A kill-switch (`PONCHO_READ_ENTRIES=0`) instantly reverts to
16
+ blob reads without a deploy. `_continuationMessages` and `pendingApprovals`
17
+ remain blob fields (not yet modeled as entries).
18
+
3
19
  ## 0.57.0
4
20
 
5
21
  ### Minor Changes
package/dist/index.js CHANGED
@@ -2443,7 +2443,7 @@ var ponchoDocsTool = defineTool({
2443
2443
  import { randomUUID as randomUUID5 } from "crypto";
2444
2444
  import { readFile as readFile9 } from "fs/promises";
2445
2445
  import { resolve as resolve11 } from "path";
2446
- import { defineTool as defineTool12, getTextContent as getTextContent2, createLogger as createLogger6, formatError as fmtErr, url as urlColor } from "@poncho-ai/sdk";
2446
+ import { defineTool as defineTool12, getTextContent as getTextContent2, createLogger as createLogger7, formatError as fmtErr, url as urlColor } from "@poncho-ai/sdk";
2447
2447
 
2448
2448
  // src/upload-store.ts
2449
2449
  import { createHash as createHash2 } from "crypto";
@@ -2776,6 +2776,87 @@ var createUploadStore = async (config, workingDir) => {
2776
2776
 
2777
2777
  // src/storage/memory-engine.ts
2778
2778
  import { randomUUID as randomUUID3 } from "crypto";
2779
+
2780
+ // src/storage/entries.ts
2781
+ import { createLogger as createLogger2 } from "@poncho-ai/sdk";
2782
+ var entriesReadLog = createLogger2("entries-read");
2783
+ function buildLlmContext(entries) {
2784
+ let latestCompaction;
2785
+ for (const e of entries) {
2786
+ if (e.type === "compaction" && (!latestCompaction || e.seq > latestCompaction.seq)) {
2787
+ latestCompaction = e;
2788
+ }
2789
+ }
2790
+ const harnessMsgs = entries.filter(
2791
+ (e) => e.type === "harness_message"
2792
+ );
2793
+ if (latestCompaction) {
2794
+ const kept = harnessMsgs.filter((e) => e.seq >= latestCompaction.firstKeptSeq).map((e) => e.message);
2795
+ return [latestCompaction.summaryMessage, ...kept];
2796
+ }
2797
+ return harnessMsgs.map((e) => e.message);
2798
+ }
2799
+ function buildDisplaySnapshot(entries, tailN) {
2800
+ const amendmentsByTarget = /* @__PURE__ */ new Map();
2801
+ for (const e of entries) {
2802
+ if (e.type === "assistant_amendment") {
2803
+ const list = amendmentsByTarget.get(e.targetEntryId) ?? [];
2804
+ list.push(e);
2805
+ amendmentsByTarget.set(e.targetEntryId, list);
2806
+ }
2807
+ }
2808
+ const built = [];
2809
+ for (const e of entries) {
2810
+ if (e.type === "user_message") {
2811
+ if (e.hidden) continue;
2812
+ built.push({ seq: e.seq, message: e.message });
2813
+ } else if (e.type === "assistant_message") {
2814
+ let content = typeof e.message.content === "string" ? e.message.content : "";
2815
+ const amendments = amendmentsByTarget.get(e.id);
2816
+ if (amendments) {
2817
+ for (const a of amendments.sort((x, y) => x.seq - y.seq)) {
2818
+ if (a.appendText) content += a.appendText;
2819
+ }
2820
+ }
2821
+ built.push({ seq: e.seq, message: { ...e.message, content } });
2822
+ }
2823
+ }
2824
+ const total = built.length;
2825
+ const tail = tailN >= total ? built : built.slice(total - tailN);
2826
+ return {
2827
+ messages: tail.map((b) => b.message),
2828
+ totalMessages: total,
2829
+ headSeq: tail.length > 0 ? tail[0].seq : null
2830
+ };
2831
+ }
2832
+ function getPendingSubagentResults(entries) {
2833
+ const consumed = /* @__PURE__ */ new Set();
2834
+ for (const e of entries) {
2835
+ if (e.type === "callback_started") {
2836
+ for (const s of e.consumedSeqs) consumed.add(s);
2837
+ }
2838
+ }
2839
+ return entries.filter((e) => e.type === "subagent_result").filter((e) => !consumed.has(e.seq)).map((e) => e.result);
2840
+ }
2841
+ var FULL_TRANSCRIPT_TAIL = 1e5;
2842
+ async function rebuildConversationFromEntries(conversation, readEntries) {
2843
+ if (process.env.PONCHO_READ_ENTRIES === "0") return conversation;
2844
+ try {
2845
+ const entries = await readEntries(conversation.conversationId);
2846
+ if (entries.length === 0) return conversation;
2847
+ conversation._harnessMessages = buildLlmContext(entries);
2848
+ conversation.messages = buildDisplaySnapshot(entries, FULL_TRANSCRIPT_TAIL).messages;
2849
+ conversation.pendingSubagentResults = getPendingSubagentResults(entries);
2850
+ return conversation;
2851
+ } catch (err) {
2852
+ entriesReadLog.warn(
2853
+ `[entries-read] ${conversation.conversationId} rebuild failed, using blob: ${err instanceof Error ? err.message : String(err)}`
2854
+ );
2855
+ return conversation;
2856
+ }
2857
+ }
2858
+
2859
+ // src/storage/memory-engine.ts
2779
2860
  var DEFAULT_TENANT = "__default__";
2780
2861
  var DEFAULT_OWNER = "local-owner";
2781
2862
  var normalizeTenant = (tenantId) => tenantId ?? DEFAULT_TENANT;
@@ -2826,12 +2907,22 @@ var InMemoryEngine = class {
2826
2907
  return results;
2827
2908
  },
2828
2909
  get: async (conversationId) => {
2829
- return this.convs.get(conversationId);
2910
+ const c = this.convs.get(conversationId);
2911
+ if (!c) return void 0;
2912
+ return rebuildConversationFromEntries(
2913
+ { ...c },
2914
+ (id) => this.conversations.readEntries(id)
2915
+ );
2830
2916
  },
2831
2917
  // In-memory storage has no separate archive blob, so both variants
2832
2918
  // return the same conversation object.
2833
2919
  getWithArchive: async (conversationId) => {
2834
- return this.convs.get(conversationId);
2920
+ const c = this.convs.get(conversationId);
2921
+ if (!c) return void 0;
2922
+ return rebuildConversationFromEntries(
2923
+ { ...c },
2924
+ (id) => this.conversations.readEntries(id)
2925
+ );
2835
2926
  },
2836
2927
  getStatusSnapshot: async (conversationId) => {
2837
2928
  const c = this.convs.get(conversationId);
@@ -3292,7 +3383,7 @@ import { dirname as dirname2, resolve as resolve6 } from "path";
3292
3383
 
3293
3384
  // src/storage/sql-dialect.ts
3294
3385
  import { randomUUID as randomUUID4 } from "crypto";
3295
- import { createLogger as createLogger2 } from "@poncho-ai/sdk";
3386
+ import { createLogger as createLogger3 } from "@poncho-ai/sdk";
3296
3387
 
3297
3388
  // src/storage/schema.ts
3298
3389
  var migrations = [
@@ -3517,7 +3608,7 @@ var migrations = [
3517
3608
  ];
3518
3609
 
3519
3610
  // src/storage/sql-dialect.ts
3520
- var egressLog = createLogger2("egress");
3611
+ var egressLog = createLogger3("egress");
3521
3612
  var sqliteDialect = {
3522
3613
  tag: "sqlite",
3523
3614
  param: () => "?",
@@ -3707,7 +3798,10 @@ var SqlStorageEngine = class {
3707
3798
  if (row.continuation_messages) {
3708
3799
  conv._continuationMessages = typeof row.continuation_messages === "string" ? JSON.parse(row.continuation_messages) : row.continuation_messages;
3709
3800
  }
3710
- return conv;
3801
+ return rebuildConversationFromEntries(
3802
+ conv,
3803
+ (id) => this.conversations.readEntries(id)
3804
+ );
3711
3805
  },
3712
3806
  getStatusSnapshot: async (conversationId) => {
3713
3807
  const runStatusExpr = this.dialect.tag === "sqlite" ? "json_extract(data, '$.runStatus')" : "data->>'runStatus'";
@@ -3757,7 +3851,10 @@ var SqlStorageEngine = class {
3757
3851
  if (row.continuation_messages) {
3758
3852
  conv._continuationMessages = typeof row.continuation_messages === "string" ? JSON.parse(row.continuation_messages) : row.continuation_messages;
3759
3853
  }
3760
- return conv;
3854
+ return rebuildConversationFromEntries(
3855
+ conv,
3856
+ (id) => this.conversations.readEntries(id)
3857
+ );
3761
3858
  },
3762
3859
  create: async (ownerId, title, tenantId, init) => {
3763
3860
  const id = randomUUID4();
@@ -6580,8 +6677,8 @@ var createReminderTools = (store) => [
6580
6677
  ];
6581
6678
 
6582
6679
  // src/mcp.ts
6583
- import { createLogger as createLogger3 } from "@poncho-ai/sdk";
6584
- var mcpLog = createLogger3("mcp");
6680
+ import { createLogger as createLogger4 } from "@poncho-ai/sdk";
6681
+ var mcpLog = createLogger4("mcp");
6585
6682
  var McpHttpError = class extends Error {
6586
6683
  status;
6587
6684
  constructor(status, message) {
@@ -7574,8 +7671,8 @@ var createModelProvider = (provider, config) => {
7574
7671
  import { readFile as readFile8, readdir as readdir3, stat as stat2 } from "fs/promises";
7575
7672
  import { dirname as dirname5, resolve as resolve9, normalize as normalize2 } from "path";
7576
7673
  import YAML3 from "yaml";
7577
- import { createLogger as createLogger4 } from "@poncho-ai/sdk";
7578
- var logger = createLogger4("skills");
7674
+ import { createLogger as createLogger5 } from "@poncho-ai/sdk";
7675
+ var logger = createLogger5("skills");
7579
7676
  var DEFAULT_SKILL_DIRS = ["skills"];
7580
7677
  var resolveSkillDirs = (workingDir, extraPaths) => {
7581
7678
  const dirs = [...DEFAULT_SKILL_DIRS];
@@ -8691,9 +8788,9 @@ import { NodeTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace
8691
8788
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
8692
8789
 
8693
8790
  // src/telemetry.ts
8694
- import { createLogger as createLogger5 } from "@poncho-ai/sdk";
8695
- var eventLog = createLogger5("event");
8696
- var telemetryLog = createLogger5("telemetry");
8791
+ import { createLogger as createLogger6 } from "@poncho-ai/sdk";
8792
+ var eventLog = createLogger6("event");
8793
+ var telemetryLog = createLogger6("telemetry");
8697
8794
  var MAX_FIELD_LENGTH = 200;
8698
8795
  var OMIT_FROM_LOG = /* @__PURE__ */ new Set(["continuationMessages", "_harnessMessages", "messages", "compactedHistory"]);
8699
8796
  function sanitizeEventForLog(event) {
@@ -8864,11 +8961,11 @@ var ToolDispatcher = class {
8864
8961
  };
8865
8962
 
8866
8963
  // src/harness.ts
8867
- var harnessLog = createLogger6("harness");
8868
- var telemetryLog2 = createLogger6("telemetry");
8869
- var costLog = createLogger6("cost");
8870
- var mcpLog2 = createLogger6("mcp");
8871
- var modelLog = createLogger6("model");
8964
+ var harnessLog = createLogger7("harness");
8965
+ var telemetryLog2 = createLogger7("telemetry");
8966
+ var costLog = createLogger7("cost");
8967
+ var mcpLog2 = createLogger7("mcp");
8968
+ var modelLog = createLogger7("model");
8872
8969
  function formatOtlpError(err) {
8873
8970
  if (!(err instanceof Error)) return String(err);
8874
8971
  const parts = [];
@@ -9840,7 +9937,7 @@ var AgentHarness = class _AgentHarness {
9840
9937
  const key = `${effectiveTenant}:${skipped.name}`;
9841
9938
  if (this.vfsSkillCollisionWarnings.has(key)) return;
9842
9939
  this.vfsSkillCollisionWarnings.add(key);
9843
- createLogger6("skills").warn(
9940
+ createLogger7("skills").warn(
9844
9941
  `VFS skill "${skipped.name}" for tenant ${effectiveTenant} ignored: a repo skill with the same name takes precedence.`
9845
9942
  );
9846
9943
  });
@@ -10093,7 +10190,7 @@ var AgentHarness = class _AgentHarness {
10093
10190
  this.agentFileFingerprint = rawContent;
10094
10191
  return true;
10095
10192
  } catch (error) {
10096
- createLogger6("agent").warn(`failed to refresh AGENT.md in dev: ${fmtErr(error)}`);
10193
+ createLogger7("agent").warn(`failed to refresh AGENT.md in dev: ${fmtErr(error)}`);
10097
10194
  return false;
10098
10195
  }
10099
10196
  }
@@ -10141,7 +10238,7 @@ var AgentHarness = class _AgentHarness {
10141
10238
  await this.refreshMcpTools("skills:changed");
10142
10239
  return true;
10143
10240
  } catch (error) {
10144
- createLogger6("skills").warn(`failed to refresh skills in dev: ${fmtErr(error)}`);
10241
+ createLogger7("skills").warn(`failed to refresh skills in dev: ${fmtErr(error)}`);
10145
10242
  return false;
10146
10243
  }
10147
10244
  }
@@ -10251,7 +10348,7 @@ var AgentHarness = class _AgentHarness {
10251
10348
  }
10252
10349
  if (config?.browser) {
10253
10350
  await this.initBrowserTools(config).catch((e) => {
10254
- createLogger6("browser").warn(`failed to load browser tools: ${fmtErr(e)}`);
10351
+ createLogger7("browser").warn(`failed to load browser tools: ${fmtErr(e)}`);
10255
10352
  });
10256
10353
  }
10257
10354
  const stateConfig = resolveStateConfig(config);
@@ -12149,7 +12246,9 @@ var InMemoryConversationStore = class {
12149
12246
  }
12150
12247
  async get(conversationId) {
12151
12248
  this.purgeExpired();
12152
- return this.conversations.get(conversationId);
12249
+ const c = this.conversations.get(conversationId);
12250
+ if (!c) return void 0;
12251
+ return rebuildConversationFromEntries({ ...c }, (id) => this.readEntries(id));
12153
12252
  }
12154
12253
  // In-memory stores already hold the full conversation object, so there's
12155
12254
  // no separate archive blob to load. Both variants return the same data.
@@ -12276,66 +12375,6 @@ var createConversationStore = (config, _options) => {
12276
12375
  return new InMemoryConversationStore(ttl);
12277
12376
  };
12278
12377
 
12279
- // src/storage/entries.ts
12280
- function buildLlmContext(entries) {
12281
- let latestCompaction;
12282
- for (const e of entries) {
12283
- if (e.type === "compaction" && (!latestCompaction || e.seq > latestCompaction.seq)) {
12284
- latestCompaction = e;
12285
- }
12286
- }
12287
- const harnessMsgs = entries.filter(
12288
- (e) => e.type === "harness_message"
12289
- );
12290
- if (latestCompaction) {
12291
- const kept = harnessMsgs.filter((e) => e.seq >= latestCompaction.firstKeptSeq).map((e) => e.message);
12292
- return [latestCompaction.summaryMessage, ...kept];
12293
- }
12294
- return harnessMsgs.map((e) => e.message);
12295
- }
12296
- function buildDisplaySnapshot(entries, tailN) {
12297
- const amendmentsByTarget = /* @__PURE__ */ new Map();
12298
- for (const e of entries) {
12299
- if (e.type === "assistant_amendment") {
12300
- const list = amendmentsByTarget.get(e.targetEntryId) ?? [];
12301
- list.push(e);
12302
- amendmentsByTarget.set(e.targetEntryId, list);
12303
- }
12304
- }
12305
- const built = [];
12306
- for (const e of entries) {
12307
- if (e.type === "user_message") {
12308
- if (e.hidden) continue;
12309
- built.push({ seq: e.seq, message: e.message });
12310
- } else if (e.type === "assistant_message") {
12311
- let content = typeof e.message.content === "string" ? e.message.content : "";
12312
- const amendments = amendmentsByTarget.get(e.id);
12313
- if (amendments) {
12314
- for (const a of amendments.sort((x, y) => x.seq - y.seq)) {
12315
- if (a.appendText) content += a.appendText;
12316
- }
12317
- }
12318
- built.push({ seq: e.seq, message: { ...e.message, content } });
12319
- }
12320
- }
12321
- const total = built.length;
12322
- const tail = tailN >= total ? built : built.slice(total - tailN);
12323
- return {
12324
- messages: tail.map((b) => b.message),
12325
- totalMessages: total,
12326
- headSeq: tail.length > 0 ? tail[0].seq : null
12327
- };
12328
- }
12329
- function getPendingSubagentResults(entries) {
12330
- const consumed = /* @__PURE__ */ new Set();
12331
- for (const e of entries) {
12332
- if (e.type === "callback_started") {
12333
- for (const s of e.consumedSeqs) consumed.add(s);
12334
- }
12335
- }
12336
- return entries.filter((e) => e.type === "subagent_result").filter((e) => !consumed.has(e.seq)).map((e) => e.result);
12337
- }
12338
-
12339
12378
  // src/tenant-token.ts
12340
12379
  import { jwtVerify } from "jose";
12341
12380
  async function verifyTenantToken(signingKey, token) {
@@ -12668,7 +12707,7 @@ var CALLBACK_LOCK_STALE_MS = 5 * 60 * 1e3;
12668
12707
  var STALE_SUBAGENT_THRESHOLD_MS = 5 * 60 * 1e3;
12669
12708
 
12670
12709
  // src/orchestrator/orchestrator.ts
12671
- import { createLogger as createLogger7, getTextContent as getTextContent4 } from "@poncho-ai/sdk";
12710
+ import { createLogger as createLogger8, getTextContent as getTextContent4 } from "@poncho-ai/sdk";
12672
12711
 
12673
12712
  // src/orchestrator/entries-dual-write.ts
12674
12713
  import { randomUUID as randomUUID6 } from "crypto";
@@ -12802,7 +12841,7 @@ var verifyEntriesParity = async (store, conversationId, blob, log2) => {
12802
12841
  };
12803
12842
 
12804
12843
  // src/orchestrator/orchestrator.ts
12805
- var dualWriteLog = createLogger7("orchestrator:entries");
12844
+ var dualWriteLog = createLogger8("orchestrator:entries");
12806
12845
  var assistantMessageText = (message) => {
12807
12846
  const raw = getTextContent4(message).trim();
12808
12847
  if (raw.startsWith("{") && raw.includes('"tool_calls"')) {
@@ -14393,8 +14432,8 @@ ${resultBody}`,
14393
14432
 
14394
14433
  // src/orchestrator/run-conversation-turn.ts
14395
14434
  import { randomUUID as randomUUID7 } from "crypto";
14396
- import { createLogger as createLogger8 } from "@poncho-ai/sdk";
14397
- var log = createLogger8("orchestrator");
14435
+ import { createLogger as createLogger9 } from "@poncho-ai/sdk";
14436
+ var log = createLogger9("orchestrator");
14398
14437
  var runConversationTurn = async (opts) => {
14399
14438
  const conversation = await opts.conversationStore.getWithArchive(opts.conversationId);
14400
14439
  if (!conversation) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.57.0",
3
+ "version": "0.58.0",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
package/src/state.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import type { Message } from "@poncho-ai/sdk";
2
- import type { ConversationEntry, NewConversationEntry } from "./storage/entries.js";
2
+ import {
3
+ type ConversationEntry,
4
+ type NewConversationEntry,
5
+ rebuildConversationFromEntries,
6
+ } from "./storage/entries.js";
3
7
 
4
8
  export interface ConversationState {
5
9
  runId: string;
@@ -269,7 +273,12 @@ export class InMemoryConversationStore implements ConversationStore {
269
273
 
270
274
  async get(conversationId: string): Promise<Conversation | undefined> {
271
275
  this.purgeExpired();
272
- return this.conversations.get(conversationId);
276
+ const c = this.conversations.get(conversationId);
277
+ if (!c) return undefined;
278
+ // Phase 3c read cutover: rebuild reader-facing fields from the entry log
279
+ // (blob fallback for un-migrated conversations). Clone first — the map
280
+ // holds a live mutable reference and the rebuild overrides fields.
281
+ return rebuildConversationFromEntries({ ...c }, (id) => this.readEntries(id));
273
282
  }
274
283
 
275
284
  // In-memory stores already hold the full conversation object, so there's
@@ -1,5 +1,7 @@
1
- import type { Message } from "@poncho-ai/sdk";
2
- import type { PendingSubagentResult } from "../state.js";
1
+ import { createLogger, type Message } from "@poncho-ai/sdk";
2
+ import type { Conversation, PendingSubagentResult } from "../state.js";
3
+
4
+ const entriesReadLog = createLogger("entries-read");
3
5
 
4
6
  /**
5
7
  * Append-only conversation entries (Phase 3 substrate).
@@ -215,3 +217,56 @@ export function getPendingSubagentResults(
215
217
  .filter((e) => !consumed.has(e.seq))
216
218
  .map((e) => e.result);
217
219
  }
220
+
221
+ // A very large tail so the rebuilt display snapshot is the full transcript.
222
+ // Display callers slice to whatever window they actually render.
223
+ const FULL_TRANSCRIPT_TAIL = 100_000;
224
+
225
+ /**
226
+ * Phase 3c read cutover: rebuild a conversation's reader-facing fields from
227
+ * the append-only entry log, with a blob fallback for conversations that
228
+ * predate dual-write.
229
+ *
230
+ * Call this in every conversation `get`/`getWithArchive` path AFTER the
231
+ * Conversation has been constructed from the stored row/blob. It:
232
+ * - reads the entry log via `readEntries`,
233
+ * - if NON-EMPTY, overrides `_harnessMessages`, `messages`, and
234
+ * `pendingSubagentResults` with entry-derived values,
235
+ * - if EMPTY (un-migrated conversation), leaves the blob-derived fields
236
+ * untouched (fallback),
237
+ * - on ANY error, logs and falls back to the blob (never throws — this is
238
+ * a hot read path).
239
+ *
240
+ * `_continuationMessages` and `pendingApprovals` are NOT modeled as entries
241
+ * yet and are intentionally left as blob fields.
242
+ *
243
+ * Kill-switch: set `PONCHO_READ_ENTRIES=0` to instantly revert to pure blob
244
+ * reads without a deploy (rebuild is ON by default).
245
+ *
246
+ * NOTE: mutates `conversation` in place and returns it. Callers that hand
247
+ * back a shared/mutable Conversation reference (the in-memory stores) MUST
248
+ * pass a clone, or the override will corrupt their stored object.
249
+ */
250
+ export async function rebuildConversationFromEntries(
251
+ conversation: Conversation,
252
+ readEntries: (conversationId: string) => Promise<ConversationEntry[]>,
253
+ ): Promise<Conversation> {
254
+ // Kill-switch: ON by default; PONCHO_READ_ENTRIES="0" reverts to blob reads.
255
+ if (process.env.PONCHO_READ_ENTRIES === "0") return conversation;
256
+
257
+ try {
258
+ const entries = await readEntries(conversation.conversationId);
259
+ if (entries.length === 0) return conversation; // fallback: pre-dual-write
260
+ conversation._harnessMessages = buildLlmContext(entries);
261
+ conversation.messages = buildDisplaySnapshot(entries, FULL_TRANSCRIPT_TAIL).messages;
262
+ conversation.pendingSubagentResults = getPendingSubagentResults(entries);
263
+ return conversation;
264
+ } catch (err) {
265
+ entriesReadLog.warn(
266
+ `[entries-read] ${conversation.conversationId} rebuild failed, using blob: ${
267
+ err instanceof Error ? err.message : String(err)
268
+ }`,
269
+ );
270
+ return conversation;
271
+ }
272
+ }
@@ -14,7 +14,11 @@ import type { MainMemory } from "../memory.js";
14
14
  import type { TodoItem } from "../todo-tools.js";
15
15
  import type { Reminder, ReminderCreateInput, ReminderStatus } from "../reminder-store.js";
16
16
  import type { StorageEngine, VfsDirEntry, VfsStat } from "./engine.js";
17
- import type { ConversationEntry, NewConversationEntry } from "./entries.js";
17
+ import {
18
+ type ConversationEntry,
19
+ type NewConversationEntry,
20
+ rebuildConversationFromEntries,
21
+ } from "./entries.js";
18
22
 
19
23
  // ---------------------------------------------------------------------------
20
24
  // Internal VFS entry type
@@ -103,13 +107,24 @@ export class InMemoryEngine implements StorageEngine {
103
107
  },
104
108
 
105
109
  get: async (conversationId: string): Promise<Conversation | undefined> => {
106
- return this.convs.get(conversationId);
110
+ const c = this.convs.get(conversationId);
111
+ if (!c) return undefined;
112
+ // Phase 3c read cutover: rebuild reader-facing fields from the entry
113
+ // log (blob fallback for un-migrated conversations). Clone first — the
114
+ // map holds a live mutable reference and the rebuild overrides fields.
115
+ return rebuildConversationFromEntries({ ...c }, (id) =>
116
+ this.conversations.readEntries(id),
117
+ );
107
118
  },
108
119
 
109
120
  // In-memory storage has no separate archive blob, so both variants
110
121
  // return the same conversation object.
111
122
  getWithArchive: async (conversationId: string): Promise<Conversation | undefined> => {
112
- return this.convs.get(conversationId);
123
+ const c = this.convs.get(conversationId);
124
+ if (!c) return undefined;
125
+ return rebuildConversationFromEntries({ ...c }, (id) =>
126
+ this.conversations.readEntries(id),
127
+ );
113
128
  },
114
129
 
115
130
  getStatusSnapshot: async (
@@ -22,7 +22,11 @@ import type { MainMemory } from "../memory.js";
22
22
  import type { TodoItem } from "../todo-tools.js";
23
23
  import type { Reminder, ReminderCreateInput, ReminderStatus } from "../reminder-store.js";
24
24
  import type { StorageEngine, VfsDirEntry, VfsStat } from "./engine.js";
25
- import type { ConversationEntry, NewConversationEntry } from "./entries.js";
25
+ import {
26
+ type ConversationEntry,
27
+ type NewConversationEntry,
28
+ rebuildConversationFromEntries,
29
+ } from "./entries.js";
26
30
  import { type DialectTag, migrations } from "./schema.js";
27
31
 
28
32
  // ---------------------------------------------------------------------------
@@ -325,7 +329,12 @@ export abstract class SqlStorageEngine implements StorageEngine {
325
329
  ? JSON.parse(row.continuation_messages)
326
330
  : row.continuation_messages;
327
331
  }
328
- return conv;
332
+ // Phase 3c read cutover: rebuild reader-facing fields from the
333
+ // append-only entry log, falling back to the blob for un-migrated
334
+ // conversations. parseConversation returns a fresh object, so no clone.
335
+ return rebuildConversationFromEntries(conv, (id) =>
336
+ this.conversations.readEntries(id),
337
+ );
329
338
  },
330
339
 
331
340
  getStatusSnapshot: async (
@@ -408,7 +417,11 @@ export abstract class SqlStorageEngine implements StorageEngine {
408
417
  ? JSON.parse(row.continuation_messages)
409
418
  : row.continuation_messages;
410
419
  }
411
- return conv;
420
+ // Phase 3c read cutover: rebuild reader-facing fields from the entry
421
+ // log (blob fallback for un-migrated conversations).
422
+ return rebuildConversationFromEntries(conv, (id) =>
423
+ this.conversations.readEntries(id),
424
+ );
412
425
  },
413
426
 
414
427
  create: async (
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { InMemoryConversationStore } from "../src/state.js";
3
+ import {
4
+ buildLlmContext,
5
+ buildDisplaySnapshot,
6
+ type NewConversationEntry,
7
+ } from "../src/storage/entries.js";
8
+ import type { Message } from "@poncho-ai/sdk";
9
+
10
+ const msg = (role: Message["role"], content: string): Message => ({ role, content });
11
+
12
+ // A turn's worth of entries: a user display message, the harness (LLM
13
+ // transcript) messages for that turn, and the final assistant bubble.
14
+ function turnEntries(): NewConversationEntry[] {
15
+ return [
16
+ { type: "user_message", id: "u1", message: msg("user", "hello"), turnId: "t1" },
17
+ { type: "harness_message", id: "h1", message: msg("user", "hello"), turnId: "t1" },
18
+ { type: "harness_message", id: "h2", message: msg("assistant", "hi there"), turnId: "t1" },
19
+ {
20
+ type: "assistant_message",
21
+ id: "a1",
22
+ message: msg("assistant", "hi there"),
23
+ turnId: "t1",
24
+ runId: "r1",
25
+ },
26
+ ];
27
+ }
28
+
29
+ describe("Phase 3c read cutover", () => {
30
+ const prevFlag = process.env.PONCHO_READ_ENTRIES;
31
+
32
+ afterEach(() => {
33
+ if (prevFlag === undefined) delete process.env.PONCHO_READ_ENTRIES;
34
+ else process.env.PONCHO_READ_ENTRIES = prevFlag;
35
+ });
36
+
37
+ it("get() rebuilds _harnessMessages/messages from entries when present", async () => {
38
+ delete process.env.PONCHO_READ_ENTRIES; // ON by default
39
+ const store = new InMemoryConversationStore();
40
+ const conv = await store.create("owner", "title", null);
41
+
42
+ // Seed the blob with stale messages so we can prove the override happened.
43
+ conv.messages = [msg("assistant", "STALE BLOB")];
44
+ conv._harnessMessages = [msg("assistant", "STALE BLOB HARNESS")];
45
+ await store.update(conv);
46
+
47
+ const entries = await store.appendEntries(conv.conversationId, "agent", null, turnEntries());
48
+
49
+ const loaded = await store.get(conv.conversationId);
50
+ expect(loaded).toBeDefined();
51
+ expect(loaded!._harnessMessages).toEqual(buildLlmContext(entries));
52
+ expect(loaded!.messages).toEqual(buildDisplaySnapshot(entries, 100000).messages);
53
+ // Display transcript drops the harness-only messages; keeps user + assistant bubble.
54
+ expect(loaded!.messages.map((m) => m.content)).toEqual(["hello", "hi there"]);
55
+ });
56
+
57
+ it("get() falls back to the blob when there are no entries", async () => {
58
+ delete process.env.PONCHO_READ_ENTRIES;
59
+ const store = new InMemoryConversationStore();
60
+ const conv = await store.create("owner", "title", null);
61
+ conv.messages = [msg("user", "blob only")];
62
+ conv._harnessMessages = [msg("user", "blob only harness")];
63
+ await store.update(conv);
64
+
65
+ const loaded = await store.get(conv.conversationId);
66
+ expect(loaded!.messages).toEqual([msg("user", "blob only")]);
67
+ expect(loaded!._harnessMessages).toEqual([msg("user", "blob only harness")]);
68
+ });
69
+
70
+ it("kill-switch PONCHO_READ_ENTRIES=0 reverts to blob reads even with entries", async () => {
71
+ process.env.PONCHO_READ_ENTRIES = "0";
72
+ const store = new InMemoryConversationStore();
73
+ const conv = await store.create("owner", "title", null);
74
+ conv.messages = [msg("user", "blob wins")];
75
+ await store.update(conv);
76
+ await store.appendEntries(conv.conversationId, "agent", null, turnEntries());
77
+
78
+ const loaded = await store.get(conv.conversationId);
79
+ expect(loaded!.messages).toEqual([msg("user", "blob wins")]);
80
+ });
81
+
82
+ it("get() does not mutate the stored blob conversation (clone)", async () => {
83
+ delete process.env.PONCHO_READ_ENTRIES;
84
+ const store = new InMemoryConversationStore();
85
+ const conv = await store.create("owner", "title", null);
86
+ conv.messages = [msg("assistant", "STALE BLOB")];
87
+ await store.update(conv);
88
+ await store.appendEntries(conv.conversationId, "agent", null, turnEntries());
89
+
90
+ await store.get(conv.conversationId);
91
+ // Re-read with the kill-switch on: should still see the untouched blob.
92
+ process.env.PONCHO_READ_ENTRIES = "0";
93
+ const blob = await store.get(conv.conversationId);
94
+ expect(blob!.messages).toEqual([msg("assistant", "STALE BLOB")]);
95
+ });
96
+ });