@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.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +677 -189
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +676 -190
- package/dist/index.mjs.map +1 -1
- package/dist/logging/debug-config.d.ts +16 -0
- package/dist/mesh/mesh-queue-assignment.d.ts +28 -0
- package/dist/mesh/mesh-reconcile-loop.d.ts +1 -0
- package/dist/mesh/worktree-bootstrap-config.d.ts +45 -0
- package/dist/providers/chat-message-normalization.d.ts +26 -0
- package/dist/providers/cli-provider-instance.d.ts +7 -0
- package/dist/providers/native-history/antigravity-claim-registry.d.ts +28 -0
- package/dist/providers/native-history/antigravity-cli-transcript.d.ts +11 -0
- package/dist/providers/native-history/dispatcher.d.ts +4 -0
- package/package.json +3 -3
- package/src/index.ts +2 -0
- package/src/logging/debug-config.ts +25 -0
- package/src/logging/debug-trace.ts +7 -2
- package/src/mesh/coordinator-prompt.ts +1 -1
- package/src/mesh/mesh-events-stale.ts +55 -4
- package/src/mesh/mesh-fast-forward.ts +22 -9
- package/src/mesh/mesh-queue-assignment.ts +220 -3
- package/src/mesh/mesh-reconcile-loop.ts +83 -10
- package/src/mesh/mesh-refine-gates.ts +22 -9
- package/src/mesh/worktree-bootstrap-config.ts +130 -0
- package/src/providers/chat-message-normalization.ts +44 -10
- package/src/providers/cli-provider-instance.ts +46 -0
- package/src/providers/native-history/antigravity-claim-registry.ts +131 -0
- package/src/providers/native-history/antigravity-cli-transcript.ts +154 -4
- 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
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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
|
-
|
|
682
|
-
if (!content)
|
|
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
|
-
|
|
696
|
-
if (!content)
|
|
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,
|