Skip to content

Commit e9addab

Browse files
fix(agentic): never restate typed draft in ghost suggestions
Offline cold-start was returning full voice samples that already began with the draft (e.g. \"kylrix is a…\"). Normalize all suggestions to a true suffix before show/cache/AI strip. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 69784bc commit e9addab

7 files changed

Lines changed: 136 additions & 45 deletions

File tree

hooks/useMomentIntelligence.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
recordAiInference,
1414
suggestOfflineReply,
1515
} from '@/lib/agentic/offline-complete';
16+
import { asSuggestionSuffix } from '@/lib/agentic/suggestion-suffix';
1617

1718
const loadSuggestCache = () => import('@/lib/agentic/suggestion-cache');
1819

@@ -64,6 +65,12 @@ export function useMomentIntelligence(opts: {
6465
const [acceptStreak, setAcceptStreak] = useState(0);
6566
const [showWand, setShowWand] = useState(false);
6667

68+
const applySuggestion = useCallback((raw: string, forDraft: string) => {
69+
const cleaned = asSuggestionSuffix(forDraft, raw);
70+
setSuggestion(cleaned);
71+
return cleaned;
72+
}, []);
73+
6774
const samplesRef = useRef<MomentVoiceSample[]>([]);
6875
const hintsRef = useRef<string[]>([]);
6976
const reqIdRef = useRef(0);
@@ -159,7 +166,7 @@ export function useMomentIntelligence(opts: {
159166
if (myReq !== reqIdRef.current) return;
160167
if (cached) {
161168
sourceRef.current = 'offline';
162-
setSuggestion(cached);
169+
applySuggestion(cached, draft);
163170
setBusy(false);
164171
return;
165172
}
@@ -189,15 +196,15 @@ export function useMomentIntelligence(opts: {
189196
if (source === 'offline') {
190197
sourceRef.current = 'offline';
191198
const text = offline.trim();
192-
setSuggestion(text);
199+
applySuggestion(text, draft);
193200
setBusy(false);
194201
if (text) void rememberSuggestion({ scope, userId, draft: cacheDraft, suggestion: text });
195202
return;
196203
}
197204

198205
if (source !== 'ai' || !isPro) {
199206
sourceRef.current = 'offline';
200-
setSuggestion(offline.trim());
207+
applySuggestion(offline, draft);
201208
setBusy(false);
202209
if (offline.trim()) {
203210
void rememberSuggestion({ scope, userId, draft: cacheDraft, suggestion: offline.trim() });
@@ -223,7 +230,7 @@ export function useMomentIntelligence(opts: {
223230
if (!res.success) {
224231
if (String(res.error || '').toLowerCase().includes('pro')) onOpenPro();
225232
sourceRef.current = 'offline';
226-
setSuggestion(offline.trim());
233+
applySuggestion(offline, draft);
227234
if (offline.trim()) {
228235
void rememberSuggestion({ scope, userId, draft: cacheDraft, suggestion: offline.trim() });
229236
}
@@ -232,19 +239,19 @@ export function useMomentIntelligence(opts: {
232239
const aiText = String(res.completion || '').trim();
233240
if (aiText) {
234241
sourceRef.current = 'ai';
235-
setSuggestion(aiText);
242+
applySuggestion(aiText, draft);
236243
void rememberSuggestion({ scope, userId, draft: cacheDraft, suggestion: aiText });
237244
} else {
238245
sourceRef.current = 'offline';
239-
setSuggestion(offline.trim());
246+
applySuggestion(offline, draft);
240247
if (offline.trim()) {
241248
void rememberSuggestion({ scope, userId, draft: cacheDraft, suggestion: offline.trim() });
242249
}
243250
}
244251
} catch {
245252
if (myReq === reqIdRef.current) {
246253
sourceRef.current = 'offline';
247-
setSuggestion(offline.trim());
254+
applySuggestion(offline, draft);
248255
}
249256
} finally {
250257
if (myReq === reqIdRef.current) setBusy(false);

hooks/useTypeIntelligence.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
pickCompletionSource,
1313
recordAiInference,
1414
} from '@/lib/agentic/offline-complete';
15+
import { asSuggestionSuffix } from '@/lib/agentic/suggestion-suffix';
1516

1617
const loadSuggestCache = () => import('@/lib/agentic/suggestion-cache');
1718

@@ -40,6 +41,11 @@ export function useTypeIntelligence(opts: {
4041

4142
const [learningStatus, setLearningStatus] = useState<LearningStatus>('off');
4243
const [suggestion, setSuggestion] = useState('');
44+
const applySuggestion = useCallback((raw: string, forDraft: string) => {
45+
const cleaned = asSuggestionSuffix(forDraft, raw);
46+
setSuggestion(cleaned);
47+
return cleaned;
48+
}, []);
4349
const [busy, setBusy] = useState(false);
4450
const [acceptStreak, setAcceptStreak] = useState(0);
4551
const [showWand, setShowWand] = useState(false);
@@ -102,7 +108,7 @@ export function useTypeIntelligence(opts: {
102108
if (myReq !== reqIdRef.current) return;
103109
if (cached) {
104110
sourceRef.current = 'offline';
105-
setSuggestion(cached);
111+
applySuggestion(cached, draft);
106112
setBusy(false);
107113
return;
108114
}
@@ -121,7 +127,7 @@ export function useTypeIntelligence(opts: {
121127
if (source === 'offline') {
122128
sourceRef.current = 'offline';
123129
const text = offline.trim();
124-
setSuggestion(text);
130+
applySuggestion(text, draft);
125131
setBusy(false);
126132
if (text) void rememberSuggestion({ scope, userId, draft, suggestion: text });
127133
return;
@@ -150,7 +156,7 @@ export function useTypeIntelligence(opts: {
150156
if (!res.success) {
151157
if (String(res.error || '').toLowerCase().includes('pro')) onOpenPro();
152158
sourceRef.current = 'offline';
153-
setSuggestion(offline.trim());
159+
applySuggestion(offline, draft);
154160
if (offline.trim()) {
155161
void rememberSuggestion({ scope, userId, draft, suggestion: offline.trim() });
156162
}
@@ -159,19 +165,19 @@ export function useTypeIntelligence(opts: {
159165
const aiText = String(res.completion || '').trim();
160166
if (aiText) {
161167
sourceRef.current = 'ai';
162-
setSuggestion(aiText);
168+
applySuggestion(aiText, draft);
163169
void rememberSuggestion({ scope, userId, draft, suggestion: aiText });
164170
} else {
165171
sourceRef.current = 'offline';
166-
setSuggestion(offline.trim());
172+
applySuggestion(offline, draft);
167173
if (offline.trim()) {
168174
void rememberSuggestion({ scope, userId, draft, suggestion: offline.trim() });
169175
}
170176
}
171177
} catch {
172178
if (myReq === reqIdRef.current) {
173179
sourceRef.current = 'offline';
174-
setSuggestion(offline.trim());
180+
applySuggestion(offline, draft);
175181
}
176182
} finally {
177183
if (myReq === reqIdRef.current) setBusy(false);

lib/actions/moment-doppelganger.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
import { AI_REQUIRES_PRO_MESSAGE } from '@/lib/agentic/access';
1919
import { userHasPaidAiAccess } from '@/lib/server/ai-subscription-gate';
2020

21+
import { asSuggestionSuffix } from '@/lib/agentic/suggestion-suffix';
22+
2123
async function getActor(jwt?: string) {
2224
const { getActor } = await import('./secure-ops');
2325
return getActor(jwt);
@@ -34,17 +36,13 @@ function cleanModelText(raw: string): string {
3436

3537
function stripDraftPrefix(completion: string, draft: string): string {
3638
let out = cleanModelText(completion);
37-
const d = draft.trim();
38-
if (d && out.startsWith(d)) {
39-
out = out.slice(d.length).replace(/^\s+/, '');
40-
}
4139
if (out.startsWith('{')) {
4240
try {
4341
const j = JSON.parse(out);
4442
out = String(j.suffix || j.completion || j.post || j.text || '').trim() || out;
4543
} catch {}
4644
}
47-
return out;
45+
return asSuggestionSuffix(draft, out);
4846
}
4947

5048
/**

lib/actions/type-intel.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { TypeIntelKind } from '@/lib/agentic/type-intel-kinds';
1919
import { TYPE_INTEL_KINDS } from '@/lib/agentic/type-intel-kinds';
2020
import { AI_REQUIRES_PRO_MESSAGE } from '@/lib/agentic/access';
2121
import { userHasPaidAiAccess } from '@/lib/server/ai-subscription-gate';
22+
import { asSuggestionSuffix } from '@/lib/agentic/suggestion-suffix';
2223

2324
async function getActor(jwt?: string) {
2425
const { getActor } = await import('./secure-ops');
@@ -36,17 +37,13 @@ function cleanModelText(raw: string): string {
3637

3738
function stripDraftPrefix(completion: string, draft: string): string {
3839
let out = cleanModelText(completion);
39-
const d = draft.trim();
40-
if (d && out.startsWith(d)) {
41-
out = out.slice(d.length).replace(/^\s+/, '');
42-
}
4340
if (out.startsWith('{')) {
4441
try {
4542
const j = JSON.parse(out);
4643
out = String(j.suffix || j.completion || j.post || j.text || j.content || '').trim() || out;
4744
} catch {}
4845
}
49-
return out;
46+
return asSuggestionSuffix(draft, out);
5047
}
5148

5249
function assertKind(kind: string): TypeIntelKind {

lib/agentic/offline-complete.ts

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import { predictiveAutocomplete } from '@/lib/contextual-engine/predictive-autocomplete';
77
import type { ContextualNiche } from '@/lib/contextual-engine/types';
8+
import { asSuggestionSuffix } from '@/lib/agentic/suggestion-suffix';
89

910
const AI_ALLOW_RATIO = 0.4; // at most ~40% of fallbacks may hit AI
1011
const MIN_AI_GAP_MS = 14_000;
@@ -36,15 +37,17 @@ export function completeOfflineSuffix(
3637
const text = String(draft || '');
3738
if (text.trim().length < 2) return '';
3839

40+
const finish = (raw: string) => asSuggestionSuffix(text, raw);
41+
3942
try {
4043
const pred = predictiveAutocomplete.predict(text, text.length, {
4144
niche: options.niche || 'productivity',
4245
minConfidence: options.minConfidence ?? 0.55,
4346
});
44-
const inline = String(pred.inlineSuffix || '').trim();
47+
const inline = finish(String(pred.inlineSuffix || ''));
4548
if (inline.length >= 2) return inline.slice(0, 120);
46-
const top = pred.suggestions?.[0]?.text;
47-
if (top && String(top).trim().length >= 2) return String(top).trim().slice(0, 120);
49+
const top = finish(String(pred.suggestions?.[0]?.text || ''));
50+
if (top.length >= 2) return top.slice(0, 120);
4851
} catch {}
4952

5053
const words = text.trim().split(/\s+/);
@@ -54,23 +57,22 @@ export function completeOfflineSuffix(
5457
const body = String(s?.text || '');
5558
const idx = body.toLowerCase().indexOf(tail);
5659
if (idx >= 0) {
57-
const rest = body.slice(idx + tail.length).replace(/^\s+/, '');
60+
const rest = finish(body.slice(idx + tail.length));
5861
if (rest.length >= 4) return rest.slice(0, 100);
5962
}
6063
}
6164
}
6265

63-
// Weak cold-start: borrow a short fragment from a sample that shares a word
64-
const lastWord = (words[words.length - 1] || '').toLowerCase().replace(/[^a-z0-9]/gi, '');
66+
// Weak cold-start: only remainder after shared opening with draft (never restate draft)
67+
const lastWord = (words[words.length - 1] || '').toLowerCase().replace(/[^a-z0-9']/gi, '');
6568
if (lastWord.length >= 4) {
6669
for (const s of samples) {
67-
const body = String(s?.text || '');
68-
if (body.toLowerCase().includes(lastWord)) {
69-
const parts = body.split(/[.!?\n]/).map((p) => p.trim()).filter((p) => p.length > 12);
70-
const pick = parts[0];
71-
if (pick && !text.toLowerCase().includes(pick.toLowerCase().slice(0, 20))) {
72-
return pick.slice(0, 80);
73-
}
70+
const body = String(s?.text || '').trim();
71+
if (!body.toLowerCase().includes(lastWord)) continue;
72+
const parts = body.split(/[.!?\n]/).map((p) => p.trim()).filter((p) => p.length > 12);
73+
for (const pick of parts) {
74+
const suffix = finish(pick);
75+
if (suffix.length >= 4) return suffix.slice(0, 80);
7476
}
7577
}
7678
}
@@ -100,7 +102,6 @@ export function suggestOfflineReply(
100102
.map((s) => String(s?.text || '').trim())
101103
.filter((s) => s.length >= 8 && s.length <= 160);
102104

103-
// Prefer short sample replies that do not echo the parent verbatim
104105
for (const s of pool) {
105106
if (parent && parent.slice(0, 40).toLowerCase() === s.slice(0, 40).toLowerCase()) continue;
106107
if (/^(just shared|shared an update)/i.test(s)) continue;
@@ -109,7 +110,6 @@ export function suggestOfflineReply(
109110

110111
if (!parent) return '';
111112

112-
// Soft topic hook from parent first words — restrained, not fake praise
113113
const topic = parent
114114
.replace(/\s+/g, ' ')
115115
.slice(0, 48)
@@ -160,9 +160,7 @@ export async function pickCompletionSource(opts: {
160160
allowAi: boolean;
161161
}): Promise<'offline' | 'ai' | 'none'> {
162162
if (opts.offlineSuffix.trim().length >= 2) {
163-
// Even with offline hit, rarely skip to AI only if offline is tiny AND budget — prefer offline
164163
if (opts.offlineSuffix.trim().length >= 6) return 'offline';
165-
// Short offline: still prefer offline 70%
166164
if (Math.random() < 0.7) return 'offline';
167165
}
168166
if (!opts.allowAi) return opts.offlineSuffix.trim() ? 'offline' : 'none';

lib/agentic/suggestion-cache.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* Exact draft hits + near-prefix recovery. Persisted in LocalEngine per scope.
44
*/
55

6+
import { asSuggestionSuffix } from '@/lib/agentic/suggestion-suffix';
7+
68
const MAX_ENTRIES = 120;
79
const TTL_MS = 1000 * 60 * 60 * 24 * 14; // 14 days
810
const PREFIX_SLACK = 28; // chars deleted from end still reuse nearest longer/shorter hit
@@ -94,7 +96,7 @@ function pickBest(entries: CacheEntry[], draftNorm: string, kind: CacheEntry['ki
9496
if (bestLonger) {
9597
// Reconstruct: remaining typed chars from the longer draft + original suggestion
9698
const remainder = bestLonger.draft.slice(draftNorm.length);
97-
const rebuilt = `${remainder}${bestLonger.suggestion}`;
99+
const rebuilt = asSuggestionSuffix(draftNorm, `${remainder}${bestLonger.suggestion}`);
98100
if (rebuilt.trim().length >= 2) {
99101
return { ...bestLonger, draft: draftNorm, suggestion: rebuilt };
100102
}
@@ -111,8 +113,9 @@ function pickBest(entries: CacheEntry[], draftNorm: string, kind: CacheEntry['ki
111113
const sug = e.suggestion;
112114
if (sug.startsWith(extra) || sug.replace(/^\s+/, '').startsWith(extra.replace(/^\s+/, ''))) {
113115
const rest = sug.startsWith(extra) ? sug.slice(extra.length) : sug.replace(/^\s+/, '').slice(extra.replace(/^\s+/, '').length);
114-
if (rest.trim().length >= 2) {
115-
const candidate = { ...e, draft: draftNorm, suggestion: rest };
116+
const cleaned = asSuggestionSuffix(draftNorm, rest);
117+
if (cleaned.trim().length >= 2) {
118+
const candidate = { ...e, draft: draftNorm, suggestion: cleaned };
116119
if (!bestShorter || e.draft.length > bestShorter.draft.length) bestShorter = candidate;
117120
}
118121
}
@@ -130,7 +133,9 @@ export async function lookupCachedSuggestion(opts: {
130133
if (draftNorm.length < 3) return null;
131134
const entries = await loadEntries(opts.scope, opts.userId);
132135
const hit = pickBest(entries, draftNorm, 'suffix');
133-
return hit?.suggestion?.trim() ? hit.suggestion : null;
136+
if (!hit?.suggestion?.trim()) return null;
137+
const cleaned = asSuggestionSuffix(opts.draft, hit.suggestion);
138+
return cleaned.trim() ? cleaned : null;
134139
}
135140

136141
/** Persist a live suffix suggestion (offline or AI). */
@@ -141,7 +146,7 @@ export async function rememberSuggestion(opts: {
141146
suggestion: string;
142147
}): Promise<void> {
143148
const draftNorm = normalizeSuggestDraft(opts.draft);
144-
const suggestion = String(opts.suggestion || '').trim();
149+
const suggestion = asSuggestionSuffix(opts.draft, String(opts.suggestion || ''));
145150
if (draftNorm.length < 3 || suggestion.length < 2) return;
146151
const entries = await loadEntries(opts.scope, opts.userId);
147152
entries.unshift({

0 commit comments

Comments
 (0)