@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/lib/state.ts ADDED
@@ -0,0 +1,125 @@
1
+ /** Persisted cloud-link / entitlement verdict — ported 1:1 from the Python thin
2
+ * client's `state.py`. The free plan allows ONE active tool per device; the
3
+ * SERVER does not enforce that on the memory endpoints (by design), so each tool
4
+ * caches its own verdict here and self-gates. The verdict is scoped to
5
+ * (tool, device_id) so one tool never inherits another's, and is trusted for a
6
+ * short TTL before re-verifying with the brain.
7
+ *
8
+ * Stored at <atlaso_dir>/cloud_state.json (next to auth.json), overridable via
9
+ * ATLASO_STATE. Written atomically (wx temp + fsync + rename).
10
+ */
11
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
12
+ import { randomUUID } from "node:crypto";
13
+ import { dirname, join } from "node:path";
14
+ import { atlasoDir } from "./atlaso";
15
+
16
+ // `0` (or negative) disables caching → re-verify every call, like the Python client.
17
+ const _ttl = parseInt(process.env.ATLASO_ENTITLEMENT_TTL ?? "600", 10);
18
+ export const ENTITLEMENT_TTL = Number.isFinite(_ttl) ? _ttl : 600;
19
+
20
+ export const LINKED = "linked";
21
+ export const LOCAL_ONLY = "local_only";
22
+ export const REVOKED = "revoked";
23
+ export const NOT_ENTITLED = "not_entitled";
24
+ export const NOT_CONNECTED = "not_connected";
25
+
26
+ export interface Grace {
27
+ in_grace: boolean;
28
+ days_left: number | null;
29
+ tools_connected: number | null;
30
+ }
31
+
32
+ export interface Verdict {
33
+ mode: string; // "linked" | "local_only"
34
+ reason: string | null; // "revoked" | "not_entitled" | "not_connected" | null
35
+ since: number;
36
+ checked_at: number;
37
+ active_tool: string | null;
38
+ tool: string | null;
39
+ device_id: string | null;
40
+ grace: Grace | null;
41
+ }
42
+
43
+ function statePath(): string {
44
+ return process.env.ATLASO_STATE || join(atlasoDir(), "cloud_state.json");
45
+ }
46
+
47
+ const nowS = () => Math.floor(Date.now() / 1000);
48
+
49
+ /** An UNVERIFIED-LINKED baseline (checked_at:0 → never fresh) so first-run /
50
+ * foreign / corrupt state forces a re-verify rather than blocking. */
51
+ export function defaultState(): Verdict {
52
+ return { mode: LINKED, reason: null, since: 0, checked_at: 0, active_tool: null, tool: null, device_id: null, grace: null };
53
+ }
54
+
55
+ export function get(): Verdict {
56
+ try {
57
+ const o = JSON.parse(readFileSync(statePath(), "utf-8"));
58
+ if (o && typeof o === "object" && o.mode) return { ...defaultState(), ...o };
59
+ } catch {
60
+ /* missing / malformed → default */
61
+ }
62
+ return defaultState();
63
+ }
64
+
65
+ function write(v: Verdict): void {
66
+ try {
67
+ const target = statePath();
68
+ const dir = dirname(target); // temp in the TARGET's dir so the rename is atomic (honors ATLASO_STATE)
69
+ mkdirSync(dir, { recursive: true });
70
+ const tmp = join(dir, `.cloud_state.${process.pid}.${randomUUID()}.tmp`);
71
+ const fd = openSync(tmp, "wx", 0o600);
72
+ try {
73
+ writeFileSync(fd, JSON.stringify(v, null, 2));
74
+ fsyncSync(fd);
75
+ } finally {
76
+ closeSync(fd);
77
+ }
78
+ renameSync(tmp, target);
79
+ } catch {
80
+ /* best-effort — absence just forces re-verify */
81
+ }
82
+ }
83
+
84
+ export function setLinked(opts: { tool?: string | null; device_id?: string | null; grace?: Grace | null }): void {
85
+ write({
86
+ mode: LINKED, reason: null, since: 0, checked_at: nowS(), active_tool: null,
87
+ tool: opts.tool ?? null, device_id: opts.device_id ?? null, grace: opts.grace ?? null,
88
+ });
89
+ }
90
+
91
+ export function setLocalOnly(
92
+ reason: string,
93
+ opts: { active_tool?: string | null; tool?: string | null; device_id?: string | null },
94
+ ): void {
95
+ const prev = get();
96
+ // preserve `since` across same-reason+identity rewrites (so a one-time notice isn't re-shown)
97
+ const same = prev.mode === LOCAL_ONLY && prev.reason === reason &&
98
+ prev.tool === (opts.tool ?? null) && prev.device_id === (opts.device_id ?? null);
99
+ write({
100
+ mode: LOCAL_ONLY, reason, since: same ? prev.since : nowS(), checked_at: nowS(),
101
+ active_tool: opts.active_tool ?? null, tool: opts.tool ?? null, device_id: opts.device_id ?? null, grace: null,
102
+ });
103
+ }
104
+
105
+ /** Drop the verdict so a fresh credential never inherits a stale free pass. */
106
+ export function invalidate(): void {
107
+ try {
108
+ unlinkSync(statePath());
109
+ } catch {
110
+ /* already gone */
111
+ }
112
+ }
113
+
114
+ /** A verdict is authoritative only for the (tool, device_id) that produced it. */
115
+ export function matches(st: Verdict, tool: string | null | undefined, deviceId: string | null | undefined): boolean {
116
+ return st.tool === (tool ?? null) && st.device_id === (deviceId ?? null);
117
+ }
118
+
119
+ export function isFresh(st: Verdict): boolean {
120
+ try {
121
+ return nowS() - (st.checked_at || 0) < ENTITLEMENT_TTL;
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
package/opencode.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "$schema": "https://opencode.ai/config.json",
3
+ "_comment": "Atlaso memory for OpenCode. The plugin (hooks: auto-recall + auto-capture) IS the value and is pure TypeScript talking only to the Atlaso brain. Model-invoked memory tools (recall/remember/forget/recent/status over MCP) are a REMOTE-MCP follow-up shipped at deploy — intentionally NOT a local Python MCP here. To enable: `bun add -d @atlaso-labs/opencode` then keep the entries below. Sign in happens via a browser-authorize on first run.",
4
+ "plugin": ["@atlaso-labs/opencode"],
5
+ "instructions": [".opencode/atlaso/AGENTS.md"]
6
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@atlaso-labs/opencode",
3
+ "version": "0.1.0",
4
+ "description": "Long-term memory for OpenCode — recalls what you've decided and remembers what matters, across sessions, projects, and tools. A pure-TypeScript OpenCode plugin (no engine; HTTP to the Atlaso brain only).",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Atlaso Labs Inc. <hello@atlaso.ai>",
8
+ "homepage": "https://atlaso.ai",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/atlaso-labs/opencode.git"
12
+ },
13
+ "keywords": [
14
+ "opencode",
15
+ "opencode-plugin",
16
+ "memory",
17
+ "long-term-memory",
18
+ "agent-memory",
19
+ "recall",
20
+ "atlaso"
21
+ ],
22
+ "main": "src/index.ts",
23
+ "module": "src/index.ts",
24
+ "exports": {
25
+ ".": {
26
+ "import": "./src/index.ts",
27
+ "default": "./src/index.ts"
28
+ }
29
+ },
30
+ "files": [
31
+ "src",
32
+ "lib",
33
+ "skills",
34
+ "AGENTS.md",
35
+ "opencode.json",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "scripts": {
40
+ "test": "bun test",
41
+ "typecheck": "tsc --noEmit"
42
+ },
43
+ "devDependencies": {
44
+ "@opencode-ai/plugin": "latest",
45
+ "@types/bun": "latest",
46
+ "typescript": "^5"
47
+ },
48
+ "bugs": {
49
+ "url": "https://github.com/atlaso-labs/opencode/issues"
50
+ }
51
+ }
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: memory
3
+ description: >-
4
+ Atlaso memory curation judgment. Use ONLY when deciding whether/what to save to
5
+ memory, whether something is personal vs project-specific, or when deliberately
6
+ recalling, forgetting, or correcting a memory. Do NOT use for ordinary recall —
7
+ relevant memories are already injected automatically every turn.
8
+ when_to_use: >-
9
+ deciding if something is worth remembering; choosing personal vs project memory;
10
+ superseding or forgetting a wrong/outdated memory; deliberately searching past
11
+ sessions for a prior decision.
12
+ ---
13
+
14
+ # Using Atlaso memory well
15
+
16
+ Relevant memories are **auto-injected every turn** (the "Atlaso Memory" block) — so
17
+ most of the time you do nothing. Reach for the memory tools (`recall`, `remember`,
18
+ `forget`, `recent`, `status`) only for the judgment calls below. When in doubt, do
19
+ less: a smaller, higher-signal memory is worth more than volume.
20
+
21
+ ## What's worth remembering (default: don't)
22
+
23
+ Save **durable** things:
24
+ - decisions **and the reason** behind them
25
+ - the user's stable preferences and working style
26
+ - hard-won gotchas ("X silently fails unless Y")
27
+ - stable facts/commands (ports, endpoints, conventions)
28
+
29
+ Don't save: transient state ("ran the tests just now"), secrets/tokens, restatements
30
+ of files already in the repo, or anything that's just in this turn's context.
31
+
32
+ ## Personal vs project — Atlaso's dual memory
33
+
34
+ Atlaso keeps two memories. Route deliberately:
35
+ - **Personal** (follows the user across every project/tool): cross-project preferences,
36
+ identity, working style. → "true in every repo."
37
+ - **Project** (this repo only): architecture, repo-specific decisions and gotchas.
38
+ → "true only here."
39
+
40
+ Rule of thumb: *would this still be true in a different project?* Yes → personal. No → project.
41
+
42
+ ## When to deliberately `recall` (vs trusting the auto-injection)
43
+
44
+ The automatic block usually has what you need. Search explicitly only when:
45
+ - the user references a past decision ("what did we decide about X?"),
46
+ - you're about to do something that might contradict an earlier choice, or
47
+ - you're starting unfamiliar work where prior context would clearly help.
48
+
49
+ Otherwise, trust the injected memories and **don't over-search**.
50
+
51
+ ## Fixing memory
52
+
53
+ A memory is wrong or outdated → `recall` to find its id, then `forget` it (or save the
54
+ correction). Supersede rather than piling up contradictions.
55
+
56
+ ## Good vs skip
57
+
58
+ - ✅ "Use pnpm, never npm — the user's standard across all projects." *(personal)*
59
+ - ✅ "Brain server runs on port 8800; recall is `GET /v1/recall`." *(project)*
60
+ - ✅ "Tauri signing key must be single-line in CI or it errors." *(hard-won gotcha)*
61
+ - ⏭️ "Compiled the app and the tests passed." *(ephemeral — skip)*
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Runnable device-authorize entrypoint, spawned DETACHED by maybeAutoconnect()
4
+ * on first run (and usable directly: `bun run src/connect-entry.ts`). Opens the
5
+ * browser, polls until approved, writes the shared ~/.atlaso/auth.json, then
6
+ * releases the connect lock. Defaults ATLASO_TOOL to "opencode".
7
+ */
8
+ import { runConnect } from "../lib/connect";
9
+
10
+ if (!process.env.ATLASO_TOOL) process.env.ATLASO_TOOL = "opencode";
11
+
12
+ runConnect()
13
+ .then((rc) => process.exit(rc))
14
+ .catch(() => process.exit(1));
package/src/index.ts ADDED
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Atlaso memory — OpenCode plugin (the npm package main + default export).
3
+ *
4
+ * The automatic memory loop, in pure TypeScript (bun-native, no Python). It owns
5
+ * NO memory logic: it only does HTTP to the brain `/v1/*` via the shared, tool-
6
+ * agnostic `lib/` (copied from the Cursor connector). The engine — retrieval,
7
+ * the conflict gate, the worth-keeping judgment — stays on the server. This is
8
+ * the IP thin-client rule, in an OpenCode plugin.
9
+ *
10
+ * Two hooks make the loop:
11
+ * • "chat.message" → recall relevant memory and inject it as a synthetic
12
+ * text part BEFORE the model responds (auto-recall).
13
+ * • "event"/session.idle → capture the user's statement to memory when the
14
+ * turn finishes (auto-capture).
15
+ *
16
+ * EVERYTHING is FAIL-OPEN: a hook error must never break an OpenCode turn, so
17
+ * each hook body is wrapped in try/catch and the loop degrades to a no-op.
18
+ *
19
+ * Recall injection (verified live, opencode 1.17.x): pushing a SCHEMA-VALID
20
+ * synthetic text part onto chat.message `output.parts` injects AI-visible context —
21
+ * the model reads it and answers from it. OpenCode PERSISTS + strictly validates the
22
+ * part (id must be `prt_*`, messageID must be this turn's real `msg_*`), so we derive
23
+ * both from `output.message` and only ever ADD to the array. Fail-open: if there's no
24
+ * real message id we SKIP injecting (a malformed part would throw and break the turn).
25
+ */
26
+ import { createHash } from "node:crypto";
27
+ import type { Hooks, Plugin } from "@opencode-ai/plugin";
28
+ import type { Part } from "@opencode-ai/sdk";
29
+
30
+ import { deposit, loadAuth, recall, type DepositItem } from "../lib/atlaso";
31
+ import { buildContent, classifyScope, heuristicPolarity, scrub, shouldDeposit } from "../lib/capture";
32
+ import { maybeAutoconnect } from "../lib/connect";
33
+ import { online } from "../lib/entitlement";
34
+ import { log } from "../lib/log";
35
+ import { projectKey } from "../lib/project";
36
+ import { renderBlock } from "../lib/render";
37
+
38
+ const TOOL = "opencode";
39
+ const RECALL_LIMIT = 5;
40
+
41
+ /** Deterministic idempotency key, so a re-deposit of the same statement collapses
42
+ * to ONE memory server-side. Scope + project key are folded in so the SAME
43
+ * content captured in a DIFFERENT project keeps its own attribution (matches the
44
+ * Cursor/Claude-Code connectors + the Python client). */
45
+ function clientId(content: string, scope: string, project: string | null): string {
46
+ return createHash("sha256").update(`${scope} ${project ?? ""} ${content}`).digest("hex").slice(0, 32);
47
+ }
48
+
49
+ /** Join this turn's REAL user text from a chat.message output — only `type:"text"`
50
+ * parts that are NOT synthetic (so we never echo our own injected block back). */
51
+ function userTextFrom(parts: unknown): string {
52
+ if (!Array.isArray(parts)) return "";
53
+ const out: string[] = [];
54
+ for (const p of parts as any[]) {
55
+ if (p && p.type === "text" && typeof p.text === "string" && !p.synthetic) out.push(p.text);
56
+ }
57
+ return out.join("\n").trim();
58
+ }
59
+
60
+ /** A SCHEMA-VALID synthetic TextPart carrying the recalled block. OpenCode SAVES
61
+ * injected parts and validates them strictly: `id` must start with `prt_` and
62
+ * `messageID` must be THIS turn's real `msg_*` — a malformed part throws and BREAKS
63
+ * the turn (verified live on opencode 1.17.x). So we derive both from
64
+ * `output.message`; the caller only calls this when a real message id is present. */
65
+ function memoryPart(block: string, message: { id: string; sessionID: string }): Part {
66
+ return {
67
+ id: `prt_${createHash("sha256").update(`${message.id}:${block}`).digest("hex").slice(0, 24)}`,
68
+ sessionID: message.sessionID,
69
+ messageID: message.id,
70
+ type: "text",
71
+ text: block,
72
+ synthetic: true, // marks it as injected — kept out of the user transcript
73
+ };
74
+ }
75
+
76
+ export const AtlasoMemory: Plugin = async ({ directory, worktree }): Promise<Hooks> => {
77
+ // Detached browser-authorize on first run; no-op once this machine is linked.
78
+ try {
79
+ maybeAutoconnect(TOOL);
80
+ } catch {
81
+ /* fail-open: never let connect setup break plugin init */
82
+ }
83
+
84
+ // Per-session pending capture: the user's statement, saved at chat.message and
85
+ // deposited on session.idle (which carries only the sessionID, not the text).
86
+ const pending = new Map<string, { user: string }>();
87
+
88
+ // Resolve the project ONCE at init (the plugin's directory is stable for the
89
+ // session); null → personal-only scope.
90
+ const project = projectKey(directory || worktree) || undefined;
91
+
92
+ return {
93
+ /**
94
+ * AUTO-RECALL. On each new user message, recall relevant memory and unshift it
95
+ * as a synthetic text part so the model sees it before responding. Cloud-gated
96
+ * (free plan = 1 active tool/device; the brain doesn't enforce it — we do).
97
+ */
98
+ "chat.message": async (input, output) => {
99
+ if (process.env.ATLASO_EXTRACTING) return; // never recall inside our own enrichment
100
+ try {
101
+ if (!output || !Array.isArray(output.parts)) return; // shape changed → no-op
102
+ const userText = userTextFrom(output.parts);
103
+ if (!userText) return;
104
+
105
+ const auth = loadAuth();
106
+ 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
108
+
109
+ const results = await recall(auth, userText, RECALL_LIMIT, project, input.sessionID);
110
+ const block = renderBlock(results);
111
+ // OpenCode persists + strictly validates injected parts, so we can only build
112
+ // a schema-valid one when THIS turn's real message id (msg_*) is present on
113
+ // output.message. If it's missing, skip injecting (never crash the turn).
114
+ const message = (output as { message?: { id?: string; sessionID?: string } }).message;
115
+ if (block && message?.id) {
116
+ output.parts.unshift(memoryPart(block, { id: message.id, sessionID: message.sessionID || input.sessionID }));
117
+ log("recall", `injected n=${results.length} session=${input.sessionID}`);
118
+ } else if (block) {
119
+ log("recall", "skip-inject (no message id on output.message)");
120
+ }
121
+ // remember this turn's user statement for capture at session.idle
122
+ pending.set(input.sessionID, { user: userText });
123
+ } catch (e) {
124
+ log("recall", `error ${e}`); // fail open — memory must never break a turn
125
+ }
126
+ },
127
+
128
+ /**
129
+ * AUTO-CAPTURE. session.idle fires at end of turn and carries ONLY the
130
+ * sessionID — so we deposit the user statement we stashed at chat.message.
131
+ * Write-only; injects no context. Cloud-gated + secret-scrubbed client-side.
132
+ *
133
+ * TODO(enrichment): session.idle has no assistant text, so v1 captures the
134
+ * user statement alone (the core value). A follow-up can fetch the turn's
135
+ * assistant reply via the opencode SDK client and enrich buildContent(user,
136
+ * assistant) before depositing.
137
+ */
138
+ event: async ({ event }) => {
139
+ if (process.env.ATLASO_EXTRACTING) return; // never capture our own enrichment
140
+ try {
141
+ if (event?.type !== "session.idle") return;
142
+ const sessionID: string | undefined = (event as any)?.properties?.sessionID;
143
+ if (!sessionID) return;
144
+ const p = pending.get(sessionID);
145
+ if (!p) return;
146
+ pending.delete(sessionID); // one capture per stashed statement
147
+
148
+ const user = p.user;
149
+ if (!shouldDeposit(user)[0]) {
150
+ log("capture", "skip (gate)");
151
+ return;
152
+ }
153
+ const auth = loadAuth();
154
+ if (!auth) return;
155
+ if (!(await online(auth, TOOL, auth.device_id ?? null))) {
156
+ log("capture", "skip (not cloud-linked — local-only)");
157
+ return;
158
+ }
159
+
160
+ const content = buildContent(scrub(user)[0], ""); // assistant omitted in v1 (see TODO)
161
+ if (!content) return;
162
+ const scope = classifyScope(user);
163
+ const pk = projectKey(directory || worktree); // for the project tag + idempotency key
164
+ const tags = ["opencode", "auto", `pol-hint:${heuristicPolarity(user)}`, `scope:${scope}`];
165
+ if (scope === "project" && pk) tags.push(`project:${pk}`);
166
+
167
+ const item: DepositItem = {
168
+ client_id: clientId(content, scope, scope === "project" ? pk : null),
169
+ text: content,
170
+ polarity: "open",
171
+ evidence_grade: "anecdotal",
172
+ scope_note: null,
173
+ tags,
174
+ };
175
+ const saved = await deposit(auth, [item]);
176
+ log("capture", `saved=${saved} scope=${scope} session=${sessionID}`);
177
+ } catch (e) {
178
+ log("capture", `error ${e}`); // fail open
179
+ }
180
+ },
181
+ };
182
+ };
183
+
184
+ export default AtlasoMemory;