@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/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;
|