@atlaso-labs/opencode 0.1.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/AGENTS.md +68 -0
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/lib/atlaso.ts +172 -0
- package/lib/capture.ts +109 -0
- package/lib/connect.ts +311 -0
- package/lib/entitlement.ts +75 -0
- package/lib/log.ts +16 -0
- package/lib/project.ts +168 -0
- package/lib/render.ts +62 -0
- package/lib/state.ts +125 -0
- package/opencode.json +6 -0
- package/package.json +51 -0
- package/skills/memory/SKILL.md +61 -0
- package/src/connect-entry.ts +14 -0
- package/src/index.ts +184 -0
package/lib/connect.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/** Device connect — the client half of the link-only auth handshake, in TS.
|
|
2
|
+
*
|
|
3
|
+
* Gets a token onto this machine and writes the SHARED ~/.atlaso/auth.json (the
|
|
4
|
+
* same file every connector reads), so the otherwise read-only hooks can talk to
|
|
5
|
+
* the user's cloud brain. Engine-free; global fetch only.
|
|
6
|
+
*
|
|
7
|
+
* Flow (PKCE + loopback, RFC 8252; the brain implements the server side —
|
|
8
|
+
* identical to the Python `connect.py`):
|
|
9
|
+
* 1. start a one-shot HTTP listener on 127.0.0.1:<ephemeral>
|
|
10
|
+
* 2. POST /v1/device/start → send a PKCE challenge + that loopback redirect_uri,
|
|
11
|
+
* get back a verification link
|
|
12
|
+
* 3. open the link → the user clicks "Authorize"; the browser is
|
|
13
|
+
* redirected to OUR loopback with a one-time code
|
|
14
|
+
* (machine-local — a phished link lands on the
|
|
15
|
+
* victim's own 127.0.0.1, not ours)
|
|
16
|
+
* 4. POST /v1/device/token → redeem {code, code_verifier} for the token (one-time)
|
|
17
|
+
* 5. write auth.json (atomic + fsync'd)
|
|
18
|
+
*
|
|
19
|
+
* `maybeAutoconnect()` makes 1-2 automatic: a hook calls it and, if this machine
|
|
20
|
+
* isn't connected, spawns a DETACHED `bun run hooks/connect.ts` (which opens the
|
|
21
|
+
* browser). Fast + best-effort — never blocks the hook, never throws.
|
|
22
|
+
*/
|
|
23
|
+
import { spawn } from "node:child_process";
|
|
24
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
25
|
+
import {
|
|
26
|
+
appendFileSync, chmodSync, closeSync, fsyncSync, mkdirSync, openSync,
|
|
27
|
+
renameSync, statSync, unlinkSync, writeFileSync, writeSync,
|
|
28
|
+
} from "node:fs";
|
|
29
|
+
import { createServer, type Server } from "node:http";
|
|
30
|
+
import { hostname } from "node:os";
|
|
31
|
+
import { dirname, join } from "node:path";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
import { atlasoDir, authPath, defaultServer, loadAuth } from "./atlaso";
|
|
34
|
+
import { invalidate as invalidateVerdict } from "./state";
|
|
35
|
+
|
|
36
|
+
const LOCK_NAME = ".connecting";
|
|
37
|
+
const LOCK_TTL_MS = 15 * 60 * 1000;
|
|
38
|
+
const START_TIMEOUT_MS = 15000;
|
|
39
|
+
const POLL_TIMEOUT_MS = 15000;
|
|
40
|
+
|
|
41
|
+
export function hasToken(): boolean {
|
|
42
|
+
return !!loadAuth()?.token;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function lockPath(): string {
|
|
46
|
+
return join(atlasoDir(), LOCK_NAME);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Atomically + durably write {server, token, user_id, device_id} at 0600:
|
|
50
|
+
* unpredictable temp name (O_EXCL, no symlink follow), fsync the file, atomic
|
|
51
|
+
* rename, then fsync the directory. Mirrors the Python `connect.save_auth`. */
|
|
52
|
+
export function writeAuth(
|
|
53
|
+
server: string,
|
|
54
|
+
token: string,
|
|
55
|
+
user_id: string,
|
|
56
|
+
device_id: string | null,
|
|
57
|
+
): string {
|
|
58
|
+
const dir = atlasoDir();
|
|
59
|
+
mkdirSync(dir, { recursive: true });
|
|
60
|
+
try {
|
|
61
|
+
chmodSync(dir, 0o700);
|
|
62
|
+
} catch {
|
|
63
|
+
/* ignore */
|
|
64
|
+
}
|
|
65
|
+
const p = authPath();
|
|
66
|
+
const data = JSON.stringify({ server, token, user_id, device_id }, null, 2);
|
|
67
|
+
const tmp = join(dir, `.auth.${process.pid}.${randomUUID()}.tmp`);
|
|
68
|
+
const fd = openSync(tmp, "wx", 0o600); // O_CREAT|O_EXCL|O_WRONLY, owner-only
|
|
69
|
+
try {
|
|
70
|
+
writeFileSync(fd, data);
|
|
71
|
+
fsyncSync(fd);
|
|
72
|
+
} finally {
|
|
73
|
+
closeSync(fd);
|
|
74
|
+
}
|
|
75
|
+
renameSync(tmp, p);
|
|
76
|
+
try {
|
|
77
|
+
const dfd = openSync(dir, "r"); // fsync the dir so the rename is durable
|
|
78
|
+
try {
|
|
79
|
+
fsyncSync(dfd);
|
|
80
|
+
} finally {
|
|
81
|
+
closeSync(dfd);
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
/* dir fsync best-effort */
|
|
85
|
+
}
|
|
86
|
+
// A fresh token = the link changed: drop any cached entitlement verdict so the
|
|
87
|
+
// next op re-verifies from scratch (no stale free pass to the new credential).
|
|
88
|
+
invalidateVerdict();
|
|
89
|
+
return p;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function openBrowser(url: string): void {
|
|
93
|
+
if (process.env.ATLASO_NO_BROWSER) return;
|
|
94
|
+
try {
|
|
95
|
+
const plt = process.platform;
|
|
96
|
+
const cmd = plt === "darwin" ? "open" : plt === "win32" ? "cmd" : "xdg-open";
|
|
97
|
+
const args = plt === "win32" ? ["/c", "start", "", url] : [url];
|
|
98
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
99
|
+
} catch {
|
|
100
|
+
/* best-effort */
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** fetch with a hard timeout. null on timeout/transport error. */
|
|
105
|
+
async function fetchT(url: string, init: RequestInit, ms: number): Promise<Response | null> {
|
|
106
|
+
const ctrl = new AbortController();
|
|
107
|
+
const t = setTimeout(() => ctrl.abort(), ms);
|
|
108
|
+
try {
|
|
109
|
+
return await fetch(url, { ...init, signal: ctrl.signal });
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
} finally {
|
|
113
|
+
clearTimeout(t);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const b64url = (b: Buffer): string =>
|
|
118
|
+
b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
119
|
+
|
|
120
|
+
/** (code_verifier, code_challenge) — RFC 7636 S256. The verifier never leaves this
|
|
121
|
+
* process; only its sha256 challenge is sent at /device/start. */
|
|
122
|
+
function pkcePair(): { verifier: string; challenge: string } {
|
|
123
|
+
const verifier = b64url(randomBytes(48)); // 64 url-safe chars (within RFC 43..128)
|
|
124
|
+
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
|
125
|
+
return { verifier, challenge };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Serve the one-shot loopback callback. Resolves the one-time code once a request
|
|
129
|
+
* arrives with a matching `state` (CSRF guard), or null on timeout. Other requests
|
|
130
|
+
* (favicon, wrong state) get a 400 and are ignored. */
|
|
131
|
+
function waitForCode(server: Server, expectedState: string, timeoutMs: number): Promise<string | null> {
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
let done = false;
|
|
134
|
+
const finish = (v: string | null) => {
|
|
135
|
+
if (done) return;
|
|
136
|
+
done = true;
|
|
137
|
+
resolve(v);
|
|
138
|
+
};
|
|
139
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
140
|
+
server.on("request", (req, res) => {
|
|
141
|
+
const u = new URL(req.url || "/", "http://127.0.0.1");
|
|
142
|
+
const code = u.searchParams.get("code") || "";
|
|
143
|
+
const st = u.searchParams.get("state") || "";
|
|
144
|
+
const ok = !!code && st === expectedState;
|
|
145
|
+
const msg = ok
|
|
146
|
+
? "<h2>Atlaso connected ✓</h2><p>You can close this tab and return to your terminal.</p>"
|
|
147
|
+
: "<h2>Atlaso: connection failed</h2><p>Return to your terminal and try again.</p>";
|
|
148
|
+
res.writeHead(ok ? 200 : 400, { "Content-Type": "text/html; charset=utf-8" });
|
|
149
|
+
res.end(`<html><body style='font-family:system-ui;max-width:30rem;margin:4rem auto;text-align:center'>${msg}</body></html>`);
|
|
150
|
+
if (ok) {
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
finish(code);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Run the connect handshake to completion. 0 on success. Releases the lock. */
|
|
159
|
+
export async function runConnect(): Promise<number> {
|
|
160
|
+
let server: Server | null = null;
|
|
161
|
+
try {
|
|
162
|
+
const base = (process.env.ATLASO_SERVER || loadAuth()?.server || defaultServer()).replace(/\/+$/, "");
|
|
163
|
+
const label = (hostname() || "this device").slice(0, 80);
|
|
164
|
+
const tool = process.env.ATLASO_TOOL || "opencode";
|
|
165
|
+
const existing = loadAuth() || ({} as Record<string, any>);
|
|
166
|
+
const { verifier, challenge } = pkcePair();
|
|
167
|
+
const state = b64url(randomBytes(16));
|
|
168
|
+
|
|
169
|
+
// Start a one-shot loopback listener on an ephemeral port (127.0.0.1 ONLY) —
|
|
170
|
+
// RFC 8252 §7.3. The approved code is delivered here, machine-local.
|
|
171
|
+
server = createServer();
|
|
172
|
+
const port = await new Promise<number>((resolve) => {
|
|
173
|
+
server!.once("error", () => resolve(0));
|
|
174
|
+
server!.listen(0, "127.0.0.1", () => {
|
|
175
|
+
const a = server!.address();
|
|
176
|
+
resolve(a && typeof a === "object" ? a.port : 0);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
if (!port) return 1;
|
|
180
|
+
const redirectUri = `http://127.0.0.1:${port}/cb`;
|
|
181
|
+
|
|
182
|
+
const startBody: Record<string, any> = {
|
|
183
|
+
label, tool: tool.slice(0, 40), code_challenge: challenge, redirect_uri: redirectUri, state,
|
|
184
|
+
};
|
|
185
|
+
if (existing.device_id) startBody.device_id = existing.device_id; // reconnect rotates in place
|
|
186
|
+
|
|
187
|
+
const r = await fetchT(
|
|
188
|
+
`${base}/v1/device/start`,
|
|
189
|
+
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(startBody) },
|
|
190
|
+
START_TIMEOUT_MS,
|
|
191
|
+
);
|
|
192
|
+
if (!r || !r.ok) return 1;
|
|
193
|
+
let d: any;
|
|
194
|
+
try {
|
|
195
|
+
d = await r.json();
|
|
196
|
+
} catch {
|
|
197
|
+
return 1;
|
|
198
|
+
}
|
|
199
|
+
const verifyUrl = d?.verification_uri_complete || d?.verification_uri || base;
|
|
200
|
+
const expiresIn = parseInt(String(d?.expires_in)) || 600;
|
|
201
|
+
|
|
202
|
+
// Surface the link even if the browser can't open (headless / xdg-open missing).
|
|
203
|
+
try {
|
|
204
|
+
appendFileSync(join(atlasoDir(), "connect.log"),
|
|
205
|
+
`${new Date().toISOString()} authorize this device: ${verifyUrl}\n`, { mode: 0o600 });
|
|
206
|
+
} catch {
|
|
207
|
+
/* best-effort */
|
|
208
|
+
}
|
|
209
|
+
console.log(`Atlaso — authorize this device:\n ${verifyUrl}`);
|
|
210
|
+
openBrowser(verifyUrl);
|
|
211
|
+
|
|
212
|
+
const code = await waitForCode(server, state, expiresIn * 1000);
|
|
213
|
+
if (!code) return 1;
|
|
214
|
+
|
|
215
|
+
const tr = await fetchT(
|
|
216
|
+
`${base}/v1/device/token`,
|
|
217
|
+
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code, code_verifier: verifier }) },
|
|
218
|
+
POLL_TIMEOUT_MS,
|
|
219
|
+
);
|
|
220
|
+
if (!tr || tr.status !== 200) return 1;
|
|
221
|
+
let t: any;
|
|
222
|
+
try {
|
|
223
|
+
t = await tr.json();
|
|
224
|
+
} catch {
|
|
225
|
+
return 1;
|
|
226
|
+
}
|
|
227
|
+
if (t?.status === "approved") {
|
|
228
|
+
if (!t.token || !t.user_id) return 1;
|
|
229
|
+
writeAuth(base, t.token, t.user_id, t.device_id ?? null);
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
return 1;
|
|
233
|
+
} finally {
|
|
234
|
+
try {
|
|
235
|
+
server?.close();
|
|
236
|
+
} catch {
|
|
237
|
+
/* ignore */
|
|
238
|
+
}
|
|
239
|
+
releaseConnectLock();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function releaseConnectLock(): void {
|
|
244
|
+
try {
|
|
245
|
+
unlinkSync(lockPath());
|
|
246
|
+
} catch {
|
|
247
|
+
/* already gone */
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Atomically claim the connect lock (O_EXCL). false if a fresh lock exists; a
|
|
252
|
+
* stale lock (> TTL) is reclaimed. */
|
|
253
|
+
function acquireLock(lock: string): boolean {
|
|
254
|
+
for (let i = 0; i < 2; i++) {
|
|
255
|
+
try {
|
|
256
|
+
const fd = openSync(lock, "wx", 0o600); // wx = O_CREAT|O_EXCL|O_WRONLY
|
|
257
|
+
try {
|
|
258
|
+
writeSync(fd, String(Date.now()));
|
|
259
|
+
} finally {
|
|
260
|
+
closeSync(fd);
|
|
261
|
+
}
|
|
262
|
+
return true;
|
|
263
|
+
} catch {
|
|
264
|
+
try {
|
|
265
|
+
if (Date.now() - statSync(lock).mtimeMs >= LOCK_TTL_MS) {
|
|
266
|
+
unlinkSync(lock); // stale → reclaim and retry
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
/* ignore */
|
|
271
|
+
}
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Auto-trigger (called by the recall hook): if not connected, spawn a DETACHED
|
|
279
|
+
* connect (opens the browser) and return true. No-op + false if already
|
|
280
|
+
* connected, opted out (ATLASO_NO_CONNECT), extracting, in CI, or a connect is
|
|
281
|
+
* already in flight. Fast — never blocks, never touches the network itself. */
|
|
282
|
+
export function maybeAutoconnect(tool = "opencode"): boolean {
|
|
283
|
+
if (hasToken()) return false;
|
|
284
|
+
if (process.env.ATLASO_NO_CONNECT || process.env.ATLASO_EXTRACTING || process.env.CI) return false;
|
|
285
|
+
const dir = atlasoDir();
|
|
286
|
+
try {
|
|
287
|
+
mkdirSync(dir, { recursive: true });
|
|
288
|
+
chmodSync(dir, 0o700);
|
|
289
|
+
} catch {
|
|
290
|
+
/* ignore */
|
|
291
|
+
}
|
|
292
|
+
const lock = lockPath();
|
|
293
|
+
if (!acquireLock(lock)) return false;
|
|
294
|
+
try {
|
|
295
|
+
// lib/ → ../src/connect-entry.ts (the detached browser-authorize process)
|
|
296
|
+
const entry = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "connect-entry.ts");
|
|
297
|
+
spawn("bun", ["run", entry], {
|
|
298
|
+
stdio: "ignore",
|
|
299
|
+
detached: true,
|
|
300
|
+
env: { ...process.env, ATLASO_TOOL: tool },
|
|
301
|
+
}).unref();
|
|
302
|
+
return true;
|
|
303
|
+
} catch {
|
|
304
|
+
try {
|
|
305
|
+
unlinkSync(lock); // release so a later hook can retry
|
|
306
|
+
} catch {
|
|
307
|
+
/* ignore */
|
|
308
|
+
}
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** Cloud-link / tool-entitlement gate — ported from the Python client's
|
|
2
|
+
* `_online()` + `_verify_entitlement()` + `cloud_mode()`.
|
|
3
|
+
*
|
|
4
|
+
* The free plan allows ONE active tool per device, and the brain does NOT
|
|
5
|
+
* enforce that on the memory endpoints — so before any cloud recall/deposit the
|
|
6
|
+
* hooks call `online()`, which checks the cached verdict and (when stale)
|
|
7
|
+
* verifies with `/v1/entitlement`, self-claiming the active slot if it's free.
|
|
8
|
+
* A non-active tool on a free plan runs LOCAL-ONLY (no cloud calls) and the
|
|
9
|
+
* recall hook surfaces an upgrade notice. Verdict is cached + (tool, device)-scoped.
|
|
10
|
+
*/
|
|
11
|
+
import { claimToolCall, entitlementCall, markRevoked, type Auth } from "./atlaso";
|
|
12
|
+
import * as state from "./state";
|
|
13
|
+
|
|
14
|
+
export type { Verdict } from "./state";
|
|
15
|
+
|
|
16
|
+
/** Should we attempt a cloud call right now? True = cloud-linked, False =
|
|
17
|
+
* local-only this turn. Never throws (memory must never break a turn). Hits the
|
|
18
|
+
* network only on a stale/foreign verdict (cached otherwise). */
|
|
19
|
+
export async function online(auth: Auth | null, tool: string, deviceId: string | null): Promise<boolean> {
|
|
20
|
+
if (!auth) return false;
|
|
21
|
+
try {
|
|
22
|
+
const st = state.get();
|
|
23
|
+
const mine = state.matches(st, tool, deviceId);
|
|
24
|
+
if (st.mode === state.LOCAL_ONLY) {
|
|
25
|
+
if (mine && st.reason === state.REVOKED) return false; // sticky until reconnect
|
|
26
|
+
if (mine && state.isFresh(st)) return false; // trust a fresh local-only (e.g. not_entitled)
|
|
27
|
+
return await verify(auth, tool, deviceId);
|
|
28
|
+
}
|
|
29
|
+
if (mine && state.isFresh(st)) return true; // trust a fresh linked verdict
|
|
30
|
+
return await verify(auth, tool, deviceId);
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Ask the brain + persist a scoped verdict. */
|
|
37
|
+
async function verify(auth: Auth, tool: string, deviceId: string | null): Promise<boolean> {
|
|
38
|
+
const ent = await entitlementCall(auth);
|
|
39
|
+
// null = revoked (call() already retired auth.json on 401/403) OR transient — either
|
|
40
|
+
// way, don't grant a cloud window this turn; leave state unchanged to retry next time.
|
|
41
|
+
if (!ent) return false;
|
|
42
|
+
if (ent.needs_reconnect) {
|
|
43
|
+
markRevoked(); // device gone server-side → re-authorize next session
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
const grace: state.Grace | null = ent.in_grace
|
|
47
|
+
? { in_grace: true, days_left: ent.grace_days_left ?? null, tools_connected: ent.tools_connected ?? null }
|
|
48
|
+
: null;
|
|
49
|
+
// paid (or grace) → multi-tool; a generic/no-tool client is never gated.
|
|
50
|
+
if (ent.multi_tool || !tool) {
|
|
51
|
+
state.setLinked({ tool, device_id: deviceId, grace });
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
// free plan: single active tool.
|
|
55
|
+
let active: string | null = ent.active_tool ?? null;
|
|
56
|
+
if (active == null) {
|
|
57
|
+
const claimed = await claimToolCall(auth, tool); // self-claim the free slot
|
|
58
|
+
if (!claimed) return false;
|
|
59
|
+
active = claimed.active_tool ?? null;
|
|
60
|
+
}
|
|
61
|
+
if (active === tool) {
|
|
62
|
+
state.setLinked({ tool, device_id: deviceId });
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
state.setLocalOnly(state.NOT_ENTITLED, { active_tool: active, tool, device_id: deviceId });
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The current verdict for surfacing a user notice (no network). */
|
|
70
|
+
export function cloudMode(auth: Auth | null, tool: string, deviceId: string | null): state.Verdict {
|
|
71
|
+
if (!auth) return { ...state.defaultState(), mode: state.LOCAL_ONLY, reason: state.NOT_CONNECTED };
|
|
72
|
+
const st = state.get();
|
|
73
|
+
if (!state.matches(st, tool, deviceId)) return state.defaultState(); // foreign → no notice
|
|
74
|
+
return st;
|
|
75
|
+
}
|
package/lib/log.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Opt-in debug log (set ATLASO_DEBUG=1). Off by default — hooks stay quiet.
|
|
2
|
+
* Mirrors the Python connector's `_shim.log`: per-hook files in the atlaso dir. */
|
|
3
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { atlasoDir } from "./atlaso";
|
|
6
|
+
|
|
7
|
+
export function log(name: string, msg: string): void {
|
|
8
|
+
if (!process.env.ATLASO_DEBUG) return;
|
|
9
|
+
try {
|
|
10
|
+
const d = atlasoDir();
|
|
11
|
+
mkdirSync(d, { recursive: true });
|
|
12
|
+
appendFileSync(join(d, `atlaso-opencode-${name}.log`), `${new Date().toISOString()} ${msg}\n`);
|
|
13
|
+
} catch {
|
|
14
|
+
/* logging is best-effort */
|
|
15
|
+
}
|
|
16
|
+
}
|
package/lib/project.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/** Derive a stable PROJECT KEY for per-project memory — commodity, never the IP.
|
|
2
|
+
*
|
|
3
|
+
* Preserves the "automatic per-project memory" UX (new folder → its own isolated
|
|
4
|
+
* scope, zero setup) WITHOUT writing anything into the project folder. We only
|
|
5
|
+
* compute a string key from a directory:
|
|
6
|
+
* 1. the git remote origin URL (stable across clones), else
|
|
7
|
+
* 2. "<basename>-<short hash of abspath>".
|
|
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`.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
13
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
14
|
+
|
|
15
|
+
const MARKERS = [
|
|
16
|
+
".git", "pyproject.toml", "package.json", "Cargo.toml", "go.mod",
|
|
17
|
+
".hg", ".svn", "Gemfile", "pom.xml", "build.gradle", "requirements.txt",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export function projectRoot(start?: string): string {
|
|
21
|
+
let cur: string;
|
|
22
|
+
try {
|
|
23
|
+
cur = resolve(start || process.cwd());
|
|
24
|
+
} catch {
|
|
25
|
+
return process.cwd();
|
|
26
|
+
}
|
|
27
|
+
let d = cur;
|
|
28
|
+
// walk up to the filesystem root looking for a project marker
|
|
29
|
+
while (true) {
|
|
30
|
+
for (const m of MARKERS) {
|
|
31
|
+
try {
|
|
32
|
+
if (existsSync(join(d, m))) return d;
|
|
33
|
+
} catch {
|
|
34
|
+
/* ignore */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const parent = dirname(d);
|
|
38
|
+
if (parent === d) break;
|
|
39
|
+
d = parent;
|
|
40
|
+
}
|
|
41
|
+
return cur; // no markers → the cwd itself is the "project"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Read remote.origin.url straight from .git/config (no subprocess). Handles a
|
|
45
|
+
* `.git` FILE (worktrees) by following gitdir → commondir, so all worktrees of
|
|
46
|
+
* one repo resolve to the SAME key. null if absent. */
|
|
47
|
+
function gitOrigin(root: string): string | null {
|
|
48
|
+
try {
|
|
49
|
+
const gitpath = join(root, ".git");
|
|
50
|
+
let cfg: string | null = null;
|
|
51
|
+
let st;
|
|
52
|
+
try {
|
|
53
|
+
st = statSync(gitpath);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (st.isDirectory()) {
|
|
58
|
+
cfg = join(gitpath, "config");
|
|
59
|
+
} else if (st.isFile()) {
|
|
60
|
+
const txt = readFileSync(gitpath, "utf-8");
|
|
61
|
+
const m = txt.match(/gitdir:\s*(.+)/);
|
|
62
|
+
if (m) {
|
|
63
|
+
const gd = resolve(root, m[1].trim());
|
|
64
|
+
let common = gd;
|
|
65
|
+
const cd = join(gd, "commondir");
|
|
66
|
+
if (existsSync(cd)) {
|
|
67
|
+
try {
|
|
68
|
+
common = resolve(gd, readFileSync(cd, "utf-8").trim());
|
|
69
|
+
} catch {
|
|
70
|
+
common = gd;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
cfg = join(common, "config");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!cfg || !existsSync(cfg)) return null;
|
|
77
|
+
const text = readFileSync(cfg, "utf-8");
|
|
78
|
+
let inOrigin = false;
|
|
79
|
+
for (const line of text.split(/\r?\n/)) {
|
|
80
|
+
const s = line.trim();
|
|
81
|
+
if (s.startsWith("[")) {
|
|
82
|
+
inOrigin = s.replace(/\s/g, "").toLowerCase().startsWith('[remote"origin"]');
|
|
83
|
+
} else if (inOrigin && s.toLowerCase().startsWith("url")) {
|
|
84
|
+
const val = s.slice(s.indexOf("=") + 1).trim();
|
|
85
|
+
return val || null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Normalize a git remote to a stable key: drop scheme/creds/.git, lowercase
|
|
95
|
+
* host+path. git@github.com:me/app.git & https://github.com/me/app(.git) →
|
|
96
|
+
* github.com/me/app. */
|
|
97
|
+
function normalizeRemote(url: string): string {
|
|
98
|
+
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)
|
|
102
|
+
u = u.replace(/\.git$/, "");
|
|
103
|
+
return u.replace(/^\/+|\/+$/g, "").toLowerCase();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A stable identity for the current project. null on any failure → personal-only. */
|
|
107
|
+
export function projectKey(start?: string): string | null {
|
|
108
|
+
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);
|
|
114
|
+
}
|
|
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}`;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** (scope, project_key) from a deposit's tags — mirrors the server + Python
|
|
124
|
+
* `_project.scope_of`. */
|
|
125
|
+
export function scopeOf(tags: string[] | undefined): [string, string | null] {
|
|
126
|
+
let scope = "personal";
|
|
127
|
+
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);
|
|
132
|
+
}
|
|
133
|
+
return [scope, pkey];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 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`. */
|
|
140
|
+
export function visibleInProject(
|
|
141
|
+
tags: string[] | undefined,
|
|
142
|
+
project: string | null | undefined,
|
|
143
|
+
): boolean {
|
|
144
|
+
const [scope, pkey] = scopeOf(tags);
|
|
145
|
+
if (scope !== "project") return true;
|
|
146
|
+
if (pkey === null) return false;
|
|
147
|
+
return pkey === (project ?? null);
|
|
148
|
+
}
|
|
149
|
+
|
|
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.) */
|
|
156
|
+
export function workspaceRoot(payload: Record<string, any>): string | null {
|
|
157
|
+
const p = payload || {};
|
|
158
|
+
const candidates: unknown[] = [
|
|
159
|
+
Array.isArray(p.workspace_roots) ? p.workspace_roots[0] : undefined,
|
|
160
|
+
Array.isArray(p.workspaceRoots) ? p.workspaceRoots[0] : undefined,
|
|
161
|
+
p.project?.workspaceRoot,
|
|
162
|
+
p.workspaceRoot,
|
|
163
|
+
p.workspace_root,
|
|
164
|
+
p.cwd,
|
|
165
|
+
];
|
|
166
|
+
for (const c of candidates) if (typeof c === "string" && c.trim()) return c;
|
|
167
|
+
return process.env.PWD || process.cwd() || null;
|
|
168
|
+
}
|
package/lib/render.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Build the recalled-memory injection STRING for OpenCode.
|
|
2
|
+
*
|
|
3
|
+
* Unlike Cursor (which writes a `.mdc` rules file because its sessionStart
|
|
4
|
+
* injection is broken), OpenCode lets a plugin inject context directly by pushing
|
|
5
|
+
* a synthetic text part onto the chat.message output. So here we just build a
|
|
6
|
+
* plain fenced block:
|
|
7
|
+
*
|
|
8
|
+
* === Atlaso Memory ===
|
|
9
|
+
* - <note> [scope]
|
|
10
|
+
* - [conflict] <note> (conflicts with N other notes) [project]
|
|
11
|
+
* === Atlaso Memory ===
|
|
12
|
+
*
|
|
13
|
+
* Per-note semantics mirror the Python `_render.recall_block` + Cursor's
|
|
14
|
+
* `lib/render`: a plain branded block (NO "untrusted data" warning, NO
|
|
15
|
+
* instructions — the model decides), each conflict flagged with a peer COUNT
|
|
16
|
+
* (never leaking internal ids), scope appended. Stored content is sanitized so it
|
|
17
|
+
* can't forge our own `=== Atlaso Memory ===` fence (→ `[atlaso]`).
|
|
18
|
+
*
|
|
19
|
+
* Returns null when there are no usable results (caller injects nothing).
|
|
20
|
+
*/
|
|
21
|
+
import type { RecallResult } from "./atlaso";
|
|
22
|
+
|
|
23
|
+
const BANNER = "Atlaso Memory";
|
|
24
|
+
const FENCE = `=== ${BANNER} ===`;
|
|
25
|
+
// neutralize any forged Atlaso fence inside stored content (memory OR orientation,
|
|
26
|
+
// opening OR closing), so recalled text can never spoof our block boundary.
|
|
27
|
+
const FENCE_RE = /=+\s*(?:END\s+)?Atlaso\s+(?:Memory|Orientation)[^\n]*/gi;
|
|
28
|
+
|
|
29
|
+
export interface RenderOpts {
|
|
30
|
+
/** Override the banner (tests / future tools). Defaults to "Atlaso Memory". */
|
|
31
|
+
banner?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Collapse a stored note to a single safe line. */
|
|
35
|
+
function clean(text: string): string {
|
|
36
|
+
return (text || "").trim().replace(FENCE_RE, "[atlaso]").replace(/\s*\n+\s*/g, " ");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One bullet per result: optional [conflict] prefix + peer count + [scope]
|
|
40
|
+
* suffix (mirrors Cursor's `line` / the Python recall_block). null if empty. */
|
|
41
|
+
function line(r: RecallResult): string | null {
|
|
42
|
+
const content = clean(r.content || "");
|
|
43
|
+
if (!content) return null;
|
|
44
|
+
const hd = !!r.has_disagreement;
|
|
45
|
+
let out = "- " + (hd ? "[conflict] " : "") + content;
|
|
46
|
+
const peers = Array.isArray(r.conflict_peers) ? r.conflict_peers.length : 0;
|
|
47
|
+
if (hd && peers) out += ` (conflicts with ${peers} other note${peers !== 1 ? "s" : ""})`;
|
|
48
|
+
if (r.scope) out += ` [${r.scope}]`;
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The fenced injection string, or null when nothing is worth injecting. */
|
|
53
|
+
export function renderBlock(results: RecallResult[] | null | undefined, opts: RenderOpts = {}): string | null {
|
|
54
|
+
const fence = opts.banner ? `=== ${opts.banner} ===` : FENCE;
|
|
55
|
+
const lines: string[] = [];
|
|
56
|
+
for (const r of results || []) {
|
|
57
|
+
const l = line(r);
|
|
58
|
+
if (l) lines.push(l);
|
|
59
|
+
}
|
|
60
|
+
if (!lines.length) return null;
|
|
61
|
+
return `${fence}\n${lines.join("\n")}\n${fence}`;
|
|
62
|
+
}
|