@atlaso-labs/opencode 0.1.1 → 0.2.1

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/mcp.ts ADDED
@@ -0,0 +1,300 @@
1
+ /** Atlaso memory MCP server for OpenCode — inline, zero-dep, bun-native.
2
+ *
3
+ * Speaks JSON-RPC 2.0 over newline-delimited stdio (the MCP stdio transport) and
4
+ * exposes the 5 memory tools — recall / remember / forget / recent / status — that
5
+ * the OTHER Atlaso connectors ship. OpenCode launches it from its opencode.json `mcp`
6
+ * key as a `type:"local"` server: `bun run <install-dir>/atlaso-mcp.js`.
7
+ *
8
+ * WHY inline stdio (not remote): the plugin's hooks already mint this device's OWN
9
+ * per-tool credential at ~/.atlaso/tools/opencode.json, and this server reuses it via
10
+ * resolveCredential("opencode"). So capture (hooks) AND the MCP tools authorize with
11
+ * the SAME token — one credential, one unlink, no second OAuth consent. It works
12
+ * offline-first and never leaks the engine (thin client — it only knows the brain's URLs).
13
+ *
14
+ * STDOUT IS THE PROTOCOL — nothing but JSON-RPC frames may be written there. All
15
+ * diagnostics go to the debug file (lib/log). One complete JSON object per line.
16
+ */
17
+ import { forget, health, loadAuth, recall, recent, remember } from "./atlaso";
18
+ import { classifyScope } from "./capture";
19
+ import { resolveCredential } from "./credential";
20
+ import { cloudMode, online } from "./entitlement";
21
+ import { REVOKED } from "./state";
22
+ import { log } from "./log";
23
+ import { currentProjectKey, currentProjectResolution, resultVisibleHere } from "./project";
24
+
25
+ const NAME = "Atlaso";
26
+ const VERSION = "0.1.0";
27
+ const PROTOCOL = "2024-11-05";
28
+ const TOOL = "opencode";
29
+
30
+ // The 5-tool surface. Descriptions are the model's only cue for WHEN to call each —
31
+ // keep them action-first and lean (the ~40-tool budget rewards brevity).
32
+ export const TOOLS = [
33
+ {
34
+ name: "recall",
35
+ description:
36
+ "Search the user's Atlaso long-term memory for notes relevant to `query` — past decisions, preferences, conventions, project facts, gotchas. Call it before answering when prior context would help. Read-only.",
37
+ inputSchema: {
38
+ type: "object",
39
+ properties: { query: { type: "string" }, limit: { type: "integer", default: 5 } },
40
+ required: ["query"],
41
+ },
42
+ },
43
+ {
44
+ name: "remember",
45
+ description:
46
+ "Save a durable fact, decision, preference, or gotcha to Atlaso memory. Project facts stay in the current project; personal preferences remain available across projects. Use for things worth keeping, not transient chatter.",
47
+ inputSchema: {
48
+ type: "object",
49
+ properties: {
50
+ text: { type: "string" },
51
+ scope: {
52
+ type: "string",
53
+ enum: ["personal", "project"],
54
+ description: "Optional override. Omit to infer personal vs project from the text.",
55
+ },
56
+ },
57
+ required: ["text"],
58
+ },
59
+ },
60
+ {
61
+ name: "forget",
62
+ description:
63
+ "Permanently delete a memory by its id (ids come from recall/recent). Destructive and not undoable — only when the user asks to forget something.",
64
+ inputSchema: {
65
+ type: "object",
66
+ properties: { id: { type: "string" } },
67
+ required: ["id"],
68
+ },
69
+ },
70
+ {
71
+ name: "recent",
72
+ description: "List the user's most recent memories (newest first). Read-only.",
73
+ inputSchema: {
74
+ type: "object",
75
+ properties: { limit: { type: "integer", default: 10 } },
76
+ },
77
+ },
78
+ {
79
+ name: "status",
80
+ description:
81
+ "Atlaso memory health: connected?, how many memories are stored, and the memory health score (FMI). Read-only.",
82
+ inputSchema: { type: "object", properties: {} },
83
+ },
84
+ ] as const;
85
+
86
+ const NOT_LINKED =
87
+ "Atlaso memory isn't linked on this device yet. Start a OpenCode chat (the plugin links automatically) or run `atlaso connect`.";
88
+
89
+
90
+ /** Run one tool. Gates on the SAME entitlement/tombstone check the hooks use
91
+ * (`online()`) BEFORE resolving a credential — otherwise a revoked or free-plan-
92
+ * gated tool could resurrect on the shared bearer through an MCP call (the hooks
93
+ * never can, because they gate first). Then resolves THIS device's per-tool
94
+ * credential so every call authorizes as the cursor tool. */
95
+ export async function dispatch(name: string, args: any): Promise<any> {
96
+ const shared = loadAuth();
97
+ if (!shared?.token) return { error: NOT_LINKED };
98
+ const deviceId = shared.device_id ?? null;
99
+ // The verified-verdict gate: revoked → stay down (sticky); free-plan non-active →
100
+ // local-only. Never resurrect a removed tool via the shared bearer.
101
+ if (!(await online(shared, { tool: TOOL, deviceId: deviceId }))) {
102
+ const mode = cloudMode(shared, { tool: TOOL, deviceId: deviceId });
103
+ return {
104
+ error:
105
+ mode.reason === REVOKED
106
+ ? "Atlaso memory was removed for OpenCode on this device. Re-add the plugin (or run `atlaso connect`) to turn it back on."
107
+ : "Atlaso memory is local-only for OpenCode right now — on the free plan only one tool per device is active. Upgrade or switch the active tool at https://app.atlaso.ai.",
108
+ };
109
+ }
110
+ const auth = await resolveCredential(TOOL);
111
+ if (!auth) return { error: NOT_LINKED };
112
+ const project = currentProjectKey();
113
+ switch (name) {
114
+ case "recall": {
115
+ const limit = Math.max(1, Math.min(50, Number(args?.limit ?? 5) || 5));
116
+ const results = await recall(
117
+ auth,
118
+ String(args?.query ?? ""),
119
+ limit,
120
+ project ?? undefined,
121
+ );
122
+ return {
123
+ results: results
124
+ .filter((r) => resultVisibleHere(r, project))
125
+ .slice(0, limit)
126
+ .map((r) => ({ id: r.id, content: r.content })),
127
+ };
128
+ }
129
+ case "recent": {
130
+ const limit = Math.max(1, Math.min(50, Number(args?.limit ?? 10) || 10));
131
+ // `/v1/memories` is global/newest-first, so over-fetch before filtering or a
132
+ // run of foreign-project rows could crowd every visible memory out of the page.
133
+ const fetchLimit = Math.min(200, Math.max(limit * 4, 40));
134
+ const memories = (await recent(auth, fetchLimit))
135
+ .filter((r) => resultVisibleHere(r, project))
136
+ .slice(0, limit)
137
+ .map((r) => ({ id: r.id, content: r.content }));
138
+ return { memories };
139
+ }
140
+ case "remember": {
141
+ const text = String(args?.text ?? "");
142
+ const requested = args?.scope === "personal" || args?.scope === "project"
143
+ ? args.scope
144
+ : null;
145
+ const scope = requested ?? classifyScope(text);
146
+ // NEVER refuse a deliberate save because we could not name the project.
147
+ // classifyScope defaults to "project", and this is a standalone MCP process
148
+ // with no hook payload and an arbitrary cwd, so currentProjectKey() is null
149
+ // more often than not — refusing meant "remember this" routinely failed on
150
+ // the user's highest-intent memory. Instead mark it unattributed and let it
151
+ // be visible everywhere: the server already treats a project-scoped row with
152
+ // no key as visible-with-provenance, which is the same fail-open rule
153
+ // auto-capture uses. Losing the scope is recoverable; losing the memory is
154
+ // not. (Bugbot #157, "Remember fails without project key".)
155
+ // 'none' and 'unknown' are NOT the same and must not be collapsed. A
156
+ // genuinely non-project root ($HOME) is real personal scope; a garbage
157
+ // measurement stays project-scoped but unattributed. Auto-capture already
158
+ // splits them, and claiming parity while collapsing them would be a lie.
159
+ let effScope = scope;
160
+ if (scope === "project" && !project && currentProjectResolution().status === "none") {
161
+ effScope = "personal";
162
+ }
163
+ const tags: string[] = [`scope:${effScope}`];
164
+ if (effScope === "project") {
165
+ if (project) tags.push(`project:${project}`);
166
+ else tags.push("project-unknown");
167
+ }
168
+ const id = await remember(auth, { text, tags });
169
+ if (!id) return { saved: false, error: "empty text, or the server was unreachable" };
170
+ return effScope === "project" && !project
171
+ ? { saved: true, id, scope: effScope, note: "saved without a project key — this project could not be identified, so the memory is visible everywhere" }
172
+ : { saved: true, id, scope: effScope };
173
+ }
174
+ case "forget": {
175
+ const id = String(args?.id ?? "");
176
+ const ok = await forget(auth, id);
177
+ return ok
178
+ ? { forgotten: true, id }
179
+ : { forgotten: false, id, note: "not forgotten — the server was unreachable. Try again when connected." };
180
+ }
181
+ case "status": {
182
+ const h = await health(auth);
183
+ if (!h) return { connected: false, error: "Atlaso memory is unreachable right now. Try again when connected." };
184
+ return { connected: true, fmi: h?.fmi ?? null, total: h?.deposit_count ?? null };
185
+ }
186
+ default:
187
+ throw new Error(`unknown tool: ${name}`);
188
+ }
189
+ }
190
+
191
+ // ── JSON-RPC 2.0 plumbing ────────────────────────────────────────────────────────
192
+ const ok = (id: any, result: any) => ({ jsonrpc: "2.0", id, result });
193
+ const rpcErr = (id: any, code: number, message: string) => ({ jsonrpc: "2.0", id, error: { code, message } });
194
+
195
+ /** A dispatch payload that represents a tool-level failure — so tools/call can set the
196
+ * MCP `isError` bit while STILL returning the readable JSON the model can act on. */
197
+ function isFailure(r: any): boolean {
198
+ return !!(r && (r.error || r.saved === false || r.forgotten === false));
199
+ }
200
+
201
+ /** Handle one JSON-RPC message. Returns the response object, or null for a
202
+ * notification (no id) that needs no reply. Never throws. */
203
+ export async function handle(msg: any): Promise<any | null> {
204
+ const { id, method, params } = msg ?? {};
205
+ // JSON-RPC: a message with no id is a NOTIFICATION — NEVER reply to one. Replying
206
+ // would put an id-less frame on stdout and corrupt the transport. (This also covers
207
+ // notifications/initialized and any progress/cancelled notifications.)
208
+ if (id === undefined || id === null) return null;
209
+
210
+ switch (method) {
211
+ case "initialize":
212
+ return ok(id, {
213
+ // Advertise the version we actually implement — do NOT echo the client's
214
+ // requested version (that would claim support for a protocol we may not run).
215
+ protocolVersion: PROTOCOL,
216
+ capabilities: { tools: { listChanged: false } },
217
+ serverInfo: { name: NAME, version: VERSION },
218
+ });
219
+ case "ping":
220
+ return ok(id, {});
221
+ case "tools/list":
222
+ return ok(id, { tools: TOOLS });
223
+ case "tools/call": {
224
+ try {
225
+ const result = await dispatch(params?.name, params?.arguments ?? {});
226
+ // Tool-level problems ride back as readable content so the model can act on
227
+ // them, AND flag isError so a client keying off the bit doesn't read a failure
228
+ // as success.
229
+ return ok(id, {
230
+ content: [{ type: "text", text: JSON.stringify(result) }],
231
+ ...(isFailure(result) ? { isError: true } : {}),
232
+ });
233
+ } catch (e) {
234
+ return ok(id, {
235
+ content: [{ type: "text", text: JSON.stringify({ error: e instanceof Error ? e.message : String(e) }) }],
236
+ isError: true,
237
+ });
238
+ }
239
+ }
240
+ default:
241
+ return rpcErr(id, -32601, `method not found: ${method}`);
242
+ }
243
+ }
244
+
245
+ // Handlers may finish concurrently, but stdout is one byte stream. Serialize complete
246
+ // frames (including backpressure) so large overlapping responses cannot interleave.
247
+ let writeQueue: Promise<void> = Promise.resolve();
248
+ function writeFrame(resp: any): Promise<void> {
249
+ const frame = JSON.stringify(resp) + "\n";
250
+ const write = () => new Promise<void>((resolve, reject) => {
251
+ process.stdout.write(frame, (err) => err ? reject(err) : resolve());
252
+ });
253
+ const task = writeQueue.then(write, write);
254
+ writeQueue = task.catch(() => {});
255
+ return task;
256
+ }
257
+
258
+ /** Read newline-delimited JSON-RPC from stdin, write responses to stdout. Handlers
259
+ * run concurrently (no head-of-line blocking); frame writes are serialized. */
260
+ async function main(): Promise<void> {
261
+ const decoder = new TextDecoder();
262
+ let buf = "";
263
+ const inflight = new Set<Promise<void>>(); // drained on EOF so no reply is lost
264
+ log("mcp", "server up");
265
+ for await (const chunk of Bun.stdin.stream()) {
266
+ buf += decoder.decode(chunk as Uint8Array, { stream: true });
267
+ let nl: number;
268
+ while ((nl = buf.indexOf("\n")) >= 0) {
269
+ const line = buf.slice(0, nl).trim();
270
+ buf = buf.slice(nl + 1);
271
+ if (!line) continue;
272
+ let msg: any;
273
+ try {
274
+ msg = JSON.parse(line);
275
+ } catch {
276
+ continue; // a malformed line is not a protocol frame — drop it
277
+ }
278
+ // Handlers run concurrently (no head-of-line blocking), but we keep a handle
279
+ // on each so EOF can drain them — otherwise the process could exit before the
280
+ // last frame's reply is written.
281
+ const p = handle(msg)
282
+ .then(async (resp) => {
283
+ if (resp) await writeFrame(resp);
284
+ })
285
+ .catch((e) => {
286
+ if (msg?.id != null) return writeFrame(rpcErr(msg.id, -32603, String(e)));
287
+ })
288
+ .finally(() => inflight.delete(p));
289
+ inflight.add(p);
290
+ }
291
+ }
292
+ await Promise.allSettled(inflight); // stdin closed — let pending replies flush
293
+ }
294
+
295
+ if (import.meta.main) {
296
+ main().catch((e) => {
297
+ log("mcp", `fatal ${e}`);
298
+ process.exit(1);
299
+ });
300
+ }
package/lib/outbox.ts ADDED
@@ -0,0 +1,348 @@
1
+ /** Durable write-ahead outbox for cloud deposits.
2
+ *
3
+ * WHY THIS EXISTS. v1 of this connector was online-first: `capture` built an item,
4
+ * POSTed it once, and on ANY failure — timeout, 500, 429, a wifi blip, a brain
5
+ * restart mid-deploy — logged `saved=false` and moved on. The memory was gone, and
6
+ * neither the user nor we would ever know. The four Python connectors never had
7
+ * this hole because they sit on `atlaso_client`'s SQLite cache + outbox. This is
8
+ * the same guarantee for the two TypeScript connectors, in the shape their runtime
9
+ * allows.
10
+ *
11
+ * THE HARD CONSTRAINT. opencode plugin callbacks are short-lived too — a hook
12
+ * event spawns a process that exits. There is no daemon and no timer, so a retry
13
+ * can only ever be driven by on-disk state that a LATER hook invocation picks up.
14
+ * Everything here is therefore synchronous file I/O with no in-memory state.
15
+ *
16
+ * WRITE-AHEAD, NOT WRITE-BEHIND. The item is persisted BEFORE the network call,
17
+ * not after it fails. A process killed mid-request (editor quit, machine sleep,
18
+ * hook timeout) has already durably recorded the memory. Enqueue-then-send is the
19
+ * only ordering that survives the process dying inside `fetch`.
20
+ *
21
+ * STORAGE: one file per item, written tmp+rename so a reader never sees a partial
22
+ * record and a torn write cannot corrupt the queue. Chosen over a single JSONL log
23
+ * because two opencode sessions are two concurrent plugin processes appending to the
24
+ * same file — an append race on records larger than PIPE_BUF interleaves and
25
+ * destroys both. Per-file also makes quarantine a rename and bounds a readdir.
26
+ * Chosen over `node:sqlite` because these plugins run on Bun, and over `bun:sqlite`
27
+ * because a file-drop plugin must not depend on one runtime's builtin.
28
+ *
29
+ * DEDUPE: the filename is a hash of `client_id`, which is already the server's
30
+ * per-item idempotency key (content-derived in capture.ts). Re-enqueueing the same
31
+ * turn OVERWRITES rather than duplicating, and a retry of an ambiguous timeout can
32
+ * never produce a double memory server-side.
33
+ *
34
+ * NOTHING IS EVER SILENTLY DROPPED. An item the server will always reject, or one
35
+ * that exhausts its attempts, is QUARANTINED — moved aside and recorded in a
36
+ * ledger — never deleted. That is a product rule, not an implementation detail.
37
+ */
38
+ import { createHash } from "node:crypto";
39
+ import {
40
+ closeSync,
41
+ existsSync,
42
+ fsyncSync,
43
+ mkdirSync,
44
+ openSync,
45
+ readdirSync,
46
+ readFileSync,
47
+ renameSync,
48
+ statSync,
49
+ unlinkSync,
50
+ writeFileSync,
51
+ appendFileSync,
52
+ } from "node:fs";
53
+ import { join } from "node:path";
54
+ import { atlasoDir, type DepositItem } from "./atlaso";
55
+
56
+ /** Bounds are read PER CALL, not at module load — the same convention lock.ts uses
57
+ * for its timeout ("read per-call so it stays env-overridable for tests"). Module-
58
+ * load constants would force tests to re-import the module under a different
59
+ * specifier to change them, which is neither type-safe nor honest about the
60
+ * runtime behaviour we actually ship. */
61
+ function num(env: string, dflt: number): number {
62
+ const n = parseInt(process.env[env] ?? "", 10);
63
+ return Number.isFinite(n) && n > 0 ? n : dflt;
64
+ }
65
+
66
+ /** How many items one drain pass will attempt. Bounds the wall-clock a hook can
67
+ * spend so a large backlog never stalls the editor; the rest go next hook. */
68
+ export const maxDrainPerRun = () => num("ATLASO_OUTBOX_DRAIN", 25);
69
+ /** Hard queue ceiling. Beyond this the OLDEST are quarantined (not dropped). */
70
+ export const maxQueue = () => num("ATLASO_OUTBOX_MAX", 5000);
71
+ /** An item older than this has almost certainly outlived its usefulness, but it is
72
+ * still quarantined rather than deleted so it can be recovered/inspected. */
73
+ export const maxAgeMs = () => num("ATLASO_OUTBOX_MAX_AGE_MS", 30 * 24 * 3600 * 1000);
74
+ /** Attempts before we stop retrying and quarantine. Generous: transient brain
75
+ * outages should not burn through this in one bad afternoon. */
76
+ export const maxAttempts = () => num("ATLASO_OUTBOX_MAX_ATTEMPTS", 25);
77
+
78
+ export interface OutboxRecord {
79
+ client_id: string;
80
+ item: DepositItem;
81
+ enqueued_at: number;
82
+ attempts: number;
83
+ last_error?: string;
84
+ }
85
+
86
+ /** What a push attempt concluded about ONE item. Drives whether it leaves the
87
+ * queue, stays for a retry, or is parked forever. */
88
+ export type Disposition = "settled" | "retry" | "quarantine";
89
+
90
+ export function outboxDir(tool: string): string {
91
+ return join(atlasoDir(), "outbox", tool);
92
+ }
93
+ export function quarantineDir(tool: string): string {
94
+ return join(outboxDir(tool), "quarantine");
95
+ }
96
+ function ledgerPath(tool: string): string {
97
+ return join(outboxDir(tool), "quarantine.log");
98
+ }
99
+
100
+ /** sha256 of the idempotency key — filesystem-safe, collision-free in practice, and
101
+ * deterministic so the same turn always maps to the same file (dedupe). */
102
+ function fileFor(clientId: string): string {
103
+ return createHash("sha256").update(clientId).digest("hex").slice(0, 32) + ".json";
104
+ }
105
+
106
+ function ensureDir(p: string): boolean {
107
+ try {
108
+ mkdirSync(p, { recursive: true, mode: 0o700 });
109
+ return true;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ /** Atomic single-file write: unique temp in the SAME directory (so rename is a
116
+ * cheap intra-filesystem move), fsync the bytes, then rename over the target.
117
+ * A reader therefore sees either the old record or the new one, never a splice. */
118
+ function writeAtomic(path: string, body: string): boolean {
119
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
120
+ let fd: number | null = null;
121
+ try {
122
+ writeFileSync(tmp, body, { mode: 0o600 });
123
+ try {
124
+ fd = openSync(tmp, "r+");
125
+ fsyncSync(fd); // durability: survive a machine crash, not just a process exit
126
+ } catch {
127
+ /* fsync unavailable/unsupported — the rename below is still atomic */
128
+ } finally {
129
+ if (fd !== null) {
130
+ try {
131
+ closeSync(fd);
132
+ } catch {
133
+ /* already closed */
134
+ }
135
+ }
136
+ }
137
+ renameSync(tmp, path);
138
+ return true;
139
+ } catch {
140
+ try {
141
+ unlinkSync(tmp);
142
+ } catch {
143
+ /* temp already gone */
144
+ }
145
+ return false;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Persist an item BEFORE it is sent. Idempotent on `client_id`: enqueueing the
151
+ * same turn twice (Cursor fires stop AND sessionEnd for one turn) overwrites.
152
+ * Returns false only if the disk itself is unusable — the caller still attempts
153
+ * the network, so a read-only home directory degrades to today's behaviour rather
154
+ * than blocking capture.
155
+ */
156
+ export function enqueue(tool: string, item: DepositItem): boolean {
157
+ const dir = outboxDir(tool);
158
+ if (!ensureDir(dir)) return false;
159
+ const existing = readRecord(join(dir, fileFor(item.client_id)));
160
+ const rec: OutboxRecord = {
161
+ client_id: item.client_id,
162
+ item,
163
+ // Preserve the ORIGINAL enqueue time across re-enqueues so age bounds measure
164
+ // how long the memory has been stranded, not when we last saw the turn.
165
+ enqueued_at: existing?.enqueued_at ?? Date.now(),
166
+ attempts: existing?.attempts ?? 0,
167
+ };
168
+ return writeAtomic(join(dir, fileFor(item.client_id)), JSON.stringify(rec));
169
+ }
170
+
171
+ function readRecord(path: string): OutboxRecord | null {
172
+ try {
173
+ const rec = JSON.parse(readFileSync(path, "utf8")) as OutboxRecord;
174
+ if (!rec || typeof rec.client_id !== "string" || !rec.item) return null;
175
+ if (typeof rec.attempts !== "number") rec.attempts = 0;
176
+ if (typeof rec.enqueued_at !== "number") rec.enqueued_at = Date.now();
177
+ return rec;
178
+ } catch {
179
+ return null; // missing, unparseable, or truncated — caller decides
180
+ }
181
+ }
182
+
183
+ /** True if anything is waiting. Deliberately cheap (one readdir, no parsing) so it
184
+ * can be called from the latency-sensitive prompt path without cost. */
185
+ export function hasPending(tool: string): boolean {
186
+ try {
187
+ for (const f of readdirSync(outboxDir(tool))) if (f.endsWith(".json")) return true;
188
+ } catch {
189
+ /* no dir yet */
190
+ }
191
+ return false;
192
+ }
193
+
194
+ /**
195
+ * Oldest-first slice of the queue, capped at `limit`.
196
+ *
197
+ * A file that will not parse is QUARANTINED on sight rather than skipped: left in
198
+ * place it would be re-read and re-fail on every single drain, forever.
199
+ */
200
+ export function pending(tool: string, limit = maxDrainPerRun()): OutboxRecord[] {
201
+ const dir = outboxDir(tool);
202
+ let names: string[];
203
+ try {
204
+ names = readdirSync(dir).filter((f) => f.endsWith(".json"));
205
+ } catch {
206
+ return [];
207
+ }
208
+ const recs: OutboxRecord[] = [];
209
+ for (const name of names) {
210
+ const rec = readRecord(join(dir, name));
211
+ if (rec) recs.push(rec);
212
+ else quarantineFile(tool, name, "unreadable");
213
+ }
214
+ recs.sort((a, b) => a.enqueued_at - b.enqueued_at);
215
+ return recs.slice(0, limit);
216
+ }
217
+
218
+ /** Item accepted (or already known) by the server — remove it. */
219
+ export function settle(tool: string, clientId: string): void {
220
+ try {
221
+ unlinkSync(join(outboxDir(tool), fileFor(clientId)));
222
+ } catch {
223
+ /* already gone — settling twice is fine */
224
+ }
225
+ }
226
+
227
+ /** Record a failed attempt. Quarantines once attempts are exhausted, so a
228
+ * permanently-poisoned item can never wedge the queue ahead of good ones. */
229
+ export function bumpAttempt(tool: string, rec: OutboxRecord, error: string): Disposition {
230
+ const next: OutboxRecord = { ...rec, attempts: rec.attempts + 1, last_error: error.slice(0, 200) };
231
+ if (next.attempts >= maxAttempts()) {
232
+ quarantine(tool, rec, `max attempts (${next.attempts}): ${error}`);
233
+ return "quarantine";
234
+ }
235
+ writeAtomic(join(outboxDir(tool), fileFor(rec.client_id)), JSON.stringify(next));
236
+ return "retry";
237
+ }
238
+
239
+ /** Park an item permanently, with a reason, in a place a human can find it.
240
+ * NEVER deletes: the founder's rule is that a user's memory is never silently
241
+ * lost, and "we gave up" is exactly the case where that rule earns its keep. */
242
+ export function quarantine(tool: string, rec: OutboxRecord, reason: string): void {
243
+ quarantineFile(tool, fileFor(rec.client_id), reason);
244
+ }
245
+
246
+ function quarantineFile(tool: string, name: string, reason: string): void {
247
+ const qdir = quarantineDir(tool);
248
+ if (!ensureDir(qdir)) return;
249
+ const from = join(outboxDir(tool), name);
250
+ try {
251
+ renameSync(from, join(qdir, name));
252
+ } catch {
253
+ return; // vanished under us — nothing to park
254
+ }
255
+ try {
256
+ appendFileSync(
257
+ ledgerPath(tool),
258
+ JSON.stringify({ at: new Date().toISOString(), file: name, reason: reason.slice(0, 300) }) + "\n",
259
+ { mode: 0o600 },
260
+ );
261
+ } catch {
262
+ /* the ledger is diagnostics; losing a line must not fail the quarantine */
263
+ }
264
+ }
265
+
266
+ /** Count of parked items — surfaced by `atlaso status` so this is visible, not
267
+ * a silent graveyard. */
268
+ export function quarantineCount(tool: string): number {
269
+ try {
270
+ return readdirSync(quarantineDir(tool)).filter((f) => f.endsWith(".json")).length;
271
+ } catch {
272
+ return 0;
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Keep the queue bounded. Over-age and over-count items are QUARANTINED, oldest
278
+ * first — never unlinked. Runs before a drain so bounds are enforced even if the
279
+ * network has been down for a month.
280
+ */
281
+ export function enforceBounds(tool: string, now = Date.now()): number {
282
+ const dir = outboxDir(tool);
283
+ let names: string[];
284
+ try {
285
+ names = readdirSync(dir).filter((f) => f.endsWith(".json"));
286
+ } catch {
287
+ return 0;
288
+ }
289
+ let parked = 0;
290
+ const aged: Array<{ name: string; at: number }> = [];
291
+ for (const name of names) {
292
+ const rec = readRecord(join(dir, name));
293
+ let at: number;
294
+ if (rec) at = rec.enqueued_at;
295
+ else {
296
+ // Unreadable: fall back to mtime so it still participates in bounds.
297
+ try {
298
+ at = statSync(join(dir, name)).mtimeMs;
299
+ } catch {
300
+ continue;
301
+ }
302
+ }
303
+ if (now - at > maxAgeMs()) {
304
+ quarantineFile(tool, name, "max age exceeded");
305
+ parked++;
306
+ } else {
307
+ aged.push({ name, at });
308
+ }
309
+ }
310
+ const cap = maxQueue();
311
+ if (aged.length > cap) {
312
+ aged.sort((a, b) => a.at - b.at);
313
+ for (const { name } of aged.slice(0, aged.length - cap)) {
314
+ quarantineFile(tool, name, "queue over capacity");
315
+ parked++;
316
+ }
317
+ }
318
+ return parked;
319
+ }
320
+
321
+ /** Present only so tests can start from a known state. */
322
+ export function _resetForTests(tool: string): void {
323
+ for (const dir of [quarantineDir(tool), outboxDir(tool)]) {
324
+ try {
325
+ for (const f of readdirSync(dir)) {
326
+ try {
327
+ unlinkSync(join(dir, f));
328
+ } catch {
329
+ /* a subdirectory (quarantine/) — skip */
330
+ }
331
+ }
332
+ } catch {
333
+ /* absent */
334
+ }
335
+ }
336
+ }
337
+
338
+ export function _ledgerPathForTests(tool: string): string {
339
+ return ledgerPath(tool);
340
+ }
341
+
342
+ export function _existsForTests(tool: string, clientId: string): boolean {
343
+ return existsSync(join(outboxDir(tool), fileFor(clientId)));
344
+ }
345
+
346
+ export function _quarantinedForTests(tool: string, clientId: string): boolean {
347
+ return existsSync(join(quarantineDir(tool), fileFor(clientId)));
348
+ }