@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/CHANGELOG.md +35 -0
- package/lib/atlaso.ts +269 -14
- package/lib/capture_stats.ts +318 -0
- package/lib/connect.ts +43 -11
- package/lib/credential.ts +148 -0
- package/lib/drain.ts +186 -0
- package/lib/entitlement.ts +4 -2
- package/lib/lock.ts +217 -0
- package/lib/mcp.ts +300 -0
- package/lib/outbox.ts +348 -0
- package/lib/project.ts +298 -48
- package/package.json +6 -2
- package/src/index.ts +68 -13
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 {
|
|
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
|
-
|
|
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 {
|
|
22
47
|
try {
|
|
23
|
-
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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;
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
/**
|
|
107
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
|
129
|
-
if (t
|
|
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
|
|
138
|
-
* (orphan) →
|
|
139
|
-
*
|
|
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
|
|
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
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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)
|
|
167
|
-
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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
|
|
176
|
-
|
|
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
|
};
|