@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 +31 -0
- package/lib/atlaso.ts +270 -15
- 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 +268 -32
- package/package.json +7 -3
- package/src/index.ts +68 -13
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 {
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
/**
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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);
|
|
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
|
|
138
|
-
* (orphan) →
|
|
139
|
-
*
|
|
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
|
|
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
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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)
|
|
167
|
-
|
|
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.
|
|
4
|
-
"description": "Long-term memory for OpenCode
|
|
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 {
|
|
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
|
};
|