@adhdev/daemon-core 0.9.82-rc.457 → 0.9.82-rc.459

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.
Files changed (30) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +677 -189
  3. package/dist/index.js.map +1 -1
  4. package/dist/index.mjs +676 -190
  5. package/dist/index.mjs.map +1 -1
  6. package/dist/logging/debug-config.d.ts +16 -0
  7. package/dist/mesh/mesh-queue-assignment.d.ts +28 -0
  8. package/dist/mesh/mesh-reconcile-loop.d.ts +1 -0
  9. package/dist/mesh/worktree-bootstrap-config.d.ts +45 -0
  10. package/dist/providers/chat-message-normalization.d.ts +26 -0
  11. package/dist/providers/cli-provider-instance.d.ts +7 -0
  12. package/dist/providers/native-history/antigravity-claim-registry.d.ts +28 -0
  13. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +11 -0
  14. package/dist/providers/native-history/dispatcher.d.ts +4 -0
  15. package/package.json +3 -3
  16. package/src/index.ts +2 -0
  17. package/src/logging/debug-config.ts +25 -0
  18. package/src/logging/debug-trace.ts +7 -2
  19. package/src/mesh/coordinator-prompt.ts +1 -1
  20. package/src/mesh/mesh-events-stale.ts +55 -4
  21. package/src/mesh/mesh-fast-forward.ts +22 -9
  22. package/src/mesh/mesh-queue-assignment.ts +220 -3
  23. package/src/mesh/mesh-reconcile-loop.ts +83 -10
  24. package/src/mesh/mesh-refine-gates.ts +22 -9
  25. package/src/mesh/worktree-bootstrap-config.ts +130 -0
  26. package/src/providers/chat-message-normalization.ts +44 -10
  27. package/src/providers/cli-provider-instance.ts +46 -0
  28. package/src/providers/native-history/antigravity-claim-registry.ts +131 -0
  29. package/src/providers/native-history/antigravity-cli-transcript.ts +154 -4
  30. package/src/providers/native-history/dispatcher.ts +150 -20
@@ -104,22 +104,56 @@ export function extractFinalAssistantSummaryEvidence(
104
104
  messages: ChatMessage[] | null | undefined,
105
105
  maxChars: number = DEFAULT_FINAL_SUMMARY_MAX_CHARS,
106
106
  ): { finalSummary: string; transcriptMessageAt?: string } {
107
- if (!Array.isArray(messages) || messages.length === 0) return { finalSummary: '' };
107
+ const turnEnd = selectFinalAssistantTurnEndMessage(messages);
108
+ if (!turnEnd) return { finalSummary: '' };
109
+ return {
110
+ finalSummary: flattenContent(turnEnd.content).trim().slice(0, maxChars),
111
+ transcriptMessageAt: readChatMessageTimestampIso(turnEnd),
112
+ };
113
+ }
114
+
115
+ /**
116
+ * EARLYNOTIFY-GATEBYPASS (a)/(b) — the shared turn-finality selector for the completion
117
+ * final-assistant judgement, so the ~duplicated "which bubble is the turn's final answer"
118
+ * logic is decided ONE way (UNIFY A-6).
119
+ *
120
+ * Returns the message that qualifies as the turn's FINAL assistant bubble, or null when the
121
+ * transcript does not (yet) prove a turn end. The rule is a NON-EMPTY LATEST user-facing
122
+ * assistant/model bubble:
123
+ * - Scanning from the end, the FIRST user-facing assistant/model bubble encountered IS the
124
+ * turn-end candidate. If it is EMPTY (a streaming placeholder / mid-turn narration whose
125
+ * text has not landed), the turn is still in flight → return null. Crucially we do NOT walk
126
+ * back past that empty bubble to promote an EARLIER assistant narration to "final" (the
127
+ * Defect-B walk-back).
128
+ * - Trailing activity/internal bubbles (tool/thought/status) are skipped — they are not the
129
+ * assistant's user-facing answer.
130
+ * - A trailing user-facing USER message (a freshly dispatched task with no reply yet) means the
131
+ * assistant did not have the last word — no earlier bubble is promoted here; callers that must
132
+ * still reach a prior turn's tail use the timestamp-scoped extractor instead.
133
+ *
134
+ * A bare snapshot-idle with an arbitrary non-empty tail therefore does NOT qualify as a turn end;
135
+ * only a genuine latest-assistant bubble does. Turn-finality signals that live OUTSIDE the
136
+ * transcript (a committed generating→idle FSM transition, a self-attributing final_summary_json,
137
+ * or a continuous-idle streak) are enforced by the callers (the CLI completion gate, the reconcile
138
+ * grace gate) on top of this structural check.
139
+ */
140
+ export function selectFinalAssistantTurnEndMessage(
141
+ messages: ChatMessage[] | null | undefined,
142
+ ): ChatMessage | null {
143
+ if (!Array.isArray(messages) || messages.length === 0) return null;
108
144
  for (let i = messages.length - 1; i >= 0; i--) {
109
145
  const msg = messages[i];
110
146
  if (!msg) continue;
111
147
  const classification = classifyChatMessageVisibility(msg);
112
- if (classification.isUserFacing && (msg.role === 'assistant' || msg.role === 'model')) {
113
- const text = flattenContent(msg.content).trim();
114
- if (text) {
115
- return {
116
- finalSummary: text.slice(0, maxChars),
117
- transcriptMessageAt: readChatMessageTimestampIso(msg),
118
- };
119
- }
148
+ if (!classification.isUserFacing) continue; // skip tool/thought/status activity + internal
149
+ if (msg.role === 'assistant' || msg.role === 'model') {
150
+ // The latest user-facing assistant bubble: it is the turn end iff it has real text.
151
+ return flattenContent(msg.content).trim() ? msg : null;
120
152
  }
153
+ // A user (or other role) had the last user-facing word → the assistant turn is not complete.
154
+ return null;
121
155
  }
122
- return { finalSummary: '' };
156
+ return null;
123
157
  }
124
158
 
125
159
  export const BUILTIN_CHAT_MESSAGE_KINDS = ['standard', 'thought', 'tool', 'terminal', 'system'] as const;
@@ -31,6 +31,11 @@ import { formatAutoApprovalMessage, pickApprovalButton, hasNegativeApprovalOptio
31
31
  import { getCliScriptCommand, parseCliScriptResult } from './cli-script-results.js';
32
32
  import { mergeProviderPatchState, resolveProviderStateSurface } from './provider-patch-state.js';
33
33
  import { normalizeProviderSessionId } from './provider-session-id.js';
34
+ import {
35
+ antigravityOwnerToken,
36
+ claimAntigravityConversation,
37
+ releaseAntigravityOwner,
38
+ } from './native-history/antigravity-claim-registry.js';
34
39
  import { buildChatMessage, buildRuntimeSystemChatMessage, isUserFacingChatMessage, normalizeChatMessages, resolveChatMessageKind, extractFinalSummaryFromMessages, extractFinalSummaryFromMessagesAfter, readChatMessageTimestampMs } from './chat-message-normalization.js';
35
40
  import { workingDirBasename } from './working-dir.js';
36
41
  import { ManualAttendanceTracker } from './manual-attendance.js';
@@ -1394,7 +1399,24 @@ export class CliProviderInstance implements ProviderInstance {
1394
1399
  }
1395
1400
  }
1396
1401
 
1402
+ /**
1403
+ * Owner token for this session in the antigravity conversation-claim
1404
+ * registry. Derived identically to the dispatcher's read-side token
1405
+ * (workspace + spawn time) so the claims the dispatcher records under this
1406
+ * session are the ones dispose() releases.
1407
+ */
1408
+ private antigravityClaimOwner(): string {
1409
+ return antigravityOwnerToken(this.workingDir, this.startedAt);
1410
+ }
1411
+
1397
1412
  dispose(): void {
1413
+ // Release this session's antigravity conversation claims so the store
1414
+ // becomes available again (e.g. a later resume) and the registry doesn't
1415
+ // leak entries for dead sessions.
1416
+ if (this.type === 'antigravity-cli') {
1417
+ const owner = this.antigravityClaimOwner();
1418
+ if (owner) releaseAntigravityOwner(owner);
1419
+ }
1398
1420
  this.adapter.shutdown();
1399
1421
  this.monitor.reset();
1400
1422
  // Cancel any armed auto-approve timers so a pending settle re-check
@@ -2483,12 +2505,27 @@ export class CliProviderInstance implements ProviderInstance {
2483
2505
  if (this.isMeshWorkerSession()) {
2484
2506
  traceMeshEventStage('fired', this.meshTraceCtx(), `${reason} (source=${fcEvidenceSource})`);
2485
2507
  }
2508
+ // EARLYNOTIFY-GATEBYPASS (c): a startup-grace fast-collapse never OBSERVED the turn's
2509
+ // generating phase — its evidence is a plain transcript tail, never a self-attributing
2510
+ // final_summary_json — so this synth is TENTATIVE by default. Mark it WEAK
2511
+ // (evidenceLevel:'weak') so buildPendingEventFingerprint keys it `…::weak` and any later
2512
+ // genuine agent:generating_completed for the same task can still surface (CANON-B). The
2513
+ // stronger missing_final_assistant marker is preserved when there is also no summary.
2514
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('synth-fire', {
2515
+ path: 'startup_grace_fast_collapse',
2516
+ reason,
2517
+ evidenceSource: fcEvidenceSource,
2518
+ hadFinalSummary: !!fcFinalSummary,
2519
+ missingEvidence,
2520
+ evidenceLevel: 'weak',
2521
+ });
2486
2522
  this.pushEvent({
2487
2523
  event: 'agent:generating_completed',
2488
2524
  chatTitle,
2489
2525
  duration: 0,
2490
2526
  timestamp: now,
2491
2527
  finalSummary: fcFinalSummary,
2528
+ evidenceLevel: 'weak',
2492
2529
  completionDiagnostic: {
2493
2530
  reason,
2494
2531
  finalAssistantEvidenceSource: fcEvidenceSource,
@@ -3604,6 +3641,15 @@ export class CliProviderInstance implements ProviderInstance {
3604
3641
  const previousHistorySessionId = this.providerSessionId || this.instanceId;
3605
3642
  const previousProviderSessionId = this.providerSessionId;
3606
3643
  this.providerSessionId = nextSessionId;
3644
+ // Conversation-binding lock (antigravity): the moment this session is
3645
+ // authoritatively bound to a conversation uuid, claim it so a concurrent
3646
+ // sibling session's newest-on-disk discovery can never resolve to the
3647
+ // same .db (RCA: two antigravity sessions ~94ms apart shared one store
3648
+ // and cross-routed completions). Released on dispose().
3649
+ if (this.type === 'antigravity-cli') {
3650
+ const owner = this.antigravityClaimOwner();
3651
+ if (owner) claimAntigravityConversation(nextSessionId, owner);
3652
+ }
3607
3653
  this.historyWriter.promoteHistorySession(this.type, previousHistorySessionId, nextSessionId);
3608
3654
  this.historyWriter.writeSessionStart(this.type, nextSessionId, this.workingDir, this.instanceId);
3609
3655
  if (this.shouldHydrateExistingProviderHistory()) {
@@ -0,0 +1,131 @@
1
+ /**
2
+ * antigravity-claim-registry — daemon-local conversation ownership map.
3
+ *
4
+ * Antigravity CLI writes each conversation into its own per-session SQLite db
5
+ * at ~/.gemini/antigravity-cli/conversations/<uuid>.db. But before a daemon
6
+ * session has resolved its provider session id, the discovery resolver used to
7
+ * fall back to "the newest .db on disk by mtime". Two sessions started within a
8
+ * few ms of each other on ONE daemon therefore both grabbed the SAME newest
9
+ * store — so one session's injected prompt and the other's assistant completion
10
+ * cross-routed, and an unrelated turn completed the wrong task (RCA: a
11
+ * coordinator session ended up reading the owner's conversation .db, and the
12
+ * coordinator's own injected instruction was absent from the bound store).
13
+ *
14
+ * This registry records which live daemon session currently owns a given
15
+ * conversation uuid. The discovery resolver (dispatcher.resolveAntigravityPath)
16
+ * consults it to:
17
+ * (1) never hand a conversation already owned by a DIFFERENT live session to
18
+ * an as-yet-unbound one — two sessions can never resolve to the same .db;
19
+ * (2) LOCK a session to the first conversation it binds, so a later, newer
20
+ * .db on disk cannot re-bind an already-bound session on a subsequent
21
+ * mtime-ordered read.
22
+ *
23
+ * Ownership is keyed by a per-session owner token (see antigravityOwnerToken)
24
+ * that BOTH the resolver and the provider instance derive identically from the
25
+ * same inputs (workspace + spawn time, or the instance id), so the instance can
26
+ * release its claims deterministically on shutdown.
27
+ *
28
+ * A claim not refreshed within CLAIM_STALE_MS is treated as abandoned — a
29
+ * safety net for a session that died without an explicit release. Live sessions
30
+ * poll native history far more frequently than this window, so an active owner
31
+ * never lapses; only a crashed/leaked owner's claim ages out.
32
+ *
33
+ * OSS code (AGPL-3.0). Must not import from packages/ (proprietary).
34
+ */
35
+ 'use strict';
36
+
37
+ interface ConversationClaim {
38
+ owner: string;
39
+ /** Last time this owner (re)confirmed the claim. Only consulted by the
40
+ * stale-claim safety net; an active owner refreshes it on every resolve. */
41
+ refreshedAtMs: number;
42
+ }
43
+
44
+ const claimsByUuid = new Map<string, ConversationClaim>();
45
+
46
+ /** A claim older than this with no refresh is reclaimable (owner presumed dead). */
47
+ export const CLAIM_STALE_MS = 10 * 60 * 1000;
48
+
49
+ function normalizeUuid(uuid: string): string {
50
+ return String(uuid || '').trim().toLowerCase();
51
+ }
52
+
53
+ /**
54
+ * Derive the per-session owner token. Both the dispatcher (from the read input:
55
+ * workspace + sessionStartedAtMs) and the provider instance (from its
56
+ * workingDir + startedAt, or its instanceId) call this with the same inputs so
57
+ * claims and releases line up. Returns '' when there is no stable identity to
58
+ * key on (e.g. a workspace-less discovery with no spawn time) — the caller then
59
+ * skips claiming but the exclusion checks still run against existing claims.
60
+ */
61
+ export function antigravityOwnerToken(
62
+ workspace: string,
63
+ sessionStartedAtMs: number,
64
+ instanceId?: string,
65
+ ): string {
66
+ const iid = typeof instanceId === 'string' ? instanceId.trim() : '';
67
+ if (iid) return `iid:${iid}`;
68
+ if (typeof sessionStartedAtMs === 'number' && sessionStartedAtMs > 0) {
69
+ const ws = String(workspace || '').trim().toLowerCase();
70
+ return `spawn:${ws}:${sessionStartedAtMs}`;
71
+ }
72
+ return '';
73
+ }
74
+
75
+ /**
76
+ * Claim `uuid` for `owner`. Succeeds (and refreshes) when the conversation is
77
+ * unclaimed, already owned by this owner, or held by a stale (abandoned) owner.
78
+ * Fails when a DIFFERENT live owner holds it. Returns whether `owner` holds the
79
+ * claim after the call.
80
+ */
81
+ export function claimAntigravityConversation(uuid: string, owner: string, now: number = Date.now()): boolean {
82
+ const key = normalizeUuid(uuid);
83
+ if (!key || !owner) return false;
84
+ const existing = claimsByUuid.get(key);
85
+ if (existing && existing.owner !== owner && (now - existing.refreshedAtMs) < CLAIM_STALE_MS) {
86
+ return false;
87
+ }
88
+ claimsByUuid.set(key, { owner, refreshedAtMs: now });
89
+ return true;
90
+ }
91
+
92
+ /** True when `uuid` is held by a live owner OTHER than `owner`. */
93
+ export function isAntigravityConversationClaimedByOther(uuid: string, owner: string, now: number = Date.now()): boolean {
94
+ const key = normalizeUuid(uuid);
95
+ if (!key) return false;
96
+ const existing = claimsByUuid.get(key);
97
+ if (!existing) return false;
98
+ if (existing.owner === owner) return false;
99
+ return (now - existing.refreshedAtMs) < CLAIM_STALE_MS;
100
+ }
101
+
102
+ /** The live owner of `uuid`, or undefined when unclaimed or stale. */
103
+ export function antigravityConversationOwner(uuid: string, now: number = Date.now()): string | undefined {
104
+ const key = normalizeUuid(uuid);
105
+ if (!key) return undefined;
106
+ const existing = claimsByUuid.get(key);
107
+ if (!existing) return undefined;
108
+ if ((now - existing.refreshedAtMs) >= CLAIM_STALE_MS) return undefined;
109
+ return existing.owner;
110
+ }
111
+
112
+ /** Release a single conversation, only if held by `owner` (or `owner` empty). */
113
+ export function releaseAntigravityConversation(uuid: string, owner?: string): void {
114
+ const key = normalizeUuid(uuid);
115
+ if (!key) return;
116
+ const existing = claimsByUuid.get(key);
117
+ if (existing && (!owner || existing.owner === owner)) claimsByUuid.delete(key);
118
+ }
119
+
120
+ /** Release every conversation held by `owner` (called on session shutdown). */
121
+ export function releaseAntigravityOwner(owner: string): void {
122
+ if (!owner) return;
123
+ for (const [key, claim] of claimsByUuid) {
124
+ if (claim.owner === owner) claimsByUuid.delete(key);
125
+ }
126
+ }
127
+
128
+ /** Test-only: wipe all claims so each test starts from a clean registry. */
129
+ export function __resetAntigravityClaimRegistry(): void {
130
+ claimsByUuid.clear();
131
+ }
@@ -38,6 +38,17 @@
38
38
  * pty parser (which only echoes the user's own input) — assistant
39
39
  * answers appeared lost even though they were on disk.
40
40
  *
41
+ * Schema-drift resilience: the exact field path (20 → 1/8 for the answer,
42
+ * 19 → 2/3 for the prompt) is empirically verified against real stores, but
43
+ * antigravity may move it in a future build. So when the known path yields
44
+ * no text, instead of silently dropping the turn we fall back to a UTF-8
45
+ * printable-run scan of the payload (recoverMessageText) that recovers the
46
+ * answer/prompt even if the field number drifted — while explicitly
47
+ * EXCLUDING the reasoning subtree (20 → 3) so internal reasoning is never
48
+ * surfaced as the answer. Only when even that finds nothing beyond
49
+ * reasoning/metadata is the step dropped, and a content-free DEBUG
50
+ * breadcrumb is logged so a real drift is greppable rather than invisible.
51
+ *
41
52
  * This adapter provides:
42
53
  * - Full coverage from a per-session .db (current format) — preferred.
43
54
  * - Full coverage when a brain transcript exists (legacy authoritative source).
@@ -546,6 +557,100 @@ function extractUserPrompt(payload: Buffer): string {
546
557
  return extractUserRequestContent(text);
547
558
  }
548
559
 
560
+ /**
561
+ * The model step's private reasoning summary lives at field 20 → field 3. We
562
+ * surface it nowhere, but we DO need it: the schema-drift recovery below scans
563
+ * the raw payload for a plausible answer run, and the reasoning is itself a long
564
+ * natural-language run — so we extract it here purely to EXCLUDE it and avoid
565
+ * accidentally surfacing internal reasoning as the assistant answer.
566
+ */
567
+ function extractModelReasoning(payload: Buffer): string {
568
+ const inner = firstLenField(payload, 20);
569
+ if (!inner) return '';
570
+ const reasoning = firstLenField(inner, 3);
571
+ if (!reasoning || !looksLikeText(reasoning)) return '';
572
+ return reasoning.toString('utf-8').trim();
573
+ }
574
+
575
+ /** Top-level protobuf field numbers present in a payload (for drift breadcrumbs). */
576
+ function topLevelFieldNumbers(payload: Buffer): number[] {
577
+ return decodeProtoFields(payload).map((f) => f.field);
578
+ }
579
+
580
+ const MIN_RECOVERED_MESSAGE_CHARS = 12;
581
+
582
+ /**
583
+ * Split a payload into UTF-8 text runs, schema-agnostically. Unlike
584
+ * extractStringsFromBuffer (ASCII-only, used for legacy .pb), this is UTF-8 aware
585
+ * so CJK / accented answers survive intact: we decode the whole blob as UTF-8
586
+ * (invalid byte sequences collapse to U+FFFD) and split on runs of C0/C1 control
587
+ * chars + the replacement char. Protobuf framing bytes (field tags, varint length
588
+ * prefixes) are almost always control or invalid-UTF-8, so each natural-language
589
+ * string field emerges as its own run while binary framing is discarded.
590
+ */
591
+ function extractUtf8TextRuns(buf: Buffer): string[] {
592
+ if (buf.length === 0) return [];
593
+ const decoded = buf.toString('utf-8');
594
+ // Keep tab/newline/CR (0x09/0x0A/0x0D) inside runs — answers contain newlines.
595
+ // Everything else in C0 (incl. 0x1A, the field-3 tag that separates reasoning
596
+ // from the answer), DEL, and the replacement char are run separators.
597
+ const parts = decoded.split(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\uFFFD]+/);
598
+ const runs: string[] = [];
599
+ for (const part of parts) {
600
+ const trimmed = part.trim();
601
+ if (trimmed.length >= MIN_PRINTABLE_RUN) runs.push(trimmed);
602
+ }
603
+ return runs;
604
+ }
605
+
606
+ /**
607
+ * Is this run plausibly a user-visible prose message, as opposed to the other
608
+ * text the payload also carries — internal reasoning (excluded separately),
609
+ * tool-call JSON arguments, code blobs, file paths, and uuid/session-id
610
+ * metadata? These filters were tuned against real antigravity stores so that a
611
+ * blind printable-run scan recovers a genuinely drifted answer while surfacing
612
+ * NONE of the tool-call / metadata runs that legitimately answer-less steps
613
+ * carry (verified: zero false recoveries across real conversation dbs).
614
+ */
615
+ function isPlausibleMessageText(s: string): boolean {
616
+ if (s.length < MIN_RECOVERED_MESSAGE_CHARS) return false;
617
+ if (!/[A-Za-zÀ-￿]/.test(s)) return false; // must contain letters (incl. CJK)
618
+ if (/^(file:\/\/|[A-Za-z]:[\\/]|\/[A-Za-z0-9._-]+\/)/.test(s)) return false; // path/URI
619
+ // Prose is multi-word: real answers have several spaces; uuids / ids / tokens
620
+ // have none. This is the single strongest prose-vs-metadata discriminator.
621
+ if ((s.match(/ /g) ?? []).length < 2) return false;
622
+ // Reject structured tool-call args / JSON / code blobs. A model tool step
623
+ // carries its arguments as JSON (e.g. {"Query":...}, {"CommandLine":...});
624
+ // those must never be surfaced as an assistant answer.
625
+ if (/[[{]\s*"/.test(s)) return false;
626
+ const structural = (s.match(/[{}[\]":\\]/g) ?? []).length;
627
+ if (structural / s.length > 0.12) return false;
628
+ return true;
629
+ }
630
+
631
+ /**
632
+ * Schema-agnostic recovery of a message's text when the known field path yields
633
+ * nothing (a possible antigravity step_payload schema drift). Scans the payload
634
+ * for UTF-8 text runs and returns the longest plausible message run, EXCLUDING
635
+ * any run that matches one of `excludeTexts` (e.g. the reasoning subtree) so we
636
+ * never surface internal reasoning as the answer. Returns '' when nothing beyond
637
+ * reasoning/metadata is present — i.e. a legitimately answer-less step.
638
+ */
639
+ function recoverMessageText(payload: Buffer, excludeTexts: string[]): string {
640
+ const exclusions = excludeTexts.map((t) => t.trim()).filter(Boolean);
641
+ let best = '';
642
+ for (const run of extractUtf8TextRuns(payload)) {
643
+ const candidate = stripAnswerMarker(run).trim();
644
+ if (!isPlausibleMessageText(candidate)) continue;
645
+ // Drop runs that are (or are contained in / contain) an excluded subtree.
646
+ if (exclusions.some((e) => e === candidate || e.includes(candidate) || candidate.includes(e))) {
647
+ continue;
648
+ }
649
+ if (candidate.length > best.length) best = candidate;
650
+ }
651
+ return best;
652
+ }
653
+
549
654
  interface AgyDbStepRow {
550
655
  idx: number;
551
656
  step_type: number;
@@ -678,8 +783,27 @@ function parseConversationDb(
678
783
  const receivedAt = baseTs + messages.length;
679
784
 
680
785
  if (row.step_type === AGY_STEP_TYPE_USER) {
681
- const content = extractUserPrompt(payload);
682
- if (!content) continue;
786
+ let content = extractUserPrompt(payload);
787
+ if (!content) {
788
+ // Primary field path (field 19 → 2/3) missed. Recover schema-agnostically
789
+ // rather than silently drop a user turn: scan the payload for the longest
790
+ // plausible prompt run (metadata/paths/tokens are filtered out). There is
791
+ // no reasoning subtree to exclude on the user side.
792
+ const recovered = extractUserRequestContent(recoverMessageText(payload, []));
793
+ if (recovered) {
794
+ content = recovered;
795
+ LOG.debug(
796
+ 'NativeHistory',
797
+ `antigravity .db ${path.basename(filePath)} step ${row.idx} (type ${row.step_type}): user prompt absent at field 19; recovered ${content.length} chars via printable-run fallback — possible step_payload schema drift`,
798
+ );
799
+ } else {
800
+ LOG.debug(
801
+ 'NativeHistory',
802
+ `antigravity .db ${path.basename(filePath)} step ${row.idx} (type ${row.step_type}) dropped: no user prompt text (payload ${payload.length}B, top-level fields [${topLevelFieldNumbers(payload).join(',')}])`,
803
+ );
804
+ continue;
805
+ }
806
+ }
683
807
  const msg: NativeHistoryMessage = {
684
808
  ts: new Date(receivedAt).toISOString(),
685
809
  receivedAt,
@@ -692,8 +816,34 @@ function parseConversationDb(
692
816
  if (normalizedWorkspace) msg.workspace = normalizedWorkspace;
693
817
  messages.push(msg);
694
818
  } else if (row.step_type === AGY_STEP_TYPE_MODEL) {
695
- const content = extractModelAnswer(payload);
696
- if (!content) continue; // reasoning-only / tool-only model step — no answer text
819
+ let content = extractModelAnswer(payload);
820
+ if (!content) {
821
+ // The known answer path (field 20 → 1/8) yielded nothing. This is either
822
+ // (a) a legitimate reasoning-only / tool-planning step — the common case,
823
+ // which carries no user-visible answer — or (b) antigravity moved the
824
+ // answer to a different field/subtree (schema drift). Attempt a
825
+ // schema-agnostic recovery that EXCLUDES the reasoning subtree (field
826
+ // 20 → 3) so internal reasoning is never surfaced as the answer.
827
+ const reasoning = extractModelReasoning(payload);
828
+ const recovered = recoverMessageText(payload, reasoning ? [reasoning] : []);
829
+ if (recovered) {
830
+ content = recovered;
831
+ LOG.debug(
832
+ 'NativeHistory',
833
+ `antigravity .db ${path.basename(filePath)} step ${row.idx} (type ${row.step_type}): answer absent at field 20; recovered ${content.length} chars via printable-run fallback — possible step_payload schema drift`,
834
+ );
835
+ } else {
836
+ // No answer at the primary path and nothing recoverable beyond
837
+ // reasoning/metadata → drop. Content-free breadcrumb so a genuine
838
+ // future drift (answer present but unreadable) is greppable, and the
839
+ // expected reasoning-only case is distinguishable via reasoningOnly.
840
+ LOG.debug(
841
+ 'NativeHistory',
842
+ `antigravity .db ${path.basename(filePath)} step ${row.idx} (type ${row.step_type}) dropped: no answer text (payload ${payload.length}B, top-level fields [${topLevelFieldNumbers(payload).join(',')}], reasoningOnly=${reasoning ? 'yes' : 'no'})`,
843
+ );
844
+ continue;
845
+ }
846
+ }
697
847
  const msg: NativeHistoryMessage = {
698
848
  ts: new Date(receivedAt).toISOString(),
699
849
  receivedAt,