feat(ai): OpenCode provider class; fix retrieval reading the wrong account's index
Three things, all from running the real thing rather than trusting a status code.
1. OpenCode as a 4th AI class (lib/ai/opencode.ts + app/api/ai/opencode/*).
A locally-running `opencode serve` — the same runtime Paperclip drives as
an adapter. Its appeal over a BYOK profile is precisely what was broken
before: opencode owns provider auth itself, so there is NO api key for
this app to hold, and it reports a REAL model list (25 on this machine)
instead of asking the user to type an exact provider-specific model id
from memory. Typing "Sonnet 5" into a free-text box and getting a bare
"Provider returned 401" is the failure this removes.
IMPORTANT trap, documented in the module header and pinned by a test:
opencode is NOT OpenAI-compatible. `/v1/models` and `/v1/chat/completions`
both answer 200 — because a web-UI catch-all serves index.html for ANY
unknown path. I built the first version against that assumed compatibility
on the strength of two 200s and had to throw it away once I read a body.
Every probe now validates the parsed shape and content-type, never the
status alone. The real API is GET /api/model + POST /session +
POST /session/{id}/message, and the reply's `reasoning` parts are stripped
so a model's private chain of thought can never surface as the answer.
Proxied through our own backend (like the `server` class) because the
desktop renderer's origin is a random port that changes every launch;
same-origin sidesteps opencode's CORS allowlist entirely. Loopback-only by
construction: a non-loopback OPENCODE_BASE_URL is refused, since "local,
no keys, nothing leaves the device" is the whole point of this class.
2. Retrieval read the WRONG ACCOUNT'S index. The indexer writes under the
active account's cookie slot (catchUpIndex passes it) but fetchLocalLeg
omitted `?slot=`, so search resolved to whichever account the multi-slot
resolver found first. Single-account installs never noticed; a real
multi-account/shared-mailbox setup reads an empty store every time. Both
call sites now pass the active slot.
3. "No local mail index available in this session" was shown even when the
index existed and simply matched nothing — actively misleading, and it
masked the missing-SESSION_SECRET bug for hours. AskResult now carries
retrievalState ('augmented' | 'no-match' | 'no-index') and the two cases
get different words: build the index, versus rephrase (with the honest
caveat that keyword search answers content questions better than recency
ones like "the last mail").
Verified live against real opencode 1.18.14: discovery found 25 models and a
real prompt round-tripped the exact expected answer through the real helper
code, not curl. Gate: tsc clean, eslint clean, 2512/2512 unit tests (10 new,
incl. one that fails if the HTML catch-all is ever accepted as an API), build clean.
This commit is contained in:
+93
-9
@@ -150,6 +150,49 @@ export async function chatPublic(
|
||||
return content;
|
||||
}
|
||||
|
||||
// ── OpenCode: a locally-running `opencode serve` (github.com/sst/opencode),
|
||||
// the same agent runtime Paperclip drives as an adapter. Reached through THIS
|
||||
// app's own backend (app/api/ai/opencode/*) rather than directly, for the same
|
||||
// reason the `server` class is: the renderer's origin is a random localhost
|
||||
// port that changes every desktop launch, so a direct fetch would need
|
||||
// opencode's CORS allowlist updated on every start. Same-origin sidesteps it.
|
||||
//
|
||||
// It is NOT OpenAI-compatible (its `/v1/*` paths only answer 200 because a
|
||||
// web-UI catch-all serves index.html for anything unknown) - the server-side
|
||||
// helper lib/ai/opencode.ts speaks its real session API and documents that
|
||||
// trap. The reason to have it as its own class rather than "just another BYOK profile": opencode
|
||||
// owns provider auth itself, so there is no API key for this app to hold, and
|
||||
// its /api/model endpoint gives a REAL model list to pick from instead of
|
||||
// asking the user to type an exact provider-specific model id from memory.
|
||||
|
||||
export interface OpencodeModelOption {
|
||||
/** "providerID/modelID" — what gets stored and sent back on ask. */
|
||||
ref: string;
|
||||
/** Human-readable, e.g. "DeepSeek V4 Flash Free (opencode)". */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export async function listOpencodeModels(): Promise<OpencodeModelOption[]> {
|
||||
const res = await fetch('/api/ai/opencode/models');
|
||||
const body = await res.json().catch(() => ({}));
|
||||
// 503 carries real setup guidance ("start opencode serve ..."), so surface
|
||||
// the server's own message rather than a bare status code.
|
||||
if (!res.ok) throw new Error(body?.error || `OpenCode returned ${res.status}`);
|
||||
return (body?.models ?? []) as OpencodeModelOption[];
|
||||
}
|
||||
|
||||
export async function chatOpencode(model: string, messages: ChatMessage[]): Promise<string> {
|
||||
const res = await fetch('/api/ai/opencode/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model, messages }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body?.error || `OpenCode returned ${res.status}`);
|
||||
if (!body?.answer) throw new Error('OpenCode returned no message content');
|
||||
return body.answer as string;
|
||||
}
|
||||
|
||||
// ── Retrieval: two legs run in parallel and get Reciprocal-Rank-Fused
|
||||
// (docs/AI-ASSISTANT-CONCEPT.md §7 steps 2-3), exactly like the doc
|
||||
// describes — this is real, not a single degraded leg wearing SourceRef's
|
||||
@@ -179,6 +222,18 @@ export interface AskResult {
|
||||
sources: AskSource[];
|
||||
/** True when the question was answered without any retrieved context. */
|
||||
unaugmented: boolean;
|
||||
/**
|
||||
* WHY the answer was unaugmented — the two cases need different words and
|
||||
* different user action, and conflating them is actively misleading:
|
||||
* - 'no-index': the local index isn't available at all (not the desktop
|
||||
* app, no keyring, not signed in, or never built). Told to build it.
|
||||
* - 'no-match': the index IS there and answered; this query just matched
|
||||
* nothing. Told to rephrase. Recency questions ("the last mail", "all
|
||||
* mail in July") land here by design: the index ranks by keyword
|
||||
* relevance and has no notion of "latest" or a date range.
|
||||
* - 'augmented': context was found and used.
|
||||
*/
|
||||
retrievalState: 'augmented' | 'no-match' | 'no-index';
|
||||
/** True the moment this call consumed a previously-unassigned licensed
|
||||
* seat on the `server` class (lib/ai/entitlement.ts). Always false for
|
||||
* `local`/`public`, which aren't entitlement-gated. */
|
||||
@@ -213,11 +268,25 @@ interface RetrievedContext {
|
||||
hits: Array<{ id: string; title: string }>;
|
||||
}
|
||||
|
||||
async function fetchLocalLeg(question: string): Promise<{ scored: Scored<SourceRef>[]; text: Map<string, { title: string; snippet: string }> }> {
|
||||
const empty = { scored: [] as Scored<SourceRef>[], text: new Map<string, { title: string; snippet: string }>() };
|
||||
/** Set by the most recent retrieveContext() call so askMail can report WHY an
|
||||
* answer was unaugmented. Module-scoped rather than threaded through the
|
||||
* return type because retrieveContext returns null precisely in the case we
|
||||
* need to describe, and a null can't carry a reason. Single-threaded UI, one
|
||||
* question at a time - no interleaving to worry about. */
|
||||
let lastLocalIndexReachable = false;
|
||||
|
||||
async function fetchLocalLeg(question: string, slot?: number): Promise<{ scored: Scored<SourceRef>[]; text: Map<string, { title: string; snippet: string }>; indexReachable: boolean }> {
|
||||
const empty = { scored: [] as Scored<SourceRef>[], text: new Map<string, { title: string; snippet: string }>(), indexReachable: false };
|
||||
try {
|
||||
const res = await fetch(`/api/offline/search?q=${encodeURIComponent(question)}&limit=6`);
|
||||
if (!res.ok) return empty; // 404/503 — no local index this session, not an error
|
||||
// `slot` is load-bearing, not optional decoration: the INDEXER writes under
|
||||
// the active account's cookie slot (lib/mail-index-client.ts's catchUpIndex
|
||||
// passes it), so a search that omits it resolves to whatever account the
|
||||
// multi-slot resolver finds FIRST and can read a different - usually empty -
|
||||
// account's index. Single-account installs never noticed; a real
|
||||
// multi-account/shared-mailbox setup reads the wrong store every time.
|
||||
const slotQuery = typeof slot === 'number' ? `&slot=${slot}` : '';
|
||||
const res = await fetch(`/api/offline/search?q=${encodeURIComponent(question)}&limit=6${slotQuery}`);
|
||||
if (!res.ok) return empty; // 404/503/401 — no usable index this session, not an error
|
||||
const body = (await res.json()) as OfflineSearchResponse;
|
||||
if (!body.ok) return empty;
|
||||
const text = new Map(body.hits.map((h) => [h.id, { title: h.title, snippet: h.snippet ?? '' }]));
|
||||
@@ -225,7 +294,11 @@ async function fetchLocalLeg(question: string): Promise<{ scored: Scored<SourceR
|
||||
ref: { product: 'mail' as const, accountId: h.jmapAccountId, collectionId: '', itemId: h.id, chunkIx: 0 },
|
||||
score: 1 / (i + 1), // rank position is all reciprocalRankFusion reads
|
||||
}));
|
||||
return { scored, text };
|
||||
// Reachable even with zero hits: a 200 means the index answered. That
|
||||
// distinction is the whole point - "the index isn't there" and "the index
|
||||
// is there and this query matched nothing" are different facts the user
|
||||
// deserves to be told apart (see AskResult.retrievalState).
|
||||
return { scored, text, indexReachable: true };
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
@@ -250,8 +323,9 @@ async function fetchServerLeg(question: string): Promise<{ scored: Scored<Source
|
||||
}
|
||||
}
|
||||
|
||||
async function retrieveContext(question: string): Promise<RetrievedContext | null> {
|
||||
const [local, server] = await Promise.all([fetchLocalLeg(question), fetchServerLeg(question)]);
|
||||
async function retrieveContext(question: string, slot?: number): Promise<RetrievedContext | null> {
|
||||
const [local, server] = await Promise.all([fetchLocalLeg(question, slot), fetchServerLeg(question)]);
|
||||
lastLocalIndexReachable = local.indexReachable;
|
||||
const fused = reciprocalRankFusion([local.scored, server.scored], 6);
|
||||
if (fused.length === 0) return null;
|
||||
|
||||
@@ -293,11 +367,15 @@ export interface ResolvedPublicProfile {
|
||||
}
|
||||
|
||||
export interface AskConfig {
|
||||
provider: 'local' | 'server' | 'public';
|
||||
provider: 'local' | 'server' | 'public' | 'opencode';
|
||||
localBaseUrl: string;
|
||||
localModel: string | null;
|
||||
serverModel: string | null;
|
||||
publicProfile: ResolvedPublicProfile | null;
|
||||
opencodeModel?: string | null;
|
||||
/** Cookie slot of the account whose local index should be searched. Omitting
|
||||
* it reads whichever account the resolver finds first — see fetchLocalLeg. */
|
||||
slot?: number;
|
||||
}
|
||||
|
||||
export async function askMail(question: string, config: AskConfig): Promise<AskResult> {
|
||||
@@ -310,8 +388,11 @@ export async function askMail(question: string, config: AskConfig): Promise<AskR
|
||||
if (config.provider === 'public' && !config.publicProfile) {
|
||||
throw new Error('No provider profile selected');
|
||||
}
|
||||
if (config.provider === 'opencode' && !config.opencodeModel) {
|
||||
throw new Error('No OpenCode model selected');
|
||||
}
|
||||
|
||||
const retrieved = await retrieveContext(question);
|
||||
const retrieved = await retrieveContext(question, config.slot);
|
||||
const messages = retrieved
|
||||
? buildPrompt(question, retrieved.contextBlock)
|
||||
: [{ role: 'user' as const, content: question }];
|
||||
@@ -325,6 +406,8 @@ export async function askMail(question: string, config: AskConfig): Promise<AskR
|
||||
const result = await chatServer(config.serverModel as string, messages);
|
||||
answer = result.answer;
|
||||
seatJustAssigned = result.seatJustAssigned;
|
||||
} else if (config.provider === 'opencode') {
|
||||
answer = await chatOpencode(config.opencodeModel as string, messages);
|
||||
} else {
|
||||
answer = await chatLocal(config.localBaseUrl, config.localModel as string, messages);
|
||||
}
|
||||
@@ -333,6 +416,7 @@ export async function askMail(question: string, config: AskConfig): Promise<AskR
|
||||
answer,
|
||||
sources: (retrieved?.hits ?? []).map((h) => ({ id: h.id, subject: h.title })),
|
||||
unaugmented: !retrieved,
|
||||
retrievalState: retrieved ? 'augmented' : lastLocalIndexReachable ? 'no-match' : 'no-index',
|
||||
seatJustAssigned,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user