@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 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
+ }