cursorconnect 0.1.7 → 0.1.9

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 (44) hide show
  1. package/bridge-runtime/.env.example +7 -1
  2. package/bridge-runtime/connector-version.json +1 -1
  3. package/bridge-runtime/dist/agent-completion-push.d.ts +27 -22
  4. package/bridge-runtime/dist/agent-completion-push.js +242 -122
  5. package/bridge-runtime/dist/agent-completion-readiness.d.ts +19 -0
  6. package/bridge-runtime/dist/agent-completion-readiness.js +42 -0
  7. package/bridge-runtime/dist/chat-display-store.d.ts +32 -7
  8. package/bridge-runtime/dist/chat-display-store.js +99 -21
  9. package/bridge-runtime/dist/chat-display.d.ts +36 -0
  10. package/bridge-runtime/dist/chat-display.js +287 -24
  11. package/bridge-runtime/dist/chat-sync.d.ts +3 -1
  12. package/bridge-runtime/dist/chat-sync.js +20 -0
  13. package/bridge-runtime/dist/config.js +2 -0
  14. package/bridge-runtime/dist/connector-client-version.js +1 -1
  15. package/bridge-runtime/dist/debug-chats-page.d.ts +1 -1
  16. package/bridge-runtime/dist/debug-chats-page.js +148 -26
  17. package/bridge-runtime/dist/dom-transcript-store.d.ts +3 -1
  18. package/bridge-runtime/dist/dom-transcript-store.js +18 -3
  19. package/bridge-runtime/dist/extract-page.js +5 -4
  20. package/bridge-runtime/dist/index.js +9 -0
  21. package/bridge-runtime/dist/keep-awake.d.ts +5 -0
  22. package/bridge-runtime/dist/keep-awake.js +48 -0
  23. package/bridge-runtime/dist/lenta-capture.d.ts +46 -0
  24. package/bridge-runtime/dist/lenta-capture.js +146 -0
  25. package/bridge-runtime/dist/lenta-debug.d.ts +42 -0
  26. package/bridge-runtime/dist/lenta-debug.js +221 -0
  27. package/bridge-runtime/dist/lenta-delivery.d.ts +3 -0
  28. package/bridge-runtime/dist/lenta-delivery.js +10 -0
  29. package/bridge-runtime/dist/lenta-seq-journal.d.ts +48 -0
  30. package/bridge-runtime/dist/lenta-seq-journal.js +109 -0
  31. package/bridge-runtime/dist/message-filter.d.ts +5 -0
  32. package/bridge-runtime/dist/message-filter.js +4 -0
  33. package/bridge-runtime/dist/relay-upstream.d.ts +3 -0
  34. package/bridge-runtime/dist/relay-upstream.js +21 -0
  35. package/bridge-runtime/dist/relay.d.ts +47 -3
  36. package/bridge-runtime/dist/relay.js +667 -96
  37. package/bridge-runtime/dist/types.d.ts +13 -4
  38. package/dist/bridge-build.js +50 -0
  39. package/dist/index.js +9 -6
  40. package/dist/launch.js +5 -1
  41. package/dist/run-service.js +10 -4
  42. package/dist/startup-check.js +6 -0
  43. package/package.json +1 -1
  44. package/version-policy.json +2 -2
@@ -1,25 +1,50 @@
1
1
  import type { ChatMessage, HistoryMessage } from './types.js';
2
2
  /**
3
- * Chat UI store: JSONL baseline for `agent:messages` (client lenta).
4
- * DOM transcript kept for debug snapshot only — not sent as liveMessages.
3
+ * Chat UI store: JSONL baseline + DOM overlay tail.
4
+ *
5
+ * Invariant:
6
+ * - JSONL baseline = everything already in `.jsonl` (monotonic archive order).
7
+ * - DOM overlay = viewport rows **not covered** by JSONL (`archiveCoversOverlay`).
8
+ * - While a row streams in DOM but JSONL is shorter → overlay keeps it; when JSONL catches up → overlay drops (no duplicate).
9
+ * - App lenta = `[...jsonlHistory, ...domOverlay]` — never sort both by DOM flatIndex.
5
10
  */
6
11
  export declare class ChatDisplayStore {
7
12
  private readonly domTranscript;
13
+ /** Last CDP viewport extract per agent — sole source for live overlay. */
14
+ private readonly latestDomViewport;
8
15
  private readonly jsonlBaseline;
9
16
  /** Raw JSONL row count per agent — incremental display merge during streaming. */
10
17
  private readonly jsonlRowCount;
18
+ /** While true, bridge pushes lenta on DOM ingest (emit path); overlay filter unchanged. */
19
+ private readonly generatingByAgent;
11
20
  clearAgent(agentId: string): void;
21
+ setAgentGenerating(agentId: string, generating: boolean): void;
22
+ isAgentGenerating(agentId: string): boolean;
12
23
  getJsonlHistory(agentId: string): ChatMessage[];
13
24
  getDomLive(agentId: string): ChatMessage[];
14
- /** Reload JSONL; prune DOM rows now covered by archive. */
25
+ /** Reload JSONL; drop viewport rows now covered by archive. */
15
26
  setJsonlBaseline(agentId: string, rows: HistoryMessage[]): void;
16
27
  /** Append JSONL rows during live stream; returns new/updated bubbles for socket `append` emit. */
17
28
  appendJsonlRows(agentId: string, deltaRows: HistoryMessage[]): ChatMessage[];
18
- /** Ingest DOM extract; overlay keeps only rows not in JSONL. */
29
+ /** Ingest DOM extract; overlay = viewport minus JSONL-covered rows. */
19
30
  mergeLiveForAgent(agentId: string, rawDom: ChatMessage[]): void;
20
- /** Debug / snapshot: combined view. */
31
+ /** Canonical UI lenta: archive then live tail (append-only, no cross-sort). */
21
32
  getDisplayMessages(agentId: string): ChatMessage[];
22
- getDomTranscript(agentId: string): ChatMessage[];
23
- private domOverlayOnly;
33
+ /** Raw DOM ingest before JSONL filter / stamp (debug only). */
34
+ getDomTranscriptRaw(agentId: string): ChatMessage[];
35
+ /** Snapshot for `/debug/lenta` and lenta:watch. */
36
+ getLentaDebug(agentId: string): {
37
+ jsonlRowCount: number;
38
+ jsonlHistory: ChatMessage[];
39
+ domOverlay: ChatMessage[];
40
+ domRaw: ChatMessage[];
41
+ messages: ChatMessage[];
42
+ source: 'jsonl' | 'hybrid';
43
+ agentGenerating: boolean;
44
+ };
45
+ /** JSONL rows used to suppress DOM overlay (baseline + displayed archive). */
46
+ private archiveForOverlayMatch;
47
+ /** Shrink cached viewport to rows still missing from JSONL (handoff DOM → JSONL). */
48
+ private reconcileDomViewportToArchive;
24
49
  private pruneDomOverlay;
25
50
  }
@@ -1,29 +1,63 @@
1
- import { filterClientDisplayList, historyRowsToChat, mergeHistoryTail, messagesEquivalent, prepareChatMessagesForDisplay, } from './chat-display.js';
1
+ import { archiveOrderFloor, filterClientDisplayList, historyRowsToChat, mergeHistoryTail, prepareChatMessagesForDisplay, prepareDomOverlayForDisplay, selectDomOverlayFromViewport, stampOverlayAfterArchive, } from './chat-display.js';
2
2
  import { DomTranscriptStore } from './dom-transcript-store.js';
3
3
  /**
4
- * Chat UI store: JSONL baseline for `agent:messages` (client lenta).
5
- * DOM transcript kept for debug snapshot only — not sent as liveMessages.
4
+ * Chat UI store: JSONL baseline + DOM overlay tail.
5
+ *
6
+ * Invariant:
7
+ * - JSONL baseline = everything already in `.jsonl` (monotonic archive order).
8
+ * - DOM overlay = viewport rows **not covered** by JSONL (`archiveCoversOverlay`).
9
+ * - While a row streams in DOM but JSONL is shorter → overlay keeps it; when JSONL catches up → overlay drops (no duplicate).
10
+ * - App lenta = `[...jsonlHistory, ...domOverlay]` — never sort both by DOM flatIndex.
6
11
  */
7
12
  export class ChatDisplayStore {
8
13
  domTranscript = new DomTranscriptStore();
14
+ /** Last CDP viewport extract per agent — sole source for live overlay. */
15
+ latestDomViewport = new Map();
9
16
  jsonlBaseline = new Map();
10
17
  /** Raw JSONL row count per agent — incremental display merge during streaming. */
11
18
  jsonlRowCount = new Map();
19
+ /** While true, bridge pushes lenta on DOM ingest (emit path); overlay filter unchanged. */
20
+ generatingByAgent = new Map();
12
21
  clearAgent(agentId) {
13
22
  this.domTranscript.clear(agentId);
23
+ this.latestDomViewport.delete(agentId);
14
24
  this.jsonlBaseline.delete(agentId);
15
25
  this.jsonlRowCount.delete(agentId);
26
+ this.generatingByAgent.delete(agentId);
27
+ }
28
+ setAgentGenerating(agentId, generating) {
29
+ if (generating)
30
+ this.generatingByAgent.set(agentId, true);
31
+ else {
32
+ this.generatingByAgent.delete(agentId);
33
+ this.reconcileDomViewportToArchive(agentId);
34
+ this.pruneDomOverlay(agentId);
35
+ }
36
+ }
37
+ isAgentGenerating(agentId) {
38
+ return this.generatingByAgent.get(agentId) ?? false;
16
39
  }
17
40
  getJsonlHistory(agentId) {
18
41
  return filterClientDisplayList(this.jsonlBaseline.get(agentId) ?? []);
19
42
  }
20
43
  getDomLive(agentId) {
21
- const history = this.jsonlBaseline.get(agentId) ?? [];
22
- return filterClientDisplayList(this.domOverlayOnly(agentId, history));
44
+ const archive = this.archiveForOverlayMatch(agentId);
45
+ const dom = selectDomOverlayFromViewport(this.latestDomViewport.get(agentId) ?? [], archive);
46
+ if (!dom.length)
47
+ return [];
48
+ if (!archive.length) {
49
+ return filterClientDisplayList(prepareChatMessagesForDisplay(dom));
50
+ }
51
+ const floor = archiveOrderFloor(archive, this.jsonlRowCount.get(agentId) ?? 0);
52
+ const stamped = stampOverlayAfterArchive(floor, dom);
53
+ return filterClientDisplayList(prepareDomOverlayForDisplay(stamped));
23
54
  }
24
- /** Reload JSONL; prune DOM rows now covered by archive. */
55
+ /** Reload JSONL; drop viewport rows now covered by archive. */
25
56
  setJsonlBaseline(agentId, rows) {
26
57
  const prevRows = this.jsonlRowCount.get(agentId) ?? 0;
58
+ if (rows.length > 0 && prevRows > 24 && rows.length < prevRows - 12) {
59
+ return;
60
+ }
27
61
  const prev = this.jsonlBaseline.get(agentId) ?? [];
28
62
  let prepared;
29
63
  if (rows.length > prevRows && prevRows > 0 && prev.length && rows.length - prevRows <= 128) {
@@ -38,6 +72,7 @@ export class ChatDisplayStore {
38
72
  }
39
73
  this.jsonlRowCount.set(agentId, rows.length);
40
74
  this.jsonlBaseline.set(agentId, prepared);
75
+ this.reconcileDomViewportToArchive(agentId);
41
76
  this.pruneDomOverlay(agentId);
42
77
  }
43
78
  /** Append JSONL rows during live stream; returns new/updated bubbles for socket `append` emit. */
@@ -50,6 +85,7 @@ export class ChatDisplayStore {
50
85
  const prepared = mergeHistoryTail(prev, deltaChat);
51
86
  this.jsonlRowCount.set(agentId, (this.jsonlRowCount.get(agentId) ?? 0) + deltaRows.length);
52
87
  this.jsonlBaseline.set(agentId, prepared);
88
+ this.reconcileDomViewportToArchive(agentId);
53
89
  this.pruneDomOverlay(agentId);
54
90
  if (prepared.length > prevLen)
55
91
  return prepared.slice(prevLen);
@@ -69,32 +105,74 @@ export class ChatDisplayStore {
69
105
  return prepared;
70
106
  return deltaChat.length ? deltaChat : [];
71
107
  }
72
- /** Ingest DOM extract; overlay keeps only rows not in JSONL. */
108
+ /** Ingest DOM extract; overlay = viewport minus JSONL-covered rows. */
73
109
  mergeLiveForAgent(agentId, rawDom) {
74
110
  const live = prepareChatMessagesForDisplay(rawDom);
75
- if (live.length)
111
+ if (live.length) {
112
+ this.latestDomViewport.set(agentId, live);
76
113
  this.domTranscript.ingest(agentId, live);
114
+ this.domTranscript.reconcileToViewport(agentId, live);
115
+ }
116
+ else {
117
+ this.latestDomViewport.delete(agentId);
118
+ this.domTranscript.reconcileToViewport(agentId, []);
119
+ }
120
+ this.reconcileDomViewportToArchive(agentId);
77
121
  this.pruneDomOverlay(agentId);
78
122
  }
79
- /** Debug / snapshot: combined view. */
123
+ /** Canonical UI lenta: archive then live tail (append-only, no cross-sort). */
80
124
  getDisplayMessages(agentId) {
81
125
  return [...this.getJsonlHistory(agentId), ...this.getDomLive(agentId)];
82
126
  }
83
- getDomTranscript(agentId) {
84
- return this.getDisplayMessages(agentId);
127
+ /** Raw DOM ingest before JSONL filter / stamp (debug only). */
128
+ getDomTranscriptRaw(agentId) {
129
+ return this.domTranscript.list(agentId);
85
130
  }
86
- domOverlayOnly(agentId, history) {
87
- const dom = this.domTranscript.list(agentId);
88
- if (!dom.length)
89
- return [];
90
- if (!history.length)
91
- return dom;
92
- return dom.filter((d) => !history.some((h) => messagesEquivalent(h, d)));
131
+ /** Snapshot for `/debug/lenta` and lenta:watch. */
132
+ getLentaDebug(agentId) {
133
+ const jsonlHistory = this.getJsonlHistory(agentId);
134
+ const domOverlay = this.getDomLive(agentId);
135
+ return {
136
+ jsonlRowCount: this.jsonlRowCount.get(agentId) ?? 0,
137
+ jsonlHistory,
138
+ domOverlay,
139
+ domRaw: this.getDomTranscriptRaw(agentId),
140
+ messages: this.getDisplayMessages(agentId),
141
+ source: domOverlay.length ? 'hybrid' : 'jsonl',
142
+ agentGenerating: this.isAgentGenerating(agentId),
143
+ };
144
+ }
145
+ /** JSONL rows used to suppress DOM overlay (baseline + displayed archive). */
146
+ archiveForOverlayMatch(agentId) {
147
+ const baseline = this.jsonlBaseline.get(agentId) ?? [];
148
+ const displayed = this.getJsonlHistory(agentId);
149
+ const seen = new Set();
150
+ const out = [];
151
+ for (const m of [...baseline, ...displayed]) {
152
+ const key = `${m.role}:${m.id ?? ''}:${m.flatIndex ?? ''}:${(m.text ?? '').slice(0, 48)}`;
153
+ if (seen.has(key))
154
+ continue;
155
+ seen.add(key);
156
+ out.push(m);
157
+ }
158
+ return out;
159
+ }
160
+ /** Shrink cached viewport to rows still missing from JSONL (handoff DOM → JSONL). */
161
+ reconcileDomViewportToArchive(agentId) {
162
+ const dom = this.latestDomViewport.get(agentId);
163
+ if (!dom?.length)
164
+ return;
165
+ const archive = this.archiveForOverlayMatch(agentId);
166
+ const kept = selectDomOverlayFromViewport(dom, archive);
167
+ if (kept.length)
168
+ this.latestDomViewport.set(agentId, kept);
169
+ else
170
+ this.latestDomViewport.delete(agentId);
93
171
  }
94
172
  pruneDomOverlay(agentId) {
95
- const history = this.jsonlBaseline.get(agentId) ?? [];
96
- if (!history.length)
173
+ const archive = this.archiveForOverlayMatch(agentId);
174
+ if (!archive.length)
97
175
  return;
98
- this.domTranscript.pruneCoveredBy(agentId, history);
176
+ this.domTranscript.pruneCoveredBy(agentId, archive);
99
177
  }
100
178
  }
@@ -2,11 +2,47 @@ import type { ChatMessage, HistoryMessage } from './types.js';
2
2
  export declare function compareUserText(text: string): string;
3
3
  export declare function userTextsEquivalent(ta: string, tb: string): boolean;
4
4
  export declare function userMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean;
5
+ /**
6
+ * JSONL `flatIndex` = file line number (~1..N); Cursor DOM = `data-flat-index` (often 1000+).
7
+ * When DOM is ahead of the archive tail, fuzzy cover hid live rows (debug: NEEDS_DOM_OVERLAY, overlay 0).
8
+ */
9
+ export declare const JSONL_DOM_ORDER_GAP = 32;
10
+ export declare function maxArchiveOrderKey(archive: ChatMessage[]): number;
11
+ export declare function isDomOrderAheadOfJsonlArchive(overlay: ChatMessage, maxArchiveKey: number): boolean;
12
+ /** Exact / near-exact match only — no word-bag (safe when DOM timeline > JSONL tail). */
13
+ export declare function archiveCoversOverlayStrict(archive: ChatMessage, overlay: ChatMessage): boolean;
14
+ /**
15
+ * Viewport rows for the live tail: not yet represented in JSONL archive.
16
+ * Uses `archiveCoversOverlay` (strict / fuzzy / streaming) — same rule for idle and generating.
17
+ */
18
+ export declare function selectDomOverlayFromViewport(viewport: ChatMessage[], archive: ChatMessage[]): ChatMessage[];
19
+ /** JSONL archive covers a DOM overlay row (DOM may be a truncated viewport). */
20
+ export declare function archiveCoversOverlay(archive: ChatMessage, overlay: ChatMessage, opts?: {
21
+ maxArchiveKey?: number;
22
+ }): boolean;
5
23
  export declare function messagesEquivalent(a: ChatMessage, b: ChatMessage): boolean;
6
24
  export declare function pickPreferredMessage(prev: ChatMessage, next: ChatMessage, preferNext: boolean): ChatMessage;
25
+ export declare function messageOrderKey(m: ChatMessage): number;
26
+ /** DOM flat-index vs JSONL line ts — see app `anchorDomOverlaySortKeys`. */
27
+ export declare function anchorDomOverlaySortKeys(history: ChatMessage[], live: ChatMessage[]): ChatMessage[];
7
28
  export declare function sortMessagesChronologically(messages: ChatMessage[]): ChatMessage[];
8
29
  export declare function prepareChatMessagesForDisplay(messages: ChatMessage[]): ChatMessage[];
9
30
  export declare function mergeHistoryTail(prev: ChatMessage[], incoming: ChatMessage[]): ChatMessage[];
31
+ /** Document order inside one DOM extract — never Cursor viewport flatIndex alone. */
32
+ export declare function sortByDomDocumentOrder(messages: ChatMessage[]): ChatMessage[];
33
+ /** Force overlay keys above archive so any sort by flatIndex stays below JSONL tail. */
34
+ export declare function stampOverlayAfterArchive(archiveFloor: number, overlay: ChatMessage[]): ChatMessage[];
35
+ export declare function archiveOrderFloor(jsonlHistory: ChatMessage[], rawJsonlRows: number): number;
36
+ /** Dedupe/collapse overlay only; order = domSeq (then stamped flatIndex). */
37
+ export declare function prepareDomOverlayForDisplay(overlay: ChatMessage[]): ChatMessage[];
38
+ /**
39
+ * Lenta for app: JSONL archive first, DOM overlay append-only.
40
+ * Order = array order here; app must not re-sort by flatIndex across the join.
41
+ */
42
+ export declare function composeDisplayLenta(jsonlHistory: ChatMessage[], domOverlay: ChatMessage[]): ChatMessage[];
43
+ /** Dev: warn if composed lenta inverts archive keys. */
44
+ export declare function assertMonotonicLenta(messages: ChatMessage[], label: string): void;
45
+ /** @deprecated Use `composeDisplayLenta` — kept for tests/tools. */
10
46
  export declare function mergeDomWithHistory(history: ChatMessage[], live: ChatMessage[]): ChatMessage[];
11
47
  export declare function historyRowsToChat(rows: HistoryMessage[]): ChatMessage[];
12
48
  export declare function filterClientDisplayList(messages: ChatMessage[]): ChatMessage[];
@@ -49,6 +49,13 @@ export function userTextsEquivalent(ta, tb) {
49
49
  if (short.length < 12)
50
50
  return false;
51
51
  if (long.includes(short)) {
52
+ // Новый user_query, цитирующий старый внутри длинного текста — не дедупить.
53
+ if (long.length > short.length * 1.2 &&
54
+ /<user_query>/i.test(long) &&
55
+ !long.startsWith(short) &&
56
+ !long.endsWith(short)) {
57
+ return false;
58
+ }
52
59
  if (long.endsWith(short) || long.startsWith(short))
53
60
  return true;
54
61
  if (short.length / long.length >= 0.4)
@@ -118,6 +125,9 @@ function pickPreferredUserMessage(a, b, preferB) {
118
125
  /** DOM textContent vs JSONL markdown — same turn, different surface form. */
119
126
  function normalizeAssistantCompareText(text) {
120
127
  return text
128
+ .replace(/[\u2013\u2014\u2212]/g, '-')
129
+ .replace(/\*\*/g, '')
130
+ .replace(/`+/g, '')
121
131
  .replace(/^#+\s*/gm, '')
122
132
  .replace(/\|/g, ' ')
123
133
  .replace(/[-]{3,}/g, ' ')
@@ -125,6 +135,18 @@ function normalizeAssistantCompareText(text) {
125
135
  .trim()
126
136
  .toLowerCase();
127
137
  }
138
+ function wordBagOverlapRatio(textA, textB) {
139
+ const wa = new Set((textA.match(/[\p{L}\p{N}]{3,}/gu) ?? []).map((w) => w.toLowerCase()));
140
+ const wb = new Set((textB.match(/[\p{L}\p{N}]{3,}/gu) ?? []).map((w) => w.toLowerCase()));
141
+ if (!wa.size || !wb.size)
142
+ return 0;
143
+ let shared = 0;
144
+ for (const w of wa) {
145
+ if (wb.has(w))
146
+ shared++;
147
+ }
148
+ return shared / Math.min(wa.size, wb.size);
149
+ }
128
150
  function assistantTextsEquivalent(a, b) {
129
151
  if (a.role !== 'assistant' || b.role !== 'assistant')
130
152
  return false;
@@ -140,7 +162,167 @@ function assistantTextsEquivalent(a, b) {
140
162
  return false;
141
163
  if (!long.includes(short))
142
164
  return false;
143
- return short.length / long.length >= 0.35;
165
+ if (long.startsWith(short) && short.length >= 32)
166
+ return true;
167
+ return short.length / Math.min(long.length, 1200) >= 0.35;
168
+ }
169
+ /** Same idea as `/debug/chats` compare — DOM textContent vs JSONL markdown. */
170
+ function compactOverlayMatchText(text, role) {
171
+ const base = role === 'assistant' ? normalizeAssistantCompareText(text) : compareUserText(text);
172
+ return base.replace(/\s/g, '');
173
+ }
174
+ function overlaySurfaceText(m) {
175
+ const t = (m.text ?? '').trim();
176
+ if (t)
177
+ return t;
178
+ return (m.html ?? '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
179
+ }
180
+ function compactTextsMatchArchiveOverlay(archive, overlay) {
181
+ const role = archive.role;
182
+ const ta = compactOverlayMatchText(overlaySurfaceText(archive), role);
183
+ const tb = compactOverlayMatchText(overlaySurfaceText(overlay), role);
184
+ if (!ta || !tb)
185
+ return false;
186
+ if (ta === tb)
187
+ return true;
188
+ const short = ta.length <= tb.length ? ta : tb;
189
+ const long = ta.length <= tb.length ? tb : ta;
190
+ if (short.length < 24)
191
+ return false;
192
+ if (long.startsWith(short))
193
+ return true;
194
+ if (long.includes(short) && short.length / Math.min(long.length, 1600) >= 0.28) {
195
+ return true;
196
+ }
197
+ return false;
198
+ }
199
+ function assistantArchiveCoversOverlay(archive, overlay) {
200
+ if (assistantTextsEquivalent(archive, overlay) || messagesEquivalent(archive, overlay)) {
201
+ return true;
202
+ }
203
+ if (compactTextsMatchArchiveOverlay(archive, overlay))
204
+ return true;
205
+ const ta = normalizeAssistantCompareText(overlaySurfaceText(archive));
206
+ const tb = normalizeAssistantCompareText(overlaySurfaceText(overlay));
207
+ if (!ta || !tb)
208
+ return false;
209
+ const head = 72;
210
+ if (ta.slice(0, head) === tb.slice(0, head) && head >= 40)
211
+ return true;
212
+ const short = ta.length <= tb.length ? ta : tb;
213
+ const long = ta.length <= tb.length ? tb : ta;
214
+ if (short.length >= 48 && long.includes(short))
215
+ return true;
216
+ // DOM textContent vs JSONL markdown: таблицы/тире дают 1–2 отличия на тысячи символов.
217
+ if (ta.length >= 120 && tb.length >= 120) {
218
+ if (wordBagOverlapRatio(ta, tb) >= 0.88)
219
+ return true;
220
+ if (wordBagOverlapRatio(ta.slice(0, 420), tb.slice(0, 420)) >= 0.92 &&
221
+ ta.slice(0, 56) === tb.slice(0, 56)) {
222
+ return true;
223
+ }
224
+ }
225
+ return false;
226
+ }
227
+ /**
228
+ * JSONL `flatIndex` = file line number (~1..N); Cursor DOM = `data-flat-index` (often 1000+).
229
+ * When DOM is ahead of the archive tail, fuzzy cover hid live rows (debug: NEEDS_DOM_OVERLAY, overlay 0).
230
+ */
231
+ export const JSONL_DOM_ORDER_GAP = 32;
232
+ export function maxArchiveOrderKey(archive) {
233
+ let max = 0;
234
+ for (const m of archive)
235
+ max = Math.max(max, messageOrderKey(m));
236
+ return max;
237
+ }
238
+ function isCursorDomOverlayRow(overlay) {
239
+ const id = overlay.id ?? '';
240
+ if (id.startsWith('hist-'))
241
+ return false;
242
+ const flat = overlay.flatIndex;
243
+ return flat != null && Number.isFinite(flat) && flat >= 64;
244
+ }
245
+ export function isDomOrderAheadOfJsonlArchive(overlay, maxArchiveKey) {
246
+ if (!isCursorDomOverlayRow(overlay))
247
+ return false;
248
+ return (overlay.flatIndex ?? 0) > maxArchiveKey + JSONL_DOM_ORDER_GAP;
249
+ }
250
+ /** Exact / near-exact match only — no word-bag (safe when DOM timeline > JSONL tail). */
251
+ export function archiveCoversOverlayStrict(archive, overlay) {
252
+ if (archive.role !== overlay.role)
253
+ return false;
254
+ if (archive.role === 'user') {
255
+ if (compactTextsMatchArchiveOverlay(archive, overlay))
256
+ return true;
257
+ return userMessagesEquivalent(archive, overlay);
258
+ }
259
+ if (archive.role === 'assistant') {
260
+ return (assistantTextsEquivalent(archive, overlay) ||
261
+ messagesEquivalent(archive, overlay) ||
262
+ compactTextsMatchArchiveOverlay(archive, overlay));
263
+ }
264
+ return messagesEquivalent(archive, overlay);
265
+ }
266
+ /** Fuzzy match (markdown vs DOM textContent, word-bag, prefixes). */
267
+ function archiveCoversOverlayFuzzy(archive, overlay) {
268
+ if (archive.role !== overlay.role)
269
+ return false;
270
+ if (archive.role === 'user') {
271
+ if (compactTextsMatchArchiveOverlay(archive, overlay))
272
+ return true;
273
+ const a = compareUserText(overlaySurfaceText(archive));
274
+ const b = compareUserText(overlaySurfaceText(overlay));
275
+ if (!a || !b)
276
+ return false;
277
+ if (a === b)
278
+ return true;
279
+ const short = a.length <= b.length ? a : b;
280
+ const long = a.length <= b.length ? b : a;
281
+ return short.length >= 24 && long.includes(short);
282
+ }
283
+ if (archive.role === 'assistant') {
284
+ return assistantArchiveCoversOverlay(archive, overlay);
285
+ }
286
+ return messagesEquivalent(archive, overlay);
287
+ }
288
+ /**
289
+ * When DOM flat-index is ahead of JSONL line keys, allow fuzzy dedupe once JSONL caught up,
290
+ * but keep overlay if DOM body is still growing (streaming).
291
+ */
292
+ const DOM_AHEAD_STREAM_MARGIN = { assistant: 48, user: 24 };
293
+ function overlayStillStreamingPastArchive(archive, overlay) {
294
+ const aLen = (archive.text ?? '').trim().length;
295
+ const oLen = (overlay.text ?? '').trim().length;
296
+ if (!aLen || !oLen)
297
+ return false;
298
+ const margin = overlay.role === 'user' ? DOM_AHEAD_STREAM_MARGIN.user : DOM_AHEAD_STREAM_MARGIN.assistant;
299
+ return oLen > aLen + margin;
300
+ }
301
+ /**
302
+ * Viewport rows for the live tail: not yet represented in JSONL archive.
303
+ * Uses `archiveCoversOverlay` (strict / fuzzy / streaming) — same rule for idle and generating.
304
+ */
305
+ export function selectDomOverlayFromViewport(viewport, archive) {
306
+ if (!viewport.length)
307
+ return [];
308
+ if (!archive.length)
309
+ return viewport;
310
+ const maxArchiveKey = maxArchiveOrderKey(archive);
311
+ return viewport.filter((d) => !archive.some((h) => archiveCoversOverlay(h, d, { maxArchiveKey })));
312
+ }
313
+ /** JSONL archive covers a DOM overlay row (DOM may be a truncated viewport). */
314
+ export function archiveCoversOverlay(archive, overlay, opts) {
315
+ const maxKey = opts?.maxArchiveKey;
316
+ if (maxKey != null && isDomOrderAheadOfJsonlArchive(overlay, maxKey)) {
317
+ if (archiveCoversOverlayStrict(archive, overlay))
318
+ return true;
319
+ if (!archiveCoversOverlayFuzzy(archive, overlay))
320
+ return false;
321
+ if (overlayStillStreamingPastArchive(archive, overlay))
322
+ return false;
323
+ return true;
324
+ }
325
+ return archiveCoversOverlayFuzzy(archive, overlay);
144
326
  }
145
327
  function messageKey(m) {
146
328
  const n = normalizeMessage(m);
@@ -195,16 +377,30 @@ export function pickPreferredMessage(prev, next, preferNext) {
195
377
  pick = preferNext ? next : prev;
196
378
  return mergeTimelineFields(prev, next, pick);
197
379
  }
198
- function messageOrderKey(m) {
380
+ export function messageOrderKey(m) {
199
381
  if (m.flatIndex != null && Number.isFinite(m.flatIndex))
200
382
  return m.flatIndex;
201
383
  if (m.domSeq != null && Number.isFinite(m.domSeq))
202
384
  return m.domSeq;
203
- const hist = /^hist-(\d+)/.exec(m.id);
385
+ const hist = /^hist-(\d+)/.exec(m.id ?? '');
204
386
  if (hist)
205
387
  return Number(hist[1]);
206
388
  return 0;
207
389
  }
390
+ /** DOM flat-index vs JSONL line ts — see app `anchorDomOverlaySortKeys`. */
391
+ export function anchorDomOverlaySortKeys(history, live) {
392
+ if (!live.length)
393
+ return live;
394
+ let maxKey = -1;
395
+ for (const m of history) {
396
+ maxKey = Math.max(maxKey, messageOrderKey(m));
397
+ }
398
+ const sortedLive = sortMessagesChronologically(live);
399
+ return sortedLive.map((m, i) => ({
400
+ ...m,
401
+ flatIndex: maxKey + 1 + i,
402
+ }));
403
+ }
208
404
  export function sortMessagesChronologically(messages) {
209
405
  return [...messages].sort((a, b) => {
210
406
  const ka = messageOrderKey(a);
@@ -331,6 +527,25 @@ function collapseAssistantBursts(messages) {
331
527
  export function prepareChatMessagesForDisplay(messages) {
332
528
  return collapseAssistantBursts(dedupeUserMessages(fixAssistantFragmentSandwich(messages)));
333
529
  }
530
+ /** Tail overlap for JSONL incremental merge — user rows only on exact text (duplicate line in file). */
531
+ function tailOverlapForMerge(prev, incoming) {
532
+ const max = Math.min(prev.length, incoming.length, 40);
533
+ let overlap = 0;
534
+ for (let n = 1; n <= max; n++) {
535
+ const a = prev.slice(-n);
536
+ const b = incoming.slice(0, n);
537
+ if (a.every((m, i) => {
538
+ const o = b[i];
539
+ if (m.role === 'user' && o.role === 'user') {
540
+ return compareUserText(m.text ?? '') === compareUserText(o.text ?? '');
541
+ }
542
+ return messagesEquivalent(m, o);
543
+ })) {
544
+ overlap = n;
545
+ }
546
+ }
547
+ return overlap;
548
+ }
334
549
  function tailOverlap(history, live) {
335
550
  const max = Math.min(history.length, live.length, 40);
336
551
  let overlap = 0;
@@ -342,6 +557,13 @@ function tailOverlap(history, live) {
342
557
  }
343
558
  return overlap;
344
559
  }
560
+ function mergeOverlappingBoundary(prev, incoming, overlap) {
561
+ let picked = prev[prev.length - overlap];
562
+ for (let i = 0; i < overlap; i++) {
563
+ picked = pickPreferredMessage(picked, incoming[i], true);
564
+ }
565
+ return picked;
566
+ }
345
567
  function upsertLiveMessage(merged, m, live, overlap, preferNext) {
346
568
  const tailWindow = Math.max(live.length - overlap, 0) + 6;
347
569
  const searchFrom = Math.max(0, merged.length - tailWindow);
@@ -364,35 +586,76 @@ export function mergeHistoryTail(prev, incoming) {
364
586
  return prepareChatMessagesForDisplay(incoming);
365
587
  const prevN = normalizeAll(prev);
366
588
  const inc = normalizeAll(incoming);
367
- let overlap = 0;
368
- const maxOverlap = Math.min(prevN.length, inc.length, 40);
369
- for (let n = 1; n <= maxOverlap; n++) {
370
- const a = prevN.slice(-n);
371
- const b = inc.slice(0, n);
372
- if (a.every((m, i) => messagesEquivalent(m, b[i])))
373
- overlap = n;
374
- }
589
+ const overlap = tailOverlapForMerge(prevN, inc);
375
590
  const prefix = prevN.slice(0, Math.max(0, prevN.length - overlap));
376
591
  const merged = [...prefix];
592
+ if (overlap > 0) {
593
+ upsertLiveMessage(merged, mergeOverlappingBoundary(prevN, inc, overlap), inc, overlap, true);
594
+ }
377
595
  for (const m of inc.slice(overlap)) {
378
596
  upsertLiveMessage(merged, m, inc, overlap, true);
379
597
  }
380
598
  return prepareChatMessagesForDisplay(merged);
381
599
  }
382
- export function mergeDomWithHistory(history, live) {
383
- const hist = dedupeUserMessages(history);
384
- const collapsedLive = collapseAssistantBursts(live);
385
- if (!collapsedLive.length)
386
- return prepareChatMessagesForDisplay(hist);
387
- if (!hist.length)
388
- return prepareChatMessagesForDisplay(collapsedLive);
389
- const overlap = tailOverlap(hist, collapsedLive);
390
- const prefix = hist.slice(0, Math.max(0, hist.length - overlap));
391
- const merged = [...prefix];
392
- for (let i = overlap; i < collapsedLive.length; i++) {
393
- upsertLiveMessage(merged, collapsedLive[i], collapsedLive, overlap, true);
600
+ /** Document order inside one DOM extract — never Cursor viewport flatIndex alone. */
601
+ export function sortByDomDocumentOrder(messages) {
602
+ return [...messages].sort((a, b) => {
603
+ const da = a.domSeq ?? 0;
604
+ const db = b.domSeq ?? 0;
605
+ if (da !== db)
606
+ return da - db;
607
+ return (a.flatIndex ?? 0) - (b.flatIndex ?? 0);
608
+ });
609
+ }
610
+ /** Force overlay keys above archive so any sort by flatIndex stays below JSONL tail. */
611
+ export function stampOverlayAfterArchive(archiveFloor, overlay) {
612
+ let seq = Math.max(archiveFloor, 0);
613
+ return overlay.map((m) => {
614
+ seq += 1;
615
+ return { ...m, flatIndex: seq };
616
+ });
617
+ }
618
+ export function archiveOrderFloor(jsonlHistory, rawJsonlRows) {
619
+ let floor = Math.max(rawJsonlRows, 0);
620
+ for (const m of jsonlHistory) {
621
+ floor = Math.max(floor, messageOrderKey(m));
394
622
  }
395
- return prepareChatMessagesForDisplay(merged);
623
+ return floor;
624
+ }
625
+ /** Dedupe/collapse overlay only; order = domSeq (then stamped flatIndex). */
626
+ export function prepareDomOverlayForDisplay(overlay) {
627
+ if (!overlay.length)
628
+ return [];
629
+ return collapseAssistantBursts(dedupeUserMessages(sortByDomDocumentOrder(overlay)));
630
+ }
631
+ /**
632
+ * Lenta for app: JSONL archive first, DOM overlay append-only.
633
+ * Order = array order here; app must not re-sort by flatIndex across the join.
634
+ */
635
+ export function composeDisplayLenta(jsonlHistory, domOverlay) {
636
+ if (!domOverlay.length)
637
+ return [...jsonlHistory];
638
+ if (!jsonlHistory.length)
639
+ return [...domOverlay];
640
+ return [...jsonlHistory, ...domOverlay];
641
+ }
642
+ /** Dev: warn if composed lenta inverts archive keys. */
643
+ export function assertMonotonicLenta(messages, label) {
644
+ if (process.env.LENTA_ORDER_ASSERT !== '1')
645
+ return;
646
+ for (let i = 1; i < messages.length; i++) {
647
+ const prev = messageOrderKey(messages[i - 1]);
648
+ const cur = messageOrderKey(messages[i]);
649
+ if (cur < prev) {
650
+ console.warn(`[lenta-order] inversion ${label} i=${i} ${prev}>${cur}`);
651
+ }
652
+ }
653
+ }
654
+ /** @deprecated Use `composeDisplayLenta` — kept for tests/tools. */
655
+ export function mergeDomWithHistory(history, live) {
656
+ const hist = prepareChatMessagesForDisplay(dedupeUserMessages(history));
657
+ const collapsedLive = prepareChatMessagesForDisplay(collapseAssistantBursts(live));
658
+ return composeDisplayLenta(hist, collapsedLive);
396
659
  }
397
660
  export function historyRowsToChat(rows) {
398
661
  return rows.map((m, i) => {
@@ -1,6 +1,8 @@
1
1
  import type { CursorState } from './types.js';
2
2
  export declare function normalizeAgentTitle(title: string): string;
3
- export declare function isComposerUuid(id: string | undefined): boolean;
3
+ export declare function isComposerUuid(id: string | undefined): id is string;
4
4
  export declare function resolveCursorActiveComposerId(state: CursorState): string | undefined;
5
+ /** Cursor is generating for this composer (active tab or sidebar spinner). */
6
+ export declare function isAgentGenerating(agentId: string, state: CursorState): boolean;
5
7
  /** Live DOM applies only when the subscribed app chat matches Cursor's active composer. */
6
8
  export declare function isChatSyncedWithCursor(selectedAgentId: string | undefined, selectedTitle: string | undefined, state: Pick<CursorState, 'activeComposerId' | 'tabs' | 'composerIdByTitle' | 'activeChatTitle'>): boolean;