@atlaso-labs/opencode 0.1.1 → 0.2.1

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,39 +6,113 @@
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, isAbsolute, 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
 
20
- export function projectRoot(start?: string): string {
21
- let cur: string;
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 {
22
47
  try {
23
- cur = resolve(start || process.cwd());
48
+ const parts = pathParts(root);
49
+ const blocked = new Set([...parts].filter((part) => TOOL_DOT_DIRS.has(part)));
50
+ // Match Python: only proven managed worktrees exempt .claude/.codex.
51
+ // A nested plugin/cache directory must still fail the ancestry guard.
52
+ const sequence = root.split(/[/\\]+/).filter(Boolean);
53
+ for (const name of [".claude", ".codex"]) {
54
+ const positions = sequence.flatMap((part, i) => part === name ? [i] : []);
55
+ if (positions.length && positions.every((i) => {
56
+ if (sequence[i + 1] !== "worktrees") return false;
57
+ let ancestor = root;
58
+ while (ancestor.split(/[/\\]+/).filter(Boolean).length > i + 2) {
59
+ if (existsSync(join(ancestor, ".git"))) return true;
60
+ const parent = dirname(ancestor);
61
+ if (parent === ancestor) break;
62
+ ancestor = parent;
63
+ }
64
+ return false;
65
+ })) blocked.delete(name);
66
+ }
67
+ if (blocked.size) return true;
68
+ // plugin caches that hide under non-dot dirs (marketplaces/cache/repos
69
+ // layouts, e.g. "…/plugins/marketplaces/atlaso/atlaso/runtime")
70
+ if (parts.has("plugins") && intersects(parts, ["cache", "marketplaces", "repos"])) return true;
71
+ if (parts.has("extensions") && intersects(parts, ["Cursor", "Code", "VSCodium"])) return true;
72
+ // ~/.cursor hosts BOTH junk (extensions, plugin caches) and real user work
73
+ // (background-agent worktrees under .cursor/worktrees) — block only its
74
+ // non-worktree subtrees.
75
+ if (parts.has(".cursor") && !parts.has("worktrees")) return true;
24
76
  } catch {
25
- return process.cwd();
77
+ return true;
26
78
  }
79
+ return false;
80
+ }
81
+
82
+ /** True when `root` is a real place that simply ISN'T a project ($HOME itself,
83
+ * the filesystem root). A trustworthy 'no project here' answer — status 'none',
84
+ * genuine personal scope. */
85
+ function noProjectRoot(root: string): boolean {
86
+ try {
87
+ // Prefer $HOME (Python's Path.home() does the same on POSIX) so this tracks
88
+ // the caller's real home even under a modified environment; fall back to the
89
+ // OS lookup. Compared realpath'd, since `root` is already canonicalized.
90
+ const raw = process.env.HOME || homedir();
91
+ let home = raw;
92
+ try {
93
+ home = realpathSync(raw);
94
+ } catch {
95
+ /* keep raw home */
96
+ }
97
+ return root === home || root === raw || root === parse(root).root;
98
+ } catch {
99
+ return true;
100
+ }
101
+ }
102
+
103
+ export function projectRoot(start?: string): string {
104
+ const cur = realpathSync(resolve(start || process.cwd()));
27
105
  let d = cur;
28
- // walk up to the filesystem root looking for a project marker
29
106
  while (true) {
30
- for (const m of MARKERS) {
31
- try {
32
- if (existsSync(join(d, m))) return d;
33
- } catch {
34
- /* ignore */
35
- }
36
- }
107
+ // Preserve existing package identities, and do not inherit a home dotfiles
108
+ // repository when the opened directory is below the home boundary.
109
+ if (noProjectRoot(d)) return cur;
110
+ if (MARKERS.some((m) => existsSync(join(d, m)))) return d;
37
111
  const parent = dirname(d);
38
112
  if (parent === d) break;
39
113
  d = parent;
40
114
  }
41
- return cur; // no markers → the cwd itself is the "project"
115
+ return cur;
42
116
  }
43
117
 
44
118
  /** Read remote.origin.url straight from .git/config (no subprocess). Handles a
@@ -96,65 +170,148 @@ function gitOrigin(root: string): string | null {
96
170
  * github.com/me/app. */
97
171
  function normalizeRemote(url: string): string {
98
172
  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)
173
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(u)) {
174
+ try {
175
+ const parsed = new URL(u);
176
+ // A transport PORT is not repository identity. Previously the scheme was
177
+ // stripped and then the first colon became a path separator, so
178
+ // ssh://git@host:2222/group/repo turned into host/2222/group/repo — a
179
+ // different project key from the same repo cloned over https, silently
180
+ // splitting one project's memories in two. (Bugbot #157, "SSH ports break
181
+ // project keys".) Parsing properly drops the port and the userinfo.
182
+ u = `${parsed.hostname}${parsed.pathname}`;
183
+ } catch {
184
+ u = u.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "");
185
+ }
186
+ } else {
187
+ u = u.replace(/^[^@/]+@/, ""); // strip user@
188
+ u = u.replace(":", "/"); // scp-style host:path → host/path (first colon only)
189
+ }
102
190
  u = u.replace(/\.git$/, "");
103
191
  return u.replace(/^\/+|\/+$/g, "").toLowerCase();
104
192
  }
105
193
 
106
- /** A stable identity for the current project. null on any failure → personal-only. */
107
- export function projectKey(start?: string): string | null {
194
+ /** name-hash key for a non-git project root. Hash basis is the NFC-normalized,
195
+ * case-folded path APFS is case-insensitive-preserving and hands back NFD
196
+ * filenames, so two layers producing the same directory's string must never
197
+ * hash it differently (lab ruling). Mirrors Python `_fallback_key` 1:1. */
198
+ function fallbackKey(root: string): string {
199
+ const basis = root.normalize("NFC").toLowerCase();
200
+ const h = createHash("sha256").update(basis, "utf-8").digest("hex").slice(0, 8);
201
+ const name = basename(root).normalize("NFC").replace(/[^A-Za-z0-9_.-]/g, "-") || "project";
202
+ return `${name}-${h}`;
203
+ }
204
+
205
+ export type ProjectStatus = "ok" | "none" | "unknown";
206
+ export interface ProjectResolution {
207
+ status: ProjectStatus;
208
+ key: string | null;
209
+ }
210
+
211
+ /** (status, key) — the tri-state project measurement. Mirrors Python
212
+ * `project_resolution`.
213
+ * 'ok' → key is a real project identity (normalized git remote, else
214
+ * name-hash of the canonical root).
215
+ * 'none' → trustworthy "this work belongs to NO project" ($HOME, the
216
+ * filesystem root) → genuine personal scope.
217
+ * 'unknown' → the measurement itself failed or was garbage (root resolved
218
+ * into tool-install/cache territory, unreadable dir, exception)
219
+ * → record as an unattributed project memory, visible with a
220
+ * provenance marker, never silently buried.
221
+ * The none/unknown split is load-bearing: collapsing them is exactly how 298
222
+ * memories became indistinguishable from "no project" and disappeared. */
223
+ export function projectResolution(start?: string): ProjectResolution {
108
224
  try {
109
- const root = projectRoot(start);
110
- const origin = gitOrigin(root);
111
- if (origin) {
112
- const key = normalizeRemote(origin);
113
- if (key) return key.slice(0, 120);
225
+ const supplied = start ?? process.cwd();
226
+ if (!isAbsolute(supplied) || !statSync(supplied).isDirectory()) {
227
+ return { status: "unknown", key: null };
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
+ const current = realpathSync(supplied);
230
+ if (garbageRoot(current)) return { status: "unknown", key: null };
231
+ const root = projectRoot(current);
232
+ if (garbageRoot(root)) return { status: "unknown", key: null };
233
+ if (noProjectRoot(root)) return { status: "none", key: null };
234
+ const origin = gitOrigin(root);
235
+ const key = origin ? normalizeRemote(origin) : fallbackKey(root);
236
+ return validProjectKey(key) ? { status: "ok", key } : { status: "unknown", key: null };
118
237
  } catch {
119
- return null;
238
+ return { status: "unknown", key: null };
120
239
  }
121
240
  }
122
241
 
242
+ /** Exact project identities match the shared Python client; never truncate. */
243
+ function validProjectKey(key: string): boolean {
244
+ return key.length > 0 && [...key].length <= 512 && key === key.trim()
245
+ && !/[\s\x00-\x1f\x7f]/u.test(key)
246
+ && key !== "unknown" && key !== "project-unknown";
247
+ }
248
+
249
+ /** A stable identity for the current project. null → personal-only (both the
250
+ * 'none' and 'unknown' cases — recall treats them the same). Mirrors Python
251
+ * `project_key`. */
252
+ export function projectKey(start?: string): string | null {
253
+ const { status, key } = projectResolution(start);
254
+ return status === "ok" ? key : null;
255
+ }
256
+
123
257
  /** (scope, project_key) from a deposit's tags — mirrors the server + Python
124
- * `_project.scope_of`. */
258
+ * `_project.scope_of`. Recognizes `scope:orphaned`, the server-side rescue
259
+ * scope for memories reattributed away from a bad key. */
125
260
  export function scopeOf(tags: string[] | undefined): [string, string | null] {
126
- let scope = "personal";
261
+ // ORDER-INDEPENDENT with precedence orphaned > project > personal
262
+ // (CodeRedTeam block: last-tag-wins let a crafted tag array leak a project
263
+ // memory everywhere or revive a rescued orphan). Mirrors _project.scope_of
264
+ // and the server's _scope_of exactly.
265
+ const tl = (tags || []).filter((t) => typeof t === "string");
127
266
  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);
267
+ for (const t of tl) {
268
+ if (t.startsWith("project:")) pkey = t.slice("project:".length);
132
269
  }
270
+ const scope = tl.includes("scope:orphaned")
271
+ ? "orphaned"
272
+ : tl.includes("scope:project")
273
+ ? "project"
274
+ : "personal";
133
275
  return [scope, pkey];
134
276
  }
135
277
 
136
278
  /** 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`. */
279
+ * everywhere. Project-scoped WITH a key → only its own project. Project-scoped
280
+ * with NO key (orphan) → VISIBLE everywhere (fail OPEN): hiding is invisible to
281
+ * the user so it can never be corrected, while over-visibility of the user's OWN
282
+ * memory is observable and fixable (lab ruling — asymmetric loss; the old
283
+ * fail-closed rule silently buried every capture the key derivation couldn't
284
+ * attribute). `scope:orphaned` (server-side rescue) is HIDDEN from normal recall.
285
+ * Ported from `_project.visible_in_project`. */
140
286
  export function visibleInProject(
141
287
  tags: string[] | undefined,
142
288
  project: string | null | undefined,
143
289
  ): boolean {
144
290
  const [scope, pkey] = scopeOf(tags);
291
+ if (scope === "orphaned") return false; // rescue scope — not surfaced in normal recall
145
292
  if (scope !== "project") return true;
146
- if (pkey === null) return false;
293
+ if (pkey === null) return true; // orphan → visible-with-provenance, never silently buried
147
294
  return pkey === (project ?? null);
148
295
  }
149
296
 
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.) */
297
+ /** Best-effort workspace root from a hook payload, with EVERY element of the
298
+ * fallback chain guarded (lab RedTeam finding: "the fallback chain will silently
299
+ * accept the plugin process's own PWD"). Cursor's exact field isn't nailed down
300
+ * (docs are thin; `workspace_roots` vs nested `project.workspaceRoot` both appear
301
+ * in the wild), so we try every plausible shape AND the process PWD/cwd but any
302
+ * candidate that canonicalizes into tool-install/cache territory is SKIPPED (the
303
+ * hook's own vendored-runtime cwd must never win). Returns the first real
304
+ * candidate, or null when every candidate is garbage → the caller records the
305
+ * capture as status 'unknown'. Shared by the recall + capture hooks so both scope
306
+ * to the SAME project. */
156
307
  export function workspaceRoot(payload: Record<string, any>): string | null {
157
308
  const p = payload || {};
309
+ let cwd: string | undefined;
310
+ try {
311
+ cwd = process.cwd();
312
+ } catch {
313
+ cwd = undefined;
314
+ }
158
315
  const candidates: unknown[] = [
159
316
  Array.isArray(p.workspace_roots) ? p.workspace_roots[0] : undefined,
160
317
  Array.isArray(p.workspaceRoots) ? p.workspaceRoots[0] : undefined,
@@ -162,7 +319,100 @@ export function workspaceRoot(payload: Record<string, any>): string | null {
162
319
  p.workspaceRoot,
163
320
  p.workspace_root,
164
321
  p.cwd,
322
+ process.env.PWD,
323
+ cwd,
165
324
  ];
166
- for (const c of candidates) if (typeof c === "string" && c.trim()) return c;
167
- return process.env.PWD || process.cwd() || null;
325
+ for (const c of candidates) {
326
+ if (typeof c !== "string" || !c.trim()) continue;
327
+ if (candidateIsGarbage(c)) continue; // skip the plugin's own runtime/cache dir
328
+ return c;
329
+ }
330
+ return null; // every candidate was garbage → status 'unknown'
331
+ }
332
+
333
+ /** True when a raw workspace-root candidate canonicalizes into tool-install
334
+ * territory. Guards each element of the workspaceRoot() chain individually. */
335
+ function candidateIsGarbage(candidate: string): boolean {
336
+ try {
337
+ let resolved = resolve(candidate);
338
+ try {
339
+ resolved = realpathSync(resolved);
340
+ } catch {
341
+ /* keep the resolve()'d path */
342
+ }
343
+ return garbageRoot(resolved);
344
+ } catch {
345
+ return true;
346
+ }
347
+ }
348
+
349
+ /** First entry of a workspace-folders list (editors pass these path-separated). */
350
+ function firstWorkspaceFolder(v: string | undefined): string | null {
351
+ if (!v) return null;
352
+ const first = v.split(/[:;,]/).map((s) => s.trim()).filter(Boolean)[0];
353
+ return first || null;
354
+ }
355
+
356
+ /** Project key for the CURRENT process, resolved from the environment.
357
+ *
358
+ * The MCP server is a standalone process — it gets no hook payload and its cwd is
359
+ * wherever the editor happened to launch it, so `projectKey()` alone would key
360
+ * memories to the wrong directory (or to none). Resolve the workspace from the env
361
+ * the editor exports, then hand it to the tri-state resolver so an unattributable
362
+ * root yields null rather than a junk key. */
363
+ /** Tri-state resolution for the CURRENT process. `currentProjectKey()` collapses
364
+ * 'none' and 'unknown' to the same null, which is NOT the same thing: 'none' is a
365
+ * trustworthy "this is genuinely not a project" ($HOME, the filesystem root) and
366
+ * belongs in personal scope, while 'unknown' is "the measurement is garbage" and
367
+ * must stay project-scoped but unattributed. Callers that act on the difference
368
+ * must use this. (Bugbot #157, "Remember skips none-vs-unknown split".) */
369
+ export function currentProjectResolution(): ProjectResolution {
370
+ // 'none' is a TRUSTWORTHY "this is genuinely not a project", and acting on it
371
+ // downgrades a memory to personal — visible in every repo forever. It may only
372
+ // come from a root the editor actually supplied. An MCP server launched without
373
+ // workspace env vars falls back to cwd, which is frequently $HOME, and $HOME
374
+ // resolves to 'none': trusting that would file project-specific facts as
375
+ // personal and follow the user across every repository. Auto-capture already
376
+ // maps a missing workspace to 'unknown'; this now matches it.
377
+ // (Bugbot #157, "Remember mis-tags personal scope", HIGH.)
378
+ const supplied = editorWorkspaceRoot();
379
+ if (!supplied) return { status: "unknown", key: null };
380
+ return projectResolution(supplied);
381
+ }
382
+
383
+ /** The workspace the EDITOR told us about, or null. Deliberately excludes any
384
+ * cwd fallback: "the editor said this is the workspace" and "we guessed from the
385
+ * process's working directory" are different claims, and only the first can be
386
+ * trusted to mean anything. */
387
+ function editorWorkspaceRoot(): string | null {
388
+ return (
389
+ process.env.OPENCODE_PROJECT_DIR ||
390
+ firstWorkspaceFolder(process.env.WORKSPACE_FOLDER_PATHS) ||
391
+ null
392
+ );
393
+ }
394
+
395
+ function currentRoot(): string {
396
+ return editorWorkspaceRoot() || process.env.PWD || process.cwd();
397
+ }
398
+
399
+ export function currentProjectKey(): string | null {
400
+ return projectKey(currentRoot());
401
+ }
402
+
403
+ /** Visibility for a RECALL RESULT, as opposed to a raw tag list.
404
+ *
405
+ * Some server versions normalize scope into a top-level `scope` field instead of
406
+ * leaving `scope:project` in tags. A caller that only inspects tags therefore
407
+ * reads such a row as PERSONAL and shows it everywhere — a cross-project leak.
408
+ * The MCP path had this right and the sessionStart hook did not, which is exactly
409
+ * the kind of drift two copies of one predicate produce, so it lives here now and
410
+ * both call it. (Bugbot #157, "Recall filter misses scope field".) */
411
+ export function resultVisibleHere(
412
+ r: { scope?: string; tags?: string[] },
413
+ project: string | null,
414
+ ): boolean {
415
+ const tags = Array.isArray(r.tags) ? [...r.tags] : [];
416
+ if (r.scope === "project" && !tags.includes("scope:project")) tags.push("scope:project");
417
+ return visibleInProject(tags, project);
168
418
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaso-labs/opencode",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
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).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
  };