@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/AGENTS.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
This is the Atlaso memory instruction block for OpenCode. It uses OpenCode's
|
|
3
|
+
native instructions format (AGENTS.md). Add it ONE of these ways:
|
|
4
|
+
1. Save it at <repo>/.opencode/atlaso/AGENTS.md and point opencode.json at it
|
|
5
|
+
via the "instructions" array, e.g. "instructions": [".opencode/atlaso/AGENTS.md"]
|
|
6
|
+
(this is what the template opencode.json does), OR
|
|
7
|
+
2. Append the section below to your project-root AGENTS.md (or
|
|
8
|
+
~/.config/opencode/AGENTS.md for global).
|
|
9
|
+
Docs: https://opencode.ai/docs/rules/
|
|
10
|
+
-->
|
|
11
|
+
|
|
12
|
+
# Using Atlaso memory well
|
|
13
|
+
|
|
14
|
+
Atlaso is the user's long-term memory. Two things are already automatic, so most
|
|
15
|
+
of the time you do nothing:
|
|
16
|
+
|
|
17
|
+
- **Auto-recall:** before each of your turns, relevant memories are injected into
|
|
18
|
+
your context as an `=== Atlaso Memory ===` block. Trust it; don't re-search what
|
|
19
|
+
it already gave you.
|
|
20
|
+
- **Auto-capture:** when a turn finishes, what the user told you is saved
|
|
21
|
+
automatically. You rarely need to save things by hand.
|
|
22
|
+
|
|
23
|
+
When in doubt, do less: a smaller, higher-signal memory is worth more than volume.
|
|
24
|
+
The judgment calls below are about *what* is worth remembering and *where* it
|
|
25
|
+
belongs (personal vs project) — apply them as you work.
|
|
26
|
+
|
|
27
|
+
## What's worth remembering (default: don't)
|
|
28
|
+
|
|
29
|
+
Save **durable** things:
|
|
30
|
+
- decisions **and the reason** behind them
|
|
31
|
+
- the user's stable preferences and working style
|
|
32
|
+
- hard-won gotchas ("X silently fails unless Y")
|
|
33
|
+
- stable facts/commands (ports, endpoints, conventions)
|
|
34
|
+
|
|
35
|
+
Don't save: transient state ("ran the tests just now"), secrets/tokens,
|
|
36
|
+
restatements of files already in the repo, or anything that's just in this turn's
|
|
37
|
+
context.
|
|
38
|
+
|
|
39
|
+
## Personal vs project — Atlaso's dual memory
|
|
40
|
+
|
|
41
|
+
Atlaso keeps two memories. Route deliberately:
|
|
42
|
+
- **Personal** (follows the user across every project/tool): cross-project
|
|
43
|
+
preferences, identity, working style. → "true in every repo."
|
|
44
|
+
- **Project** (this repo only): architecture, repo-specific decisions and gotchas.
|
|
45
|
+
→ "true only here."
|
|
46
|
+
|
|
47
|
+
Rule of thumb: *would this still be true in a different project?* Yes → personal.
|
|
48
|
+
No → project.
|
|
49
|
+
|
|
50
|
+
## Trust the injected memories
|
|
51
|
+
|
|
52
|
+
The automatic `=== Atlaso Memory ===` block usually has what you need — use it,
|
|
53
|
+
and **don't over-rely on it either**: if the user references a past decision, or
|
|
54
|
+
you're about to do something that might contradict an earlier choice, weigh what
|
|
55
|
+
the block actually says rather than assuming.
|
|
56
|
+
|
|
57
|
+
## When memory looks wrong
|
|
58
|
+
|
|
59
|
+
If an injected memory is clearly outdated or contradicted by the current
|
|
60
|
+
conversation, prefer what the user just told you and note the correction so the
|
|
61
|
+
newer fact is what gets captured. Supersede rather than piling up contradictions.
|
|
62
|
+
|
|
63
|
+
## Good vs skip
|
|
64
|
+
|
|
65
|
+
- ✅ "Use pnpm, never npm — the user's standard across all projects." *(personal)*
|
|
66
|
+
- ✅ "Brain server runs on port 8800; recall is `GET /v1/recall`." *(project)*
|
|
67
|
+
- ✅ "Signing key must be single-line in CI or it errors." *(hard-won gotcha)*
|
|
68
|
+
- ⏭️ "Compiled the app and the tests passed." *(ephemeral — skip)*
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Atlaso Labs Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Atlaso × OpenCode
|
|
2
|
+
|
|
3
|
+
Long-term memory for [OpenCode](https://opencode.ai) — recalls what you've
|
|
4
|
+
decided and remembers what matters, across sessions, projects, and tools.
|
|
5
|
+
|
|
6
|
+
This is a **pure-TypeScript, bun-native OpenCode plugin**. No Python, no local
|
|
7
|
+
engine: the plugin only does HTTP to the Atlaso brain's `/v1/*` API. The
|
|
8
|
+
intelligence — retrieval, the conflict gate, the worth-keeping judgment — stays
|
|
9
|
+
on the server. Memory is **fail-open**: a hook error never breaks an OpenCode
|
|
10
|
+
turn.
|
|
11
|
+
|
|
12
|
+
## What you get
|
|
13
|
+
|
|
14
|
+
| Piece | OpenCode mechanism | What it does |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| **Auto-recall** | plugin `chat.message` hook | Before each of your turns, recalled memory is injected as a synthetic text part so the model sees it. |
|
|
17
|
+
| **Auto-capture** | plugin `event` hook on `session.idle` | When a turn finishes, the user's statement is saved (scrubbed for secrets first). |
|
|
18
|
+
| **Instructions** | `AGENTS.md` (native rules format) | Teaches the model how to use memory well (personal vs project, what's worth keeping). |
|
|
19
|
+
| **Skill** | `.opencode/skills/memory/SKILL.md` | Curation judgment, auto-discovered by OpenCode. |
|
|
20
|
+
|
|
21
|
+
The automatic loop is the value and comes **entirely from the plugin hooks**.
|
|
22
|
+
|
|
23
|
+
> **Model-invoked memory tools** (`recall` / `remember` / `forget` / `recent` /
|
|
24
|
+
> `status` over MCP) are a **remote-MCP follow-up shipped at deploy** — this
|
|
25
|
+
> connector intentionally ships **no** local Python MCP. The hooks above don't
|
|
26
|
+
> depend on them.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
Atlaso memory is one npm package: `@atlaso-labs/opencode`.
|
|
31
|
+
|
|
32
|
+
### Recommended — add it to your `opencode.json`
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bun add -d @atlaso-labs/opencode
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
then list it in your project (or global `~/.config/opencode/`) `opencode.json`:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"$schema": "https://opencode.ai/config.json",
|
|
43
|
+
"plugin": ["@atlaso-labs/opencode"],
|
|
44
|
+
"instructions": [".opencode/atlaso/AGENTS.md"]
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The `opencode.json` in this folder is a ready-to-use template. Copy `AGENTS.md`
|
|
49
|
+
to `.opencode/atlaso/AGENTS.md` (or point `instructions` at wherever you keep it),
|
|
50
|
+
and the `skills/memory/` folder to `.opencode/skills/memory/` so OpenCode
|
|
51
|
+
auto-discovers the curation skill.
|
|
52
|
+
|
|
53
|
+
### Dev — drop the plugin file in directly
|
|
54
|
+
|
|
55
|
+
For local hacking you can skip npm and copy the entry into OpenCode's plugin
|
|
56
|
+
directory ([docs](https://opencode.ai/docs/plugins/)):
|
|
57
|
+
|
|
58
|
+
- **Project-local:** copy `src/index.ts` → `<repo>/.opencode/plugins/atlaso-memory.ts`
|
|
59
|
+
- **Global:** copy it → `~/.config/opencode/plugins/atlaso-memory.ts`
|
|
60
|
+
|
|
61
|
+
(Note the **plural** `plugins/` directory — that's the OpenCode convention. When
|
|
62
|
+
copying the single file in, keep the `lib/` folder reachable next to it, or use
|
|
63
|
+
the npm install above so the imports resolve.)
|
|
64
|
+
|
|
65
|
+
### Sign in (first run)
|
|
66
|
+
|
|
67
|
+
Memory is **cloud-linked**: on the first turn after install the plugin opens a
|
|
68
|
+
browser to **authorize this device** (a one-time device-link flow that writes the
|
|
69
|
+
shared `~/.atlaso/auth.json`). Approve it in the dashboard and memory starts
|
|
70
|
+
syncing. Until then — or for a tool that isn't your active tool on the free plan
|
|
71
|
+
— the plugin simply does nothing that turn (fail-open).
|
|
72
|
+
|
|
73
|
+
OpenCode itself: `curl -fsSL https://opencode.ai/install | bash` (or
|
|
74
|
+
`npm i -g opencode-ai`); provider auth via the TUI `/connect` or
|
|
75
|
+
`opencode auth login`.
|
|
76
|
+
|
|
77
|
+
## How it works
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
tools/opencode/
|
|
81
|
+
src/index.ts plugin entry: chat.message (recall) + event/session.idle (capture)
|
|
82
|
+
src/connect-entry.ts detached browser-authorize process (spawned on first run)
|
|
83
|
+
lib/ tool-agnostic brain client, copied from the Cursor connector:
|
|
84
|
+
atlaso.ts thin /v1/* HTTP client (recall/deposit/entitlement), fail-open
|
|
85
|
+
capture.ts commodity heuristics (worth-keeping gate, scope, polarity, secret scrub)
|
|
86
|
+
project.ts stable per-project key (git remote / path hash), read-only
|
|
87
|
+
entitlement.ts cloud-linked vs local-only gate (free = 1 active tool/device)
|
|
88
|
+
state.ts cached entitlement verdict (tool+device scoped)
|
|
89
|
+
connect.ts device-link handshake → ~/.atlaso/auth.json
|
|
90
|
+
render.ts builds the "=== Atlaso Memory ===" injection string
|
|
91
|
+
log.ts opt-in debug log (ATLASO_DEBUG=1)
|
|
92
|
+
skills/memory/SKILL.md curation judgment (auto-discovered)
|
|
93
|
+
AGENTS.md memory instructions (native rules format)
|
|
94
|
+
opencode.json install template
|
|
95
|
+
tests/ bun tests (lib + plugin behavior, all with fakes — no network)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- **The dual memory** (the headline feature) is preserved: `personal` memories
|
|
99
|
+
follow the user across every project/tool; `project` memories stay isolated to
|
|
100
|
+
the repo (a stable key derived from the git remote, with strict cross-project
|
|
101
|
+
visibility). Nothing is ever written into your project folder.
|
|
102
|
+
- **Auto-capture** rides `session.idle`, which carries only the sessionID — so the
|
|
103
|
+
plugin stashes the user's statement at `chat.message` and deposits it when the
|
|
104
|
+
turn goes idle. Assistant-snippet enrichment is a follow-up (see the `TODO` in
|
|
105
|
+
`src/index.ts`).
|
|
106
|
+
- **Secrets never leave the machine**: captures are scrubbed client-side
|
|
107
|
+
(defense-in-depth; the server re-scrubs too).
|
|
108
|
+
- **Graceful degradation**: a revoked or non-entitled tool keeps working and never
|
|
109
|
+
deletes memories — that's the shared cloud-linked/local-only state machine.
|
|
110
|
+
|
|
111
|
+
## Honesty / caveats (verify on a live OpenCode before relying on these)
|
|
112
|
+
|
|
113
|
+
- **Auto-capture is the documented, robust half.** It's a write-only side effect
|
|
114
|
+
on the documented `event`/`session.idle` hook — it depends on nothing fragile.
|
|
115
|
+
- **Auto-recall injection is a community pattern.** Pushing a synthetic text part
|
|
116
|
+
onto `chat.message` `output.parts` is how working OpenCode memory plugins inject
|
|
117
|
+
AI-visible context today; a documented true-injection API is still an open
|
|
118
|
+
feature request, and some OpenCode builds have reportedly mis-rendered injected
|
|
119
|
+
parts. We only ever **add** a well-formed part and otherwise no-op, so a future
|
|
120
|
+
OpenCode never breaks because of us — but **confirm the block actually surfaces
|
|
121
|
+
to the model in your build**.
|
|
122
|
+
- **The exact `session.idle` payload path is `event.properties.sessionID`** per
|
|
123
|
+
the current contract; confirm it live (a different shape just means no capture
|
|
124
|
+
that turn — still fail-open).
|
|
125
|
+
- **Windows global-config path is unverified.** Project-level `<repo>/.opencode/`
|
|
126
|
+
works cross-OS; the documented global path is `~/.config/opencode`.
|
|
127
|
+
|
|
128
|
+
## Dev / test
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
cd platform/tools/opencode
|
|
132
|
+
bun install
|
|
133
|
+
bun test # all fakes, no network
|
|
134
|
+
bun run typecheck # tsc --noEmit
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`ATLASO_DEBUG=1` writes per-hook logs to `<atlaso dir>/atlaso-opencode-*.log`.
|
|
138
|
+
Env escape hatches: `ATLASO_NO_CONNECT=1` (skip the browser-authorize),
|
|
139
|
+
`ATLASO_SERVER` (point at a non-prod brain), `ATLASO_GLOBAL_PATH` (redirect
|
|
140
|
+
`auth.json` + state for throwaway testing).
|
package/lib/atlaso.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin Atlaso brain client for the Cursor plugin — bun-native, ZERO deps.
|
|
3
|
+
*
|
|
4
|
+
* Reads the SAME `~/.atlaso/auth.json` every Atlaso connector shares
|
|
5
|
+
* ({server, token, user_id, device_id}) and calls the brain's documented REST
|
|
6
|
+
* endpoints over the global `fetch`. The engine stays on the server; this only
|
|
7
|
+
* knows the URLs — the IP thin-client rule, in TypeScript.
|
|
8
|
+
*
|
|
9
|
+
* v1 is ONLINE-FIRST: no local cache / outbox / sync (deferred — see README).
|
|
10
|
+
* Every call is FAIL-OPEN (memory must never break a Cursor turn): callers get
|
|
11
|
+
* `[]` / `false` on any error — never a throw. A REACHED-but-rejected token
|
|
12
|
+
* (HTTP 401/403) is the one authoritative signal: we retire auth.json so the next
|
|
13
|
+
* session re-authorizes (mirrors the Python client's AuthRejected handling).
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync, renameSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
export interface Auth {
|
|
20
|
+
server: string;
|
|
21
|
+
token: string;
|
|
22
|
+
user_id?: string;
|
|
23
|
+
device_id?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RecallResult {
|
|
27
|
+
id?: string;
|
|
28
|
+
content?: string;
|
|
29
|
+
scope?: string;
|
|
30
|
+
has_disagreement?: boolean;
|
|
31
|
+
conflict_peers?: unknown[];
|
|
32
|
+
tags?: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DepositItem {
|
|
36
|
+
client_id: string;
|
|
37
|
+
text: string;
|
|
38
|
+
polarity: string;
|
|
39
|
+
evidence_grade: string;
|
|
40
|
+
scope_note: string | null;
|
|
41
|
+
tags: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const RECALL_TIMEOUT_MS = 8000;
|
|
45
|
+
const DEPOSIT_TIMEOUT_MS = 15000;
|
|
46
|
+
|
|
47
|
+
export function atlasoDir(): string {
|
|
48
|
+
return (
|
|
49
|
+
process.env.ATLASO_GLOBAL_PATH ||
|
|
50
|
+
process.env.ATLASO_PATH ||
|
|
51
|
+
join(homedir(), ".atlaso")
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function authPath(): string {
|
|
56
|
+
return join(atlasoDir(), "auth.json");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function defaultServer(): string {
|
|
60
|
+
return process.env.ATLASO_SERVER || "https://api.atlaso.ai";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** {server, token, user_id, device_id} from auth.json, or null if not connected. */
|
|
64
|
+
export function loadAuth(): Auth | null {
|
|
65
|
+
try {
|
|
66
|
+
const obj = JSON.parse(readFileSync(authPath(), "utf-8"));
|
|
67
|
+
if (obj && typeof obj === "object" && typeof obj.token === "string" && obj.token) {
|
|
68
|
+
return {
|
|
69
|
+
server: typeof obj.server === "string" && obj.server ? obj.server : defaultServer(),
|
|
70
|
+
token: obj.token,
|
|
71
|
+
user_id: obj.user_id,
|
|
72
|
+
device_id: obj.device_id,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
/* not connected / unreadable → offline */
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A reachable brain rejected our token (401/403) → retire auth.json so the next
|
|
82
|
+
* session's sessionStart hook re-runs the device-authorize flow. Non-destructive
|
|
83
|
+
* (renamed, not deleted). Transport errors (offline/5xx) never reach here. */
|
|
84
|
+
export function markRevoked(): void {
|
|
85
|
+
try {
|
|
86
|
+
renameSync(authPath(), authPath() + ".revoked");
|
|
87
|
+
} catch {
|
|
88
|
+
/* already gone / unwritable — best-effort */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One bearer-authed JSON call, hard-bounded by a timeout. null on ANY non-2xx
|
|
93
|
+
* or transport/parse error; 401/403 also retires the (revoked) token. */
|
|
94
|
+
async function call(
|
|
95
|
+
auth: Auth,
|
|
96
|
+
method: string,
|
|
97
|
+
path: string,
|
|
98
|
+
body: unknown,
|
|
99
|
+
timeoutMs: number,
|
|
100
|
+
): Promise<any | null> {
|
|
101
|
+
const ctrl = new AbortController();
|
|
102
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch(auth.server.replace(/\/+$/, "") + path, {
|
|
105
|
+
method,
|
|
106
|
+
headers: {
|
|
107
|
+
Authorization: `Bearer ${auth.token}`,
|
|
108
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
109
|
+
},
|
|
110
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
111
|
+
signal: ctrl.signal,
|
|
112
|
+
});
|
|
113
|
+
if (res.status === 401 || res.status === 403) {
|
|
114
|
+
markRevoked(); // authoritative: token rejected → re-authorize next session
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
if (!res.ok) return null;
|
|
118
|
+
return await res.json();
|
|
119
|
+
} catch {
|
|
120
|
+
return null; // transport/timeout/parse — transient, leave auth.json intact
|
|
121
|
+
} finally {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Smart recall from the server. `project` scopes to personal + this-project.
|
|
127
|
+
* `session` threads the opencode session id through `&session=<id>` so the brain
|
|
128
|
+
* can close the recall-usefulness loop (mirrors the Python client's session
|
|
129
|
+
* threading) — omitted when absent. */
|
|
130
|
+
export async function recall(
|
|
131
|
+
auth: Auth,
|
|
132
|
+
query: string,
|
|
133
|
+
limit = 8,
|
|
134
|
+
project?: string,
|
|
135
|
+
session?: string,
|
|
136
|
+
): Promise<RecallResult[]> {
|
|
137
|
+
const params = new URLSearchParams({ q: query, limit: String(limit) });
|
|
138
|
+
if (project) params.set("project", project);
|
|
139
|
+
if (session) params.set("session", session);
|
|
140
|
+
const data = await call(auth, "GET", `/v1/recall?${params.toString()}`, null, RECALL_TIMEOUT_MS);
|
|
141
|
+
const results = data?.results;
|
|
142
|
+
return Array.isArray(results) ? results : [];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Most-recent deposits (the sessionStart fallback). NOT project-filtered by the
|
|
146
|
+
* server — callers MUST filter with project.visibleInProject before showing them. */
|
|
147
|
+
export async function recent(auth: Auth, limit = 8): Promise<RecallResult[]> {
|
|
148
|
+
const data = await call(auth, "GET", `/v1/memories?limit=${limit}`, null, RECALL_TIMEOUT_MS);
|
|
149
|
+
const deposits = data?.deposits;
|
|
150
|
+
return Array.isArray(deposits) ? deposits : [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Batch deposit (the server re-scrubs + runs the worth-keeping gate). The
|
|
154
|
+
* client_id is the server idempotency key, so a retry never duplicates. */
|
|
155
|
+
export async function deposit(auth: Auth, items: DepositItem[]): Promise<boolean> {
|
|
156
|
+
if (!items.length) return false;
|
|
157
|
+
const data = await call(auth, "POST", "/v1/memories/batch", { items }, DEPOSIT_TIMEOUT_MS);
|
|
158
|
+
return !!data;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** POST /v1/entitlement — this device's tool policy {active_tool, multi_tool,
|
|
162
|
+
* needs_reconnect, in_grace, grace_days_left, tools_connected, ...}. null on a
|
|
163
|
+
* transient error OR a revoked token (call() already retired auth.json on 401/403). */
|
|
164
|
+
export async function entitlementCall(auth: Auth): Promise<any | null> {
|
|
165
|
+
return call(auth, "POST", "/v1/entitlement", null, RECALL_TIMEOUT_MS);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** POST /v1/devices/claim-tool — claim the free active slot for `tool` (no-op if a
|
|
169
|
+
* tool already holds it). Returns {active_tool, multi_tool} or null. */
|
|
170
|
+
export async function claimToolCall(auth: Auth, tool: string): Promise<any | null> {
|
|
171
|
+
return call(auth, "POST", "/v1/devices/claim-tool", { tool }, RECALL_TIMEOUT_MS);
|
|
172
|
+
}
|
package/lib/capture.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Commodity capture heuristics — NOT the IP.
|
|
2
|
+
*
|
|
3
|
+
* Simple regex/string helpers (a chatter gate, scope router, polarity hint,
|
|
4
|
+
* secret scrub) that decide WHETHER and HOW to send a capture to the server.
|
|
5
|
+
* None of the proprietary engine lives here — that stays on the brain. Ported
|
|
6
|
+
* 1:1 from the Python thin client's `_capture.py` so the bun connector keeps the
|
|
7
|
+
* same capture quality. The server re-scrubs + runs the real worth-keeping gate.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ── worth-keeping gate ───────────────────────────────────────────────────────
|
|
11
|
+
const CHATTER =
|
|
12
|
+
/^(?:ok(?:ay)?|k|thx|thanks?|thank you|ty|yes|yep|yeah|yup|no|nope|sure|cool|nice|great|awesome|perfect|lgtm|got it|continue|go ahead|do it|please do|proceed|run it|run the tests?|next|stop|wait|hmm+|ah|oh|nvm|never ?mind)[\s.!?]*$/i;
|
|
13
|
+
const SIGNAL =
|
|
14
|
+
/\b(prefer|always|never|don'?t|do not|avoid|use\b|using|i like|we should|should (?:always|never|use)|remember|note that|going with|decided|rule:|important|make sure|ensure|must\b|need to|require|my .+ is\b|the .+ is\b)\b/i;
|
|
15
|
+
const MIN_WORDS = 4;
|
|
16
|
+
|
|
17
|
+
export function shouldDeposit(userText: string): [boolean, string] {
|
|
18
|
+
const t = (userText || "").trim();
|
|
19
|
+
if (!t) return [false, "empty"];
|
|
20
|
+
if (CHATTER.test(t)) return [false, "chatter"];
|
|
21
|
+
if (SIGNAL.test(t)) return [true, "signal"];
|
|
22
|
+
if (t.split(/\s+/).length < MIN_WORDS) return [false, "too_short"];
|
|
23
|
+
return [true, "substantive"];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function heuristicPolarity(userText: string): string {
|
|
27
|
+
const t = (userText || "").toLowerCase();
|
|
28
|
+
if (/\b(never|don'?t|do not|avoid|stop|doesn'?t work|didn'?t work|fails?|failed|broke|broken|bug|wrong|bad)\b/.test(t))
|
|
29
|
+
return "cautionary";
|
|
30
|
+
if (/\b(prefer|always|use|like|love|want|should|works?|good)\b/.test(t)) return "positive";
|
|
31
|
+
return "open";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── scope router (personal/global vs project) ────────────────────────────────
|
|
35
|
+
const PERSONAL =
|
|
36
|
+
/\b(i (?:prefer|like|love|always|usually|tend to|never|hate|avoid)\b|my (?:favou?rite|preferred|default|usual|go-?to|style|setup|workflow)\b|for all my (?:projects|repos)\b|i'?m a .*?(?:person|developer|engineer)\b)/i;
|
|
37
|
+
const PROJECT =
|
|
38
|
+
/(?:\b(?:this (?:project|repo|codebase|app|service)|in this (?:repo|project)|the (?:server|database|db|api|endpoint|service|build|deploy(?:ment)?|schema)\b|localhost|127\.0\.0\.1|\b\d{1,3}(?:\.\d{1,3}){3}\b)|\/[\w.\-]+\/[\w./\-]+)/i;
|
|
39
|
+
|
|
40
|
+
export function classifyScope(userText: string): string {
|
|
41
|
+
const t = userText || "";
|
|
42
|
+
if (PROJECT.test(t)) return "project";
|
|
43
|
+
if (PERSONAL.test(t)) return "personal";
|
|
44
|
+
return "project"; // default: contain locally rather than pollute global
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildContent(userText: string, asstText: string): string {
|
|
48
|
+
let content = (userText || "").trim();
|
|
49
|
+
const a = (asstText || "").trim();
|
|
50
|
+
if (a) content += `\n\n(assistant: ${a.slice(0, 400)})`;
|
|
51
|
+
return content.trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── secret scrub (defense-in-depth; the server re-scrubs too) ────────────────
|
|
55
|
+
type Rule = { kind: string; re: RegExp };
|
|
56
|
+
const PATTERNS: Rule[] = [
|
|
57
|
+
{ kind: "private_key", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
|
|
58
|
+
{ kind: "openai_anthropic_key", re: /\bsk-[A-Za-z0-9_-]{16,}\b/g },
|
|
59
|
+
{ kind: "github_token", re: /\b(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}\b/g },
|
|
60
|
+
{ kind: "aws_access_key", re: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
61
|
+
{ kind: "google_api_key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
62
|
+
{ kind: "slack_token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
|
|
63
|
+
{ kind: "uri_credential", re: /\b([a-zA-Z][a-zA-Z0-9+.\-]*:\/\/[^\s:/@]*):([^\s/@]+)@/g },
|
|
64
|
+
{ kind: "jwt", re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
|
|
65
|
+
{ kind: "bearer", re: /\bbearer\s+[A-Za-z0-9._\-]{16,}/gi },
|
|
66
|
+
{
|
|
67
|
+
// key looks secret-y → redact its value whether unquoted OR quoted (quoted
|
|
68
|
+
// values may contain spaces, e.g. PASSWORD="correct horse battery staple").
|
|
69
|
+
kind: "assignment",
|
|
70
|
+
re: /\b([A-Za-z0-9_]*(?:api[_-]?key|secret|token|password|passwd|pwd|access[_-]?key|client[_-]?secret|auth[_-]?token)[A-Za-z0-9_]*)\s*[:=]\s*(?:"[^"]{2,}"|'[^']{2,}'|[^\s"']{6,})/gi,
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
const BLOB = /\b[A-Za-z0-9+/=_-]{32,}\b/g;
|
|
74
|
+
const ENTROPY_THRESHOLD = 4.2;
|
|
75
|
+
|
|
76
|
+
function entropy(s: string): number {
|
|
77
|
+
if (!s) return 0;
|
|
78
|
+
const n = s.length;
|
|
79
|
+
const counts = new Map<string, number>();
|
|
80
|
+
for (const c of s) counts.set(c, (counts.get(c) || 0) + 1);
|
|
81
|
+
let e = 0;
|
|
82
|
+
for (const c of counts.values()) e -= (c / n) * Math.log2(c / n);
|
|
83
|
+
return e;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Returns [scrubbed, kindsFound]. */
|
|
87
|
+
export function scrub(text: string): [string, string[]] {
|
|
88
|
+
if (!text) return [text, []];
|
|
89
|
+
const found: string[] = [];
|
|
90
|
+
let out = text;
|
|
91
|
+
for (const { kind, re } of PATTERNS) {
|
|
92
|
+
out = out.replace(re, (...args: any[]) => {
|
|
93
|
+
const groups = args.slice(1, -2); // capture groups (drop offset + whole string)
|
|
94
|
+
found.push(kind);
|
|
95
|
+
if (kind === "assignment") return `${groups[0]}=[REDACTED]`; // keep the key name
|
|
96
|
+
if (kind === "uri_credential") return `${groups[0]}:[REDACTED]@`; // keep scheme+user+host
|
|
97
|
+
return `[REDACTED:${kind}]`;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
out = out.replace(BLOB, (tok: string) => {
|
|
101
|
+
if (tok.includes("REDACTED")) return tok;
|
|
102
|
+
if (entropy(tok) >= ENTROPY_THRESHOLD) {
|
|
103
|
+
found.push("high_entropy");
|
|
104
|
+
return "[REDACTED:high_entropy]";
|
|
105
|
+
}
|
|
106
|
+
return tok;
|
|
107
|
+
});
|
|
108
|
+
return [out, found];
|
|
109
|
+
}
|