@atlaso-labs/opencode 0.1.0 → 0.2.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ **Your memories now survive a bad connection.** Capture used to send a memory once
6
+ and, if anything went wrong — a timeout, a server hiccup, a dropped wifi
7
+ connection — it was gone, with nothing kept locally and nothing to tell you. Every
8
+ memory is now written to disk *before* it is sent and retried automatically on a
9
+ later turn. A memory is never silently lost; if one truly cannot be delivered it is
10
+ set aside with a reason rather than discarded.
11
+
12
+ **Removing the plugin now really stops it, on every platform.** On Windows the
13
+ plugin could not obtain its own credential, which meant it never learned it had been
14
+ removed and kept syncing. Fixed.
15
+
16
+ **Secrets stay on your machine.** Explicit `remember` calls and recall queries are
17
+ now scrubbed on-device, matching what automatic capture already did. Previously a
18
+ key pasted into an explicit save or a search left your machine (it was redacted
19
+ before storage, but it should never have been sent at all).
20
+
21
+ **Memory stays in the right project.** Per-project filtering now applies to the
22
+ `recall` and `recent` tools, so one repository's notes no longer surface in another.
23
+ Repositories cloned over SSH with a custom port no longer split into two separate
24
+ projects. An explicit save can now be scoped to a project instead of always being
25
+ personal.
26
+
27
+ **Fixes**
28
+ - `status` no longer reports "connected" when the server is unreachable.
29
+ - Concurrent tool calls can no longer interleave and corrupt the MCP transport.
30
+ - A preference that merely mentions a file path is no longer trapped in one repo.
31
+ - A valid credential is no longer discarded and re-minted on every run.
package/lib/atlaso.ts CHANGED
@@ -6,21 +6,35 @@
6
6
  * endpoints over the global `fetch`. The engine stays on the server; this only
7
7
  * knows the URLs — the IP thin-client rule, in TypeScript.
8
8
  *
9
- * v1 is ONLINE-FIRST: no local cache / outbox / sync (deferred see README).
9
+ * NO local RECALL cache reads always go to the brain, keeping the ranking
10
+ * engine server-side (the IP thin-client rule). WRITES are durable: deposits go
11
+ * through lib/outbox.ts write-ahead and are retried by lib/drain.ts, so a
12
+ * timeout, 5xx, 429, WAF block or offline laptop can no longer silently lose a
13
+ * user's memory the way v1 did.
10
14
  * Every call is FAIL-OPEN (memory must never break a Cursor turn): callers get
11
15
  * `[]` / `false` on any error — never a throw. A REACHED-but-rejected token
12
16
  * (HTTP 401/403) is the one authoritative signal: we retire auth.json so the next
13
17
  * session re-authorizes (mirrors the Python client's AuthRejected handling).
14
18
  */
15
- import { readFileSync, renameSync } from "node:fs";
19
+ import {
20
+ closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync,
21
+ } from "node:fs";
22
+ import { createHash, randomUUID } from "node:crypto";
16
23
  import { homedir } from "node:os";
17
24
  import { join } from "node:path";
25
+ import { scrub } from "./capture";
18
26
 
19
27
  export interface Auth {
20
28
  server: string;
21
29
  token: string;
22
30
  user_id?: string;
23
31
  device_id?: string;
32
+ // Where this credential came from — set by resolveCredential (lib/credential.ts).
33
+ // "own" = the tool's OWN ~/.atlaso/tools/<tool>.json; "shared" = the shared bearer.
34
+ // undefined = a bare loadAuth() result (treated as shared for retirement). This is
35
+ // what lets a rejected per-tool token retire ONLY that file, never the shared one.
36
+ source?: "own" | "shared";
37
+ tool?: string; // the tool slug, when source === "own"
24
38
  }
25
39
 
26
40
  export interface RecallResult {
@@ -57,7 +71,7 @@ export function authPath(): string {
57
71
  }
58
72
 
59
73
  export function defaultServer(): string {
60
- return process.env.ATLASO_SERVER || "https://api.atlaso.ai";
74
+ return process.env.ATLASO_SERVER || "https://mcp.atlaso.ai";
61
75
  }
62
76
 
63
77
  /** {server, token, user_id, device_id} from auth.json, or null if not connected. */
@@ -89,8 +103,91 @@ export function markRevoked(): void {
89
103
  }
90
104
  }
91
105
 
92
- /** One bearer-authed JSON call, hard-bounded by a timeout. null on ANY non-2xx
93
- * or transport/parse error; 401/403 also retires the (revoked) token. */
106
+ // ── per-tool credentials (~/.atlaso/tools/<tool>.json) ───────────────────────────
107
+ //
108
+ // Each Atlaso integration on a machine holds its OWN credential, minted from the
109
+ // shared bearer (see lib/credential.ts). That's what lets the brain tell two tools
110
+ // on one device apart, so removing one truly stops it. ONE FILE PER TOOL (not a map
111
+ // inside auth.json): two hooks can fire concurrently, and separate files + a kernel
112
+ // lock avoid a read-modify-write clobber. This module owns the file I/O; the mint
113
+ // state machine lives in lib/credential.ts.
114
+
115
+ export function toolsDir(): string {
116
+ return join(atlasoDir(), "tools");
117
+ }
118
+
119
+ export function toolAuthPath(tool: string): string {
120
+ return join(toolsDir(), `${tool}.json`);
121
+ }
122
+
123
+ export function toolLockPath(tool: string): string {
124
+ return join(toolsDir(), `${tool}.lock`);
125
+ }
126
+
127
+ /** {server, token, user_id, device_id, tool} from tools/<tool>.json, or null. */
128
+ export function loadToolAuth(tool: string): Auth | null {
129
+ try {
130
+ const o = JSON.parse(readFileSync(toolAuthPath(tool), "utf-8"));
131
+ if (o && typeof o === "object" && typeof o.token === "string" && o.token) {
132
+ return {
133
+ server: typeof o.server === "string" && o.server ? o.server : defaultServer(),
134
+ token: o.token,
135
+ user_id: o.user_id,
136
+ device_id: o.device_id,
137
+ tool,
138
+ source: "own",
139
+ };
140
+ }
141
+ } catch {
142
+ /* missing / unreadable → no per-tool credential yet */
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /** Atomically + durably write tools/<tool>.json at 0600 (O_EXCL temp + fsync +
148
+ * atomic rename) — a torn credential file would brick the integration. */
149
+ export function saveToolAuth(tool: string, cred: Record<string, unknown>): void {
150
+ const dir = toolsDir();
151
+ mkdirSync(dir, { recursive: true });
152
+ const p = toolAuthPath(tool);
153
+ const tmp = join(dir, `.${tool}.${process.pid}.${randomUUID()}.tmp`);
154
+ const fd = openSync(tmp, "wx", 0o600); // O_CREAT|O_EXCL|O_WRONLY, owner-only
155
+ try {
156
+ writeFileSync(fd, JSON.stringify(cred, null, 2));
157
+ fsyncSync(fd);
158
+ } finally {
159
+ closeSync(fd);
160
+ }
161
+ renameSync(tmp, p);
162
+ }
163
+
164
+ /** Remove a per-tool credential (a rejected token, or a foreign leftover). The
165
+ * shared auth.json and the lock file are untouched. */
166
+ export function clearToolAuth(tool: string): void {
167
+ try {
168
+ unlinkSync(toolAuthPath(tool));
169
+ } catch {
170
+ /* already gone */
171
+ }
172
+ }
173
+
174
+ /** Retire the credential a rejected call was made with — the ONE thing that takes a
175
+ * client offline, so it is source-aware and never over-reaches:
176
+ * - source "own" → drop ONLY tools/<tool>.json; the shared auth.json (and any
177
+ * other tool riding it) is untouched. resolveCredential re-mints next run, or the
178
+ * server refuses (tombstoned tool) and it goes local-only.
179
+ * - source "shared"/undefined → retire the shared bearer, as before.
180
+ * Called only for a VERIFIED verdict (see call()). */
181
+ function retireForAuth(auth: Auth): void {
182
+ if (auth.source === "own" && auth.tool) clearToolAuth(auth.tool);
183
+ else markRevoked();
184
+ }
185
+
186
+ /** One bearer-authed JSON call, hard-bounded by a timeout. null on ANY non-2xx or
187
+ * transport/parse error. A 401/403 retires the token ONLY when the response is a
188
+ * VERIFIED verdict from our own brain — `x-atlaso-response: 1`, stamped by a global
189
+ * middleware on every app response including errors. An edge/WAF 403 lacks it and
190
+ * must NOT take us offline (never-brick; the WAF sync-brick incident). */
94
191
  async function call(
95
192
  auth: Auth,
96
193
  method: string,
@@ -98,6 +195,32 @@ async function call(
98
195
  body: unknown,
99
196
  timeoutMs: number,
100
197
  ): Promise<any | null> {
198
+ return (await callDetailed(auth, method, path, body, timeoutMs)).data;
199
+ }
200
+
201
+ /** Outcome of one call, with enough detail for the outbox to classify a failure.
202
+ * `call()` above collapses this to data-or-null for the many call sites that only
203
+ * need "did it work"; the deposit path needs the WHY, because "retry forever",
204
+ * "stop retrying", and "this will never succeed" are three different answers and
205
+ * guessing wrong either loses a memory or wedges the queue. */
206
+ export interface CallOutcome {
207
+ data: any | null;
208
+ /** HTTP status, or 0 when the request never produced a response (timeout,
209
+ * DNS failure, connection reset, unparseable body). */
210
+ status: number;
211
+ /** True when the response positively identifies as OUR brain rather than an
212
+ * edge/WAF page — the same marker that gates credential retirement. */
213
+ ours: boolean;
214
+ error?: string;
215
+ }
216
+
217
+ export async function callDetailed(
218
+ auth: Auth,
219
+ method: string,
220
+ path: string,
221
+ body: unknown,
222
+ timeoutMs: number,
223
+ ): Promise<CallOutcome> {
101
224
  const ctrl = new AbortController();
102
225
  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
103
226
  try {
@@ -110,14 +233,24 @@ async function call(
110
233
  body: body ? JSON.stringify(body) : undefined,
111
234
  signal: ctrl.signal,
112
235
  });
236
+ const ours = res.headers?.get("x-atlaso-response") === "1";
113
237
  if (res.status === 401 || res.status === 403) {
114
- markRevoked(); // authoritative: token rejected re-authorize next session
115
- return null;
238
+ // Only OUR server's verdict may retire a credential — an edge/WAF block is not one.
239
+ if (ours) retireForAuth(auth);
240
+ return { data: null, status: res.status, ours };
116
241
  }
117
- if (!res.ok) return null;
118
- return await res.json();
119
- } catch {
120
- return null; // transport/timeout/parse — transient, leave auth.json intact
242
+ if (!res.ok) return { data: null, status: res.status, ours };
243
+ try {
244
+ return { data: await res.json(), status: res.status, ours };
245
+ } catch {
246
+ // 2xx with an unreadable body: the write may well have landed. Report it as
247
+ // a transport-class failure so the caller RETRIES — the deposit is
248
+ // idempotent on client_id, so a retry cannot duplicate.
249
+ return { data: null, status: 0, ours, error: "unparseable body" };
250
+ }
251
+ } catch (e) {
252
+ // transport/timeout/abort — transient, leave auth.json intact
253
+ return { data: null, status: 0, ours: false, error: String(e).slice(0, 200) };
121
254
  } finally {
122
255
  clearTimeout(timer);
123
256
  }
@@ -134,7 +267,11 @@ export async function recall(
134
267
  project?: string,
135
268
  session?: string,
136
269
  ): Promise<RecallResult[]> {
137
- const params = new URLSearchParams({ q: query, limit: String(limit) });
270
+ // Explicit MCP queries are user/agent-controlled too. Scrub BEFORE building the
271
+ // URL so a pasted token cannot escape through the READ path while searching
272
+ // memory — the write path is not the only way a secret leaves the machine.
273
+ const safeQuery = scrub(query || "")[0];
274
+ const params = new URLSearchParams({ q: safeQuery, limit: String(limit) });
138
275
  if (project) params.set("project", project);
139
276
  if (session) params.set("session", session);
140
277
  const data = await call(auth, "GET", `/v1/recall?${params.toString()}`, null, RECALL_TIMEOUT_MS);
@@ -150,12 +287,58 @@ export async function recent(auth: Auth, limit = 8): Promise<RecallResult[]> {
150
287
  return Array.isArray(deposits) ? deposits : [];
151
288
  }
152
289
 
290
+ export interface DepositOutcome {
291
+ ok: boolean;
292
+ results: Array<{ client_id?: string; status?: string }>;
293
+ /** Transport/HTTP detail so the outbox can decide retry vs quarantine. */
294
+ status: number;
295
+ ours: boolean;
296
+ error?: string;
297
+ }
298
+
299
+ /** Batch deposit exposing the full outcome. The outbox drain uses this; everything
300
+ * that only cares "did it work" uses `depositWithResults` below. */
301
+ export async function depositDetailed(
302
+ auth: Auth,
303
+ items: DepositItem[],
304
+ captureStats?: unknown[],
305
+ ): Promise<DepositOutcome> {
306
+ if (!items.length && !(captureStats && captureStats.length)) {
307
+ return { ok: false, results: [], status: 0, ours: false, error: "empty" };
308
+ }
309
+ const body: Record<string, unknown> = { items };
310
+ if (captureStats && captureStats.length) body.capture_stats = captureStats;
311
+ const out = await callDetailed(auth, "POST", "/v1/memories/batch", body, DEPOSIT_TIMEOUT_MS);
312
+ return {
313
+ ok: !!out.data,
314
+ results: Array.isArray(out.data?.results) ? out.data.results : [],
315
+ status: out.status,
316
+ ours: out.ours,
317
+ error: out.error,
318
+ };
319
+ }
320
+
321
+ /** Batch deposit returning the server's per-item verdicts (added/duplicate) —
322
+ * the capture counters need them. `captureStats` is the ADDITIVE content-free
323
+ * counter payload (old servers ignore it; items may be [] for a stats-only
324
+ * flush, though the connectors currently only piggyback). */
325
+ /** Batch deposit returning the server's per-item verdicts (added/duplicate) —
326
+ * the capture counters need them. `captureStats` is the ADDITIVE content-free
327
+ * counter payload (old servers ignore it; items may be [] for a stats-only
328
+ * flush, though the connectors currently only piggyback). */
329
+ export async function depositWithResults(
330
+ auth: Auth,
331
+ items: DepositItem[],
332
+ captureStats?: unknown[],
333
+ ): Promise<{ ok: boolean; results: Array<{ client_id?: string; status?: string }> }> {
334
+ const r = await depositDetailed(auth, items, captureStats);
335
+ return { ok: r.ok, results: r.results };
336
+ }
337
+
153
338
  /** Batch deposit (the server re-scrubs + runs the worth-keeping gate). The
154
339
  * client_id is the server idempotency key, so a retry never duplicates. */
155
340
  export async function deposit(auth: Auth, items: DepositItem[]): Promise<boolean> {
156
- if (!items.length) return false;
157
- const data = await call(auth, "POST", "/v1/memories/batch", { items }, DEPOSIT_TIMEOUT_MS);
158
- return !!data;
341
+ return (await depositWithResults(auth, items)).ok;
159
342
  }
160
343
 
161
344
  /** POST /v1/entitlement — this device's tool policy {active_tool, multi_tool,
@@ -170,3 +353,75 @@ export async function entitlementCall(auth: Auth): Promise<any | null> {
170
353
  export async function claimToolCall(auth: Auth, tool: string): Promise<any | null> {
171
354
  return call(auth, "POST", "/v1/devices/claim-tool", { tool }, RECALL_TIMEOUT_MS);
172
355
  }
356
+
357
+ // ── the MCP surface: explicit remember / forget / status ─────────────────────────
358
+ // These back the plugin's MCP tools (recall/recent already exist above). An explicit
359
+ // user "remember" is tagged `manual` — the server enricher treats manual memories as
360
+ // untouchable — plus the tool id for attribution. Mirrors the Cursor connector.
361
+
362
+ /** Deposit ONE memory the user explicitly asked to keep. Returns the server id
363
+ * (so a later `forget` can target it), or null on failure. */
364
+ export interface RememberOptions {
365
+ text: string;
366
+ /** Extra tags (e.g. scope:project + project:<key>) so an explicit save is
367
+ * scoped like an automatic capture instead of defaulting to personal —
368
+ * otherwise repo-specific facts saved via MCP follow the user everywhere. */
369
+ tags?: string[];
370
+ }
371
+
372
+ export async function remember(auth: Auth, opts: RememberOptions): Promise<string | null> {
373
+ // SCRUB BEFORE SENDING. Auto-capture scrubs secrets on-device; this explicit path
374
+ // did not, so "remember my key is sk-..." shipped the key to the brain in clear —
375
+ // a hole in the on-device scrubbing guarantee, reachable from the MCP `remember`
376
+ // tool with arbitrary agent-supplied text. (Bugbot, cursor/plugins#157, HIGH.)
377
+ const t = scrub(opts.text || "")[0].trim();
378
+ if (!t) return null;
379
+ // CONTENT-DERIVED idempotency key, not a random UUID. A random key meant a
380
+ // timeout AFTER the server committed looked like failure, and the retry minted a
381
+ // NEW key — so the same fact could be stored twice, or reported unsaved when it
382
+ // had landed. Auto-capture already derives its key from content; the explicit
383
+ // path is the higher-intent one and deserves it more, not less.
384
+ // (Bugbot #157, "Remember lacks durable idempotency".)
385
+ const client_id = createHash("sha256")
386
+ .update(`remember\u0000${t}\u0000${(opts.tags || []).slice().sort().join(",")}`)
387
+ .digest("hex")
388
+ .slice(0, 32);
389
+ const tags = [...new Set(["opencode", "manual", ...(opts.tags || [])])];
390
+ const item: DepositItem = {
391
+ client_id, text: t, polarity: "open", evidence_grade: "anecdotal",
392
+ scope_note: null, tags,
393
+ };
394
+ const data = await call(auth, "POST", "/v1/memories/batch", { items: [item] }, DEPOSIT_TIMEOUT_MS);
395
+ if (!data) return null;
396
+ const results = Array.isArray(data.results) ? data.results : [];
397
+ const row = results.find((r: any) => r?.client_id === client_id) ?? results[0];
398
+ return row?.id ?? client_id; // durable server id when settled, else the idempotency key
399
+ }
400
+
401
+ /** DELETE /v1/memories/<id>. Success = REACHED and 2xx (the body may be empty, so we
402
+ * don't route it through call()'s JSON parse). A verified 401/403 retires the token. */
403
+ export async function forget(auth: Auth, id: string): Promise<boolean> {
404
+ const ctrl = new AbortController();
405
+ const timer = setTimeout(() => ctrl.abort(), RECALL_TIMEOUT_MS);
406
+ try {
407
+ const res = await fetch(auth.server.replace(/\/+$/, "") + `/v1/memories/${encodeURIComponent(id)}`, {
408
+ method: "DELETE",
409
+ headers: { Authorization: `Bearer ${auth.token}` },
410
+ signal: ctrl.signal,
411
+ });
412
+ if (res.status === 401 || res.status === 403) {
413
+ if (res.headers?.get("x-atlaso-response") === "1") retireForAuth(auth);
414
+ return false;
415
+ }
416
+ return res.ok;
417
+ } catch {
418
+ return false; // offline / transient — the memory stays; caller says "try again"
419
+ } finally {
420
+ clearTimeout(timer);
421
+ }
422
+ }
423
+
424
+ /** GET /v1/health — {fmi, deposit_count} for the status tool. null on any error. */
425
+ export async function health(auth: Auth): Promise<any | null> {
426
+ return call(auth, "GET", "/v1/health", null, RECALL_TIMEOUT_MS);
427
+ }