@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/lib/project.ts CHANGED
@@ -6,17 +6,82 @@
6
6
  * 1. the git remote origin URL (stable across clones), else
7
7
  * 2. "<basename>-<short hash of abspath>".
8
8
  * READ-ONLY: never creates a .atlaso folder, never throws (→ null = personal-only).
9
- * Ported 1:1 from the Python thin client's `_project.py`.
9
+ * Ported 1:1 from the Python thin client's `_project.py` — the TRI-STATE design
10
+ * (ok / none / unknown) is load-bearing and must stay in lockstep with it.
10
11
  */
11
12
  import { createHash } from "node:crypto";
12
- import { existsSync, readFileSync, statSync } from "node:fs";
13
- import { basename, dirname, join, resolve } from "node:path";
13
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { basename, dirname, join, parse, resolve } from "node:path";
14
16
 
15
17
  const MARKERS = [
16
18
  ".git", "pyproject.toml", "package.json", "Cargo.toml", "go.mod",
17
19
  ".hg", ".svn", "Gemfile", "pom.xml", "build.gradle", "requirements.txt",
18
20
  ];
19
21
 
22
+ // Tool-install territory: a "project" resolved inside any of these is the
23
+ // connector's own runtime/extension dir or a package cache, never the user's
24
+ // work. An unguarded cwd walk lands exactly here — one NEW fake project per
25
+ // version-pinned release dir (field deposit 52c7e97d). Membership is by path
26
+ // ANCESTRY over the canonicalized (realpath'd) root — exact-path lists rot on
27
+ // the next versioned release (lab ruling).
28
+ const TOOL_DOT_DIRS = new Set([
29
+ ".claude", ".codex", ".gemini", ".vscode", ".opencode", ".atlaso",
30
+ "node_modules", "site-packages", "__pypackages__", ".cache", "Caches",
31
+ ]);
32
+
33
+ function intersects(parts: Set<string>, names: Iterable<string>): boolean {
34
+ for (const n of names) if (parts.has(n)) return true;
35
+ return false;
36
+ }
37
+
38
+ /** Split a path into its named components (drops empty leading/anchor parts). */
39
+ function pathParts(p: string): Set<string> {
40
+ return new Set(p.split(/[/\\]+/).filter(Boolean));
41
+ }
42
+
43
+ /** True when `root` is tool-install/cache territory — the measurement itself is
44
+ * garbage (we learn nothing about where the user was working). Distinct from
45
+ * `noProjectRoot`: garbage → status 'unknown'. */
46
+ function garbageRoot(root: string): boolean {
47
+ try {
48
+ const parts = pathParts(root);
49
+ if (intersects(parts, TOOL_DOT_DIRS)) return true;
50
+ // plugin caches that hide under non-dot dirs (marketplaces/cache/repos
51
+ // layouts, e.g. "…/plugins/marketplaces/atlaso/atlaso/runtime")
52
+ if (parts.has("plugins") && intersects(parts, ["cache", "marketplaces", "repos"])) return true;
53
+ if (parts.has("extensions") && intersects(parts, ["Cursor", "Code", "VSCodium"])) return true;
54
+ // ~/.cursor hosts BOTH junk (extensions, plugin caches) and real user work
55
+ // (background-agent worktrees under .cursor/worktrees) — block only its
56
+ // non-worktree subtrees.
57
+ if (parts.has(".cursor") && !parts.has("worktrees")) return true;
58
+ } catch {
59
+ return true;
60
+ }
61
+ return false;
62
+ }
63
+
64
+ /** True when `root` is a real place that simply ISN'T a project ($HOME itself,
65
+ * the filesystem root). A trustworthy 'no project here' answer — status 'none',
66
+ * genuine personal scope. */
67
+ function noProjectRoot(root: string): boolean {
68
+ try {
69
+ // Prefer $HOME (Python's Path.home() does the same on POSIX) so this tracks
70
+ // the caller's real home even under a modified environment; fall back to the
71
+ // OS lookup. Compared realpath'd, since `root` is already canonicalized.
72
+ const raw = process.env.HOME || homedir();
73
+ let home = raw;
74
+ try {
75
+ home = realpathSync(raw);
76
+ } catch {
77
+ /* keep raw home */
78
+ }
79
+ return root === home || root === raw || root === parse(root).root;
80
+ } catch {
81
+ return true;
82
+ }
83
+ }
84
+
20
85
  export function projectRoot(start?: string): string {
21
86
  let cur: string;
22
87
  try {
@@ -96,65 +161,143 @@ function gitOrigin(root: string): string | null {
96
161
  * github.com/me/app. */
97
162
  function normalizeRemote(url: string): string {
98
163
  let u = url.trim();
99
- u = u.replace(/^[a-zA-Z]+:\/\//, ""); // strip scheme
100
- u = u.replace(/^[^@/]+@/, ""); // strip user@
101
- u = u.replace(":", "/"); // scp-style host:path → host/path (first colon only)
164
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(u)) {
165
+ try {
166
+ const parsed = new URL(u);
167
+ // A transport PORT is not repository identity. Previously the scheme was
168
+ // stripped and then the first colon became a path separator, so
169
+ // ssh://git@host:2222/group/repo turned into host/2222/group/repo — a
170
+ // different project key from the same repo cloned over https, silently
171
+ // splitting one project's memories in two. (Bugbot #157, "SSH ports break
172
+ // project keys".) Parsing properly drops the port and the userinfo.
173
+ u = `${parsed.hostname}${parsed.pathname}`;
174
+ } catch {
175
+ u = u.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "");
176
+ }
177
+ } else {
178
+ u = u.replace(/^[^@/]+@/, ""); // strip user@
179
+ u = u.replace(":", "/"); // scp-style host:path → host/path (first colon only)
180
+ }
102
181
  u = u.replace(/\.git$/, "");
103
182
  return u.replace(/^\/+|\/+$/g, "").toLowerCase();
104
183
  }
105
184
 
106
- /** A stable identity for the current project. null on any failure → personal-only. */
107
- export function projectKey(start?: string): string | null {
185
+ /** name-hash key for a non-git project root. Hash basis is the NFC-normalized,
186
+ * case-folded path APFS is case-insensitive-preserving and hands back NFD
187
+ * filenames, so two layers producing the same directory's string must never
188
+ * hash it differently (lab ruling). Mirrors Python `_fallback_key` 1:1. */
189
+ function fallbackKey(root: string): string {
190
+ const basis = root.normalize("NFC").toLowerCase();
191
+ const h = createHash("sha256").update(basis, "utf-8").digest("hex").slice(0, 8);
192
+ const name = basename(root).normalize("NFC").replace(/[^A-Za-z0-9_.-]/g, "-") || "project";
193
+ return `${name}-${h}`;
194
+ }
195
+
196
+ export type ProjectStatus = "ok" | "none" | "unknown";
197
+ export interface ProjectResolution {
198
+ status: ProjectStatus;
199
+ key: string | null;
200
+ }
201
+
202
+ /** (status, key) — the tri-state project measurement. Mirrors Python
203
+ * `project_resolution`.
204
+ * 'ok' → key is a real project identity (normalized git remote, else
205
+ * name-hash of the canonical root).
206
+ * 'none' → trustworthy "this work belongs to NO project" ($HOME, the
207
+ * filesystem root) → genuine personal scope.
208
+ * 'unknown' → the measurement itself failed or was garbage (root resolved
209
+ * into tool-install/cache territory, unreadable dir, exception)
210
+ * → record as an unattributed project memory, visible with a
211
+ * provenance marker, never silently buried.
212
+ * The none/unknown split is load-bearing: collapsing them is exactly how 298
213
+ * memories became indistinguishable from "no project" and disappeared. */
214
+ export function projectResolution(start?: string): ProjectResolution {
108
215
  try {
109
- const root = projectRoot(start);
216
+ let root = projectRoot(start);
217
+ try {
218
+ root = realpathSync(root); // canonicalize BEFORE the garbage/none checks
219
+ } catch {
220
+ /* keep the path.resolve()'d root */
221
+ }
222
+ if (garbageRoot(root)) return { status: "unknown", key: null };
223
+ if (noProjectRoot(root)) return { status: "none", key: null };
110
224
  const origin = gitOrigin(root);
111
225
  if (origin) {
112
226
  const key = normalizeRemote(origin);
113
- if (key) return key.slice(0, 120);
227
+ if (key) return { status: "ok", key: key.slice(0, 120) };
114
228
  }
115
- const h = createHash("sha256").update(root).digest("hex").slice(0, 8);
116
- const name = (basename(root).replace(/[^A-Za-z0-9_.-]/g, "-") || "project");
117
- return `${name}-${h}`;
229
+ return { status: "ok", key: fallbackKey(root) };
118
230
  } catch {
119
- return null;
231
+ return { status: "unknown", key: null };
120
232
  }
121
233
  }
122
234
 
235
+ /** A stable identity for the current project. null → personal-only (both the
236
+ * 'none' and 'unknown' cases — recall treats them the same). Mirrors Python
237
+ * `project_key`. */
238
+ export function projectKey(start?: string): string | null {
239
+ const { status, key } = projectResolution(start);
240
+ return status === "ok" ? key : null;
241
+ }
242
+
123
243
  /** (scope, project_key) from a deposit's tags — mirrors the server + Python
124
- * `_project.scope_of`. */
244
+ * `_project.scope_of`. Recognizes `scope:orphaned`, the server-side rescue
245
+ * scope for memories reattributed away from a bad key. */
125
246
  export function scopeOf(tags: string[] | undefined): [string, string | null] {
126
- let scope = "personal";
247
+ // ORDER-INDEPENDENT with precedence orphaned > project > personal
248
+ // (CodeRedTeam block: last-tag-wins let a crafted tag array leak a project
249
+ // memory everywhere or revive a rescued orphan). Mirrors _project.scope_of
250
+ // and the server's _scope_of exactly.
251
+ const tl = (tags || []).filter((t) => typeof t === "string");
127
252
  let pkey: string | null = null;
128
- for (const t of tags || []) {
129
- if (t === "scope:project") scope = "project";
130
- else if (t === "scope:personal") scope = "personal";
131
- else if (typeof t === "string" && t.startsWith("project:")) pkey = t.slice("project:".length);
253
+ for (const t of tl) {
254
+ if (t.startsWith("project:")) pkey = t.slice("project:".length);
132
255
  }
256
+ const scope = tl.includes("scope:orphaned")
257
+ ? "orphaned"
258
+ : tl.includes("scope:project")
259
+ ? "project"
260
+ : "personal";
133
261
  return [scope, pkey];
134
262
  }
135
263
 
136
264
  /** Per-project visibility — MUST match the server. Personal/untagged → visible
137
- * everywhere. Project-scoped → only its own project. Project-scoped with NO key
138
- * (orphan) → FAIL CLOSED (hidden), so a capture we couldn't attribute never
139
- * leaks across repos. Ported from `_project.visible_in_project`. */
265
+ * everywhere. Project-scoped WITH a key → only its own project. Project-scoped
266
+ * with NO key (orphan) → VISIBLE everywhere (fail OPEN): hiding is invisible to
267
+ * the user so it can never be corrected, while over-visibility of the user's OWN
268
+ * memory is observable and fixable (lab ruling — asymmetric loss; the old
269
+ * fail-closed rule silently buried every capture the key derivation couldn't
270
+ * attribute). `scope:orphaned` (server-side rescue) is HIDDEN from normal recall.
271
+ * Ported from `_project.visible_in_project`. */
140
272
  export function visibleInProject(
141
273
  tags: string[] | undefined,
142
274
  project: string | null | undefined,
143
275
  ): boolean {
144
276
  const [scope, pkey] = scopeOf(tags);
277
+ if (scope === "orphaned") return false; // rescue scope — not surfaced in normal recall
145
278
  if (scope !== "project") return true;
146
- if (pkey === null) return false;
279
+ if (pkey === null) return true; // orphan → visible-with-provenance, never silently buried
147
280
  return pkey === (project ?? null);
148
281
  }
149
282
 
150
- /** Best-effort workspace root from a hook payload. Cursor's exact field for this
151
- * isn't nailed down (docs are thin; `workspace_roots` vs nested `project
152
- * .workspaceRoot` both appear in the wild), so we try every plausible shape and
153
- * fall back to the cwd Cursor launched the hook from. Shared by the recall +
154
- * capture hooks so both scope to the SAME project. (The live field is one of the
155
- * things to confirm in the deployed end-to-end test.) */
283
+ /** Best-effort workspace root from a hook payload, with EVERY element of the
284
+ * fallback chain guarded (lab RedTeam finding: "the fallback chain will silently
285
+ * accept the plugin process's own PWD"). Cursor's exact field isn't nailed down
286
+ * (docs are thin; `workspace_roots` vs nested `project.workspaceRoot` both appear
287
+ * in the wild), so we try every plausible shape AND the process PWD/cwd but any
288
+ * candidate that canonicalizes into tool-install/cache territory is SKIPPED (the
289
+ * hook's own vendored-runtime cwd must never win). Returns the first real
290
+ * candidate, or null when every candidate is garbage → the caller records the
291
+ * capture as status 'unknown'. Shared by the recall + capture hooks so both scope
292
+ * to the SAME project. */
156
293
  export function workspaceRoot(payload: Record<string, any>): string | null {
157
294
  const p = payload || {};
295
+ let cwd: string | undefined;
296
+ try {
297
+ cwd = process.cwd();
298
+ } catch {
299
+ cwd = undefined;
300
+ }
158
301
  const candidates: unknown[] = [
159
302
  Array.isArray(p.workspace_roots) ? p.workspace_roots[0] : undefined,
160
303
  Array.isArray(p.workspaceRoots) ? p.workspaceRoots[0] : undefined,
@@ -162,7 +305,100 @@ export function workspaceRoot(payload: Record<string, any>): string | null {
162
305
  p.workspaceRoot,
163
306
  p.workspace_root,
164
307
  p.cwd,
308
+ process.env.PWD,
309
+ cwd,
165
310
  ];
166
- for (const c of candidates) if (typeof c === "string" && c.trim()) return c;
167
- return process.env.PWD || process.cwd() || null;
311
+ for (const c of candidates) {
312
+ if (typeof c !== "string" || !c.trim()) continue;
313
+ if (candidateIsGarbage(c)) continue; // skip the plugin's own runtime/cache dir
314
+ return c;
315
+ }
316
+ return null; // every candidate was garbage → status 'unknown'
317
+ }
318
+
319
+ /** True when a raw workspace-root candidate canonicalizes into tool-install
320
+ * territory. Guards each element of the workspaceRoot() chain individually. */
321
+ function candidateIsGarbage(candidate: string): boolean {
322
+ try {
323
+ let resolved = resolve(candidate);
324
+ try {
325
+ resolved = realpathSync(resolved);
326
+ } catch {
327
+ /* keep the resolve()'d path */
328
+ }
329
+ return garbageRoot(resolved);
330
+ } catch {
331
+ return true;
332
+ }
333
+ }
334
+
335
+ /** First entry of a workspace-folders list (editors pass these path-separated). */
336
+ function firstWorkspaceFolder(v: string | undefined): string | null {
337
+ if (!v) return null;
338
+ const first = v.split(/[:;,]/).map((s) => s.trim()).filter(Boolean)[0];
339
+ return first || null;
340
+ }
341
+
342
+ /** Project key for the CURRENT process, resolved from the environment.
343
+ *
344
+ * The MCP server is a standalone process — it gets no hook payload and its cwd is
345
+ * wherever the editor happened to launch it, so `projectKey()` alone would key
346
+ * memories to the wrong directory (or to none). Resolve the workspace from the env
347
+ * the editor exports, then hand it to the tri-state resolver so an unattributable
348
+ * root yields null rather than a junk key. */
349
+ /** Tri-state resolution for the CURRENT process. `currentProjectKey()` collapses
350
+ * 'none' and 'unknown' to the same null, which is NOT the same thing: 'none' is a
351
+ * trustworthy "this is genuinely not a project" ($HOME, the filesystem root) and
352
+ * belongs in personal scope, while 'unknown' is "the measurement is garbage" and
353
+ * must stay project-scoped but unattributed. Callers that act on the difference
354
+ * must use this. (Bugbot #157, "Remember skips none-vs-unknown split".) */
355
+ export function currentProjectResolution(): ProjectResolution {
356
+ // 'none' is a TRUSTWORTHY "this is genuinely not a project", and acting on it
357
+ // downgrades a memory to personal — visible in every repo forever. It may only
358
+ // come from a root the editor actually supplied. An MCP server launched without
359
+ // workspace env vars falls back to cwd, which is frequently $HOME, and $HOME
360
+ // resolves to 'none': trusting that would file project-specific facts as
361
+ // personal and follow the user across every repository. Auto-capture already
362
+ // maps a missing workspace to 'unknown'; this now matches it.
363
+ // (Bugbot #157, "Remember mis-tags personal scope", HIGH.)
364
+ const supplied = editorWorkspaceRoot();
365
+ if (!supplied) return { status: "unknown", key: null };
366
+ return projectResolution(supplied);
367
+ }
368
+
369
+ /** The workspace the EDITOR told us about, or null. Deliberately excludes any
370
+ * cwd fallback: "the editor said this is the workspace" and "we guessed from the
371
+ * process's working directory" are different claims, and only the first can be
372
+ * trusted to mean anything. */
373
+ function editorWorkspaceRoot(): string | null {
374
+ return (
375
+ process.env.OPENCODE_PROJECT_DIR ||
376
+ firstWorkspaceFolder(process.env.WORKSPACE_FOLDER_PATHS) ||
377
+ null
378
+ );
379
+ }
380
+
381
+ function currentRoot(): string {
382
+ return editorWorkspaceRoot() || process.env.PWD || process.cwd();
383
+ }
384
+
385
+ export function currentProjectKey(): string | null {
386
+ return projectKey(currentRoot());
387
+ }
388
+
389
+ /** Visibility for a RECALL RESULT, as opposed to a raw tag list.
390
+ *
391
+ * Some server versions normalize scope into a top-level `scope` field instead of
392
+ * leaving `scope:project` in tags. A caller that only inspects tags therefore
393
+ * reads such a row as PERSONAL and shows it everywhere — a cross-project leak.
394
+ * The MCP path had this right and the sessionStart hook did not, which is exactly
395
+ * the kind of drift two copies of one predicate produce, so it lives here now and
396
+ * both call it. (Bugbot #157, "Recall filter misses scope field".) */
397
+ export function resultVisibleHere(
398
+ r: { scope?: string; tags?: string[] },
399
+ project: string | null,
400
+ ): boolean {
401
+ const tags = Array.isArray(r.tags) ? [...r.tags] : [];
402
+ if (r.scope === "project" && !tags.includes("scope:project")) tags.push("scope:project");
403
+ return visibleInProject(tags, project);
168
404
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@atlaso-labs/opencode",
3
- "version": "0.1.0",
4
- "description": "Long-term memory for OpenCode recalls what you've decided and remembers what matters, across sessions, projects, and tools. A pure-TypeScript OpenCode plugin (no engine; HTTP to the Atlaso brain only).",
3
+ "version": "0.2.0",
4
+ "description": "Long-term memory for OpenCode \u2014 recalls what you've decided and remembers what matters, across sessions, projects, and tools. A pure-TypeScript OpenCode plugin (no engine; HTTP to the Atlaso brain only).",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Atlaso Labs Inc. <hello@atlaso.ai>",
@@ -34,7 +34,8 @@
34
34
  "AGENTS.md",
35
35
  "opencode.json",
36
36
  "README.md",
37
- "LICENSE"
37
+ "LICENSE",
38
+ "CHANGELOG.md"
38
39
  ],
39
40
  "scripts": {
40
41
  "test": "bun test",
@@ -47,5 +48,8 @@
47
48
  },
48
49
  "bugs": {
49
50
  "url": "https://github.com/atlaso-labs/opencode/issues"
51
+ },
52
+ "publishConfig": {
53
+ "access": "public"
50
54
  }
51
55
  }
package/src/index.ts CHANGED
@@ -27,12 +27,16 @@ import { createHash } from "node:crypto";
27
27
  import type { Hooks, Plugin } from "@opencode-ai/plugin";
28
28
  import type { Part } from "@opencode-ai/sdk";
29
29
 
30
- import { deposit, loadAuth, recall, type DepositItem } from "../lib/atlaso";
30
+ import { depositDetailed, loadAuth, recall, type DepositItem } from "../lib/atlaso";
31
+ import { drainIfPending } from "../lib/drain";
32
+ import { enqueue, quarantine, settle } from "../lib/outbox";
33
+ import { markStatsSent, pendingCaptureStats, recordDepositResults, recordGate } from "../lib/capture_stats";
31
34
  import { buildContent, classifyScope, heuristicPolarity, scrub, shouldDeposit } from "../lib/capture";
35
+ import { resolveCredential } from "../lib/credential";
32
36
  import { maybeAutoconnect } from "../lib/connect";
33
37
  import { online } from "../lib/entitlement";
34
38
  import { log } from "../lib/log";
35
- import { projectKey } from "../lib/project";
39
+ import { projectKey, projectResolution } from "../lib/project";
36
40
  import { renderBlock } from "../lib/render";
37
41
 
38
42
  const TOOL = "opencode";
@@ -104,9 +108,17 @@ export const AtlasoMemory: Plugin = async ({ directory, worktree }): Promise<Hoo
104
108
 
105
109
  const auth = loadAuth();
106
110
  if (!auth) return; // online-first: not linked → nothing to recall
107
- if (!(await online(auth, TOOL, auth.device_id ?? null))) return; // local-only this turn
111
+ if (!(await online(auth, { tool: TOOL, deviceId: auth.device_id ?? null }))) return; // local-only this turn
112
+ const cred = await resolveCredential(TOOL);
113
+ if (!cred) return; // no cloud this run
108
114
 
109
- const results = await recall(auth, userText, RECALL_LIMIT, project, input.sessionID);
115
+ // THE RECOVERY PATH. If a previous session ended while the brain was down
116
+ // (or mid-deploy, or the laptop was offline), those memories are still in
117
+ // the outbox. Catch up here — we already hold a credential and the user is
118
+ // waiting on a model call anyway. One readdir when the queue is empty.
119
+ await drainIfPending(TOOL, cred);
120
+
121
+ const results = await recall(cred, userText, RECALL_LIMIT, project, input.sessionID);
110
122
  const block = renderBlock(results);
111
123
  // OpenCode persists + strictly validates injected parts, so we can only build
112
124
  // a schema-valid one when THIS turn's real message id (msg_*) is present on
@@ -146,23 +158,43 @@ export const AtlasoMemory: Plugin = async ({ directory, worktree }): Promise<Hoo
146
158
  pending.delete(sessionID); // one capture per stashed statement
147
159
 
148
160
  const user = p.user;
149
- if (!shouldDeposit(user)[0]) {
161
+ // Counter records EVERY gate evaluation (content-free; lab 85a5c41b).
162
+ const scrubbed = scrub(user)[0];
163
+ // Tri-state project attribution (mirrors the Python client core.py): resolve
164
+ // a key only when the scope is "project" — 'ok' tags the key, 'none' ($HOME
165
+ // etc.) downgrades to personal, 'unknown' (garbage: plugin/cache dir, error)
166
+ // keeps scope:project with a bare "project-unknown" marker, never a key.
167
+ let scope = classifyScope(user);
168
+ let pk: string | null = null;
169
+ let projectUnknown = false;
170
+ if (scope === "project") {
171
+ const { status, key } = projectResolution(directory || worktree);
172
+ if (status === "none") scope = "personal";
173
+ else if (status === "unknown") projectUnknown = true;
174
+ else pk = key; // 'ok'
175
+ }
176
+ const preContent = buildContent(scrubbed, "");
177
+ const dedupeKey = preContent
178
+ ? clientId(preContent, scope, scope === "project" ? pk : null)
179
+ : null;
180
+ const [gateOk, gateReason] = shouldDeposit(user);
181
+ await recordGate(gateReason, { turnKey: dedupeKey });
182
+ if (!gateOk) {
150
183
  log("capture", "skip (gate)");
151
184
  return;
152
185
  }
153
186
  const auth = loadAuth();
154
187
  if (!auth) return;
155
- if (!(await online(auth, TOOL, auth.device_id ?? null))) {
188
+ if (!(await online(auth, { tool: TOOL, deviceId: auth.device_id ?? null }))) {
156
189
  log("capture", "skip (not cloud-linked — local-only)");
157
190
  return;
158
191
  }
159
192
 
160
- const content = buildContent(scrub(user)[0], ""); // assistant omitted in v1 (see TODO)
193
+ const content = preContent; // assistant omitted in v1 (see TODO)
161
194
  if (!content) return;
162
- const scope = classifyScope(user);
163
- const pk = projectKey(directory || worktree); // for the project tag + idempotency key
164
195
  const tags = ["opencode", "auto", `pol-hint:${heuristicPolarity(user)}`, `scope:${scope}`];
165
- if (scope === "project" && pk) tags.push(`project:${pk}`);
196
+ if (projectUnknown) tags.push("project-unknown"); // unattributed → provenance marker, no key
197
+ if (pk) tags.push(`project:${pk}`);
166
198
 
167
199
  const item: DepositItem = {
168
200
  client_id: clientId(content, scope, scope === "project" ? pk : null),
@@ -172,10 +204,33 @@ export const AtlasoMemory: Plugin = async ({ directory, worktree }): Promise<Hoo
172
204
  scope_note: null,
173
205
  tags,
174
206
  };
175
- const saved = await deposit(auth, [item]);
176
- log("capture", `saved=${saved} scope=${scope} session=${sessionID}`);
207
+ const cred = await resolveCredential(TOOL);
208
+ if (!cred) {
209
+ log("capture", "skip (local-only — no tool credential)");
210
+ return;
211
+ }
212
+ // WRITE-AHEAD. Persist BEFORE the network call, never after it fails, so a
213
+ // process killed inside fetch (session end, machine sleep, a brain restart
214
+ // mid-deploy) has already durably recorded the memory. Previously any of
215
+ // those lost it silently. Idempotent on client_id.
216
+ enqueue(TOOL, item);
217
+
218
+ const stats = pendingCaptureStats();
219
+ const { ok, results, status } = await depositDetailed(cred, [item], stats ?? undefined);
220
+ if (ok) {
221
+ if (stats) await markStatsSent(stats);
222
+ await recordDepositResults(results);
223
+ const verdict = results.find((r) => r.client_id === item.client_id);
224
+ if (verdict && verdict.status !== "invalid") settle(TOOL, item.client_id);
225
+ else if (verdict)
226
+ quarantine(TOOL, { client_id: item.client_id, item, enqueued_at: Date.now(), attempts: 1 },
227
+ `server rejected: ${verdict.status}`);
228
+ }
229
+ log("capture", `saved=${ok}${ok ? "" : ` queued (${status || "transport"})`} scope=${scope} session=${sessionID}`);
230
+ // Opportunistic catch-up between turns; cheap no-op on an empty queue.
231
+ await drainIfPending(TOOL, cred);
177
232
  } catch (e) {
178
- log("capture", `error ${e}`); // fail open
233
+ log("capture", `error ${e} (queued)`); // fail open — the memory is on disk
179
234
  }
180
235
  },
181
236
  };