@juspay/neurolink 12.0.5 → 12.2.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/dist/agent/agentToolRegistrar.d.ts +30 -0
  3. package/dist/agent/agentToolRegistrar.js +72 -18
  4. package/dist/agent/backgroundCommands.d.ts +110 -0
  5. package/dist/agent/backgroundCommands.js +914 -0
  6. package/dist/agent/backgroundDelegation.d.ts +87 -0
  7. package/dist/agent/backgroundDelegation.js +753 -0
  8. package/dist/agent/gitTools.d.ts +43 -0
  9. package/dist/agent/gitTools.js +618 -0
  10. package/dist/agent/taskChecklist.d.ts +58 -0
  11. package/dist/agent/taskChecklist.js +322 -0
  12. package/dist/artifacts/artifactBanking.d.ts +57 -0
  13. package/dist/artifacts/artifactBanking.js +123 -0
  14. package/dist/artifacts/artifactStore.d.ts +36 -8
  15. package/dist/artifacts/artifactStore.js +164 -13
  16. package/dist/browser/neurolink.min.js +442 -414
  17. package/dist/cli/commands/setup.js +2 -1
  18. package/dist/constants/enums.d.ts +19 -0
  19. package/dist/constants/enums.js +20 -0
  20. package/dist/factories/providerDescriptors.js +16 -1
  21. package/dist/models/manifestRegistry.js +2 -0
  22. package/dist/models/manifests/cerebras.d.ts +9 -0
  23. package/dist/models/manifests/cerebras.js +19 -0
  24. package/dist/neurolink.d.ts +294 -3
  25. package/dist/neurolink.js +447 -4
  26. package/dist/providers/openaiCompatCatalog.d.ts +1 -1
  27. package/dist/providers/openaiCompatCatalog.js +34 -3
  28. package/dist/types/artifact.d.ts +54 -0
  29. package/dist/types/backgroundCommand.d.ts +174 -0
  30. package/dist/types/backgroundCommand.js +22 -0
  31. package/dist/types/delegation.d.ts +178 -0
  32. package/dist/types/delegation.js +18 -0
  33. package/dist/types/gitTools.d.ts +69 -0
  34. package/dist/types/gitTools.js +22 -0
  35. package/dist/types/index.d.ts +5 -0
  36. package/dist/types/index.js +8 -0
  37. package/dist/types/pathSandbox.d.ts +23 -0
  38. package/dist/types/pathSandbox.js +12 -0
  39. package/dist/types/providers.d.ts +4 -0
  40. package/dist/types/tasks.d.ts +85 -0
  41. package/dist/types/tasks.js +14 -0
  42. package/dist/types/tools.d.ts +11 -0
  43. package/dist/utils/modelChoices.js +17 -1
  44. package/dist/utils/pathSandbox.d.ts +49 -0
  45. package/dist/utils/pathSandbox.js +127 -0
  46. package/dist/utils/providerConfig.d.ts +4 -0
  47. package/dist/utils/providerConfig.js +17 -0
  48. package/package.json +5 -1
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Task checklist primitive (N1) — session-scoped, compaction-proof.
3
+ *
4
+ * A long-running agent that plans in prose loses the plan the moment the
5
+ * conversation is summarized. This keeps the plan OUT of the message list:
6
+ * checklist state lives in a module-level Map keyed by sessionId, so
7
+ * compaction — which only ever rewrites messages — cannot touch it. Every
8
+ * `tasks_*` tool returns the WHOLE list, so the first call after a compaction
9
+ * re-anchors the model for free; there is no re-injection machinery to get
10
+ * wrong (N1.3 is satisfied structurally, not by a mechanism).
11
+ *
12
+ * The host reads the same state synchronously via `getChecklistState()`, which
13
+ * is what makes a completeness gate ("no pending items may survive this
14
+ * stage") one line of host code with no LLM in the loop.
15
+ *
16
+ * Registration is opt-in (`NeuroLink.registerTaskTools()`), and goes through
17
+ * `host.registerTool()` so the tools land in the "user-defined" category —
18
+ * the only one that reaches the LLM's tool schema.
19
+ */
20
+ import type { NeuroLink } from "../neurolink.js";
21
+ import type { ChecklistCommandCountsSource, ChecklistDelegateCountsSource, ChecklistState, MCPExecutableTool } from "../types/index.js";
22
+ /**
23
+ * Let the async-delegation primitive feed `delegatesPending` / `delegatesReady`
24
+ * into every checklist result, so the model learns a worker finished from any
25
+ * `tasks_list` — without this module importing delegation (which would make a
26
+ * cycle) and without a polling loop.
27
+ */
28
+ export declare function setChecklistDelegateCountsSource(source: ChecklistDelegateCountsSource | undefined): void;
29
+ /**
30
+ * Let the background-command primitive feed `commandsRunning` /
31
+ * `commandsFinished` into every checklist result — the same notification
32
+ * channel the delegate counters use, so the model learns "the build finished"
33
+ * from any `tasks_list` without this module importing the command runtime
34
+ * (which would make a cycle) and without a polling loop.
35
+ */
36
+ export declare function setChecklistCommandCountsSource(source: ChecklistCommandCountsSource | undefined): void;
37
+ /**
38
+ * Which checklist a tool call belongs to.
39
+ *
40
+ * Execution context wins: a worker created with the shared tool registry runs
41
+ * the host's registered tool closure but arrives with its OWN sessionId, and
42
+ * that worker's checklist must not merge into its parent's. The host's
43
+ * `setToolContext()` session is the next authority, and a per-host key is the
44
+ * last resort so a host that never declared a session still gets ONE list
45
+ * instead of a new one per call.
46
+ */
47
+ export declare function resolveChecklistSessionId(host: NeuroLink, context?: unknown): string;
48
+ /** Never throws: an unknown session simply has an empty checklist. */
49
+ export declare function getChecklistState(sessionId: string): ChecklistState;
50
+ /** Drop one session's checklist. Returns whether there was one to drop. */
51
+ export declare function clearChecklistState(sessionId: string): boolean;
52
+ /**
53
+ * The three model-facing checklist tools, bound to `host` for session
54
+ * resolution. Register them with `host.registerTool()` (see
55
+ * `NeuroLink.registerTaskTools()`), never on the tool registry directly:
56
+ * only the "user-defined" category reaches the LLM's tool schema.
57
+ */
58
+ export declare function createChecklistTools(host: NeuroLink): Record<string, MCPExecutableTool>;
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Task checklist primitive (N1) — session-scoped, compaction-proof.
3
+ *
4
+ * A long-running agent that plans in prose loses the plan the moment the
5
+ * conversation is summarized. This keeps the plan OUT of the message list:
6
+ * checklist state lives in a module-level Map keyed by sessionId, so
7
+ * compaction — which only ever rewrites messages — cannot touch it. Every
8
+ * `tasks_*` tool returns the WHOLE list, so the first call after a compaction
9
+ * re-anchors the model for free; there is no re-injection machinery to get
10
+ * wrong (N1.3 is satisfied structurally, not by a mechanism).
11
+ *
12
+ * The host reads the same state synchronously via `getChecklistState()`, which
13
+ * is what makes a completeness gate ("no pending items may survive this
14
+ * stage") one line of host code with no LLM in the loop.
15
+ *
16
+ * Registration is opt-in (`NeuroLink.registerTaskTools()`), and goes through
17
+ * `host.registerTool()` so the tools land in the "user-defined" category —
18
+ * the only one that reaches the LLM's tool schema.
19
+ */
20
+ import { z } from "zod";
21
+ import { logger } from "../utils/logger.js";
22
+ /** Module-level on purpose: compaction rewrites messages, never a module map. */
23
+ const checklists = new Map();
24
+ const STATUSES = [
25
+ "pending",
26
+ "in_progress",
27
+ "done",
28
+ "closed",
29
+ ];
30
+ /** Ids the engine hands out: t1, t2, … — never accepted from the model. */
31
+ const ID_PREFIX = "t";
32
+ /**
33
+ * A session id the engine invented for a single tool call: NeuroLink's
34
+ * tool-context merge defaults to `fallback-${Date.now()}` when no session was
35
+ * declared anywhere. Honouring it would file every model tool call under a
36
+ * different checklist — a silently empty list — so it is ignored in favour of
37
+ * a stable per-host key.
38
+ *
39
+ * The registry's other placeholder (a bare `randomUUID()` minted in
40
+ * `createExecutionContext` when a DIRECT `executeTool()` call carries no
41
+ * session) is indistinguishable from a host that genuinely uses UUIDs as
42
+ * session ids, so it is honoured as given: programmatic callers pass
43
+ * `authContext: { sessionId }`.
44
+ */
45
+ const SYNTHETIC_SESSION_ID = /^fallback-\d+$/;
46
+ const hostDefaultSessions = new WeakMap();
47
+ let hostDefaultCounter = 0;
48
+ /** Set by the delegation primitive; absent means "no background workers". */
49
+ let delegateCountsSource;
50
+ /** Set by the background-command primitive; absent means "no commands". */
51
+ let commandCountsSource;
52
+ /**
53
+ * Let the async-delegation primitive feed `delegatesPending` / `delegatesReady`
54
+ * into every checklist result, so the model learns a worker finished from any
55
+ * `tasks_list` — without this module importing delegation (which would make a
56
+ * cycle) and without a polling loop.
57
+ */
58
+ export function setChecklistDelegateCountsSource(source) {
59
+ delegateCountsSource = source;
60
+ }
61
+ function delegateCountsFor(sessionId) {
62
+ if (!delegateCountsSource) {
63
+ return { pending: 0, ready: 0 };
64
+ }
65
+ try {
66
+ return delegateCountsSource(sessionId);
67
+ }
68
+ catch (error) {
69
+ logger.warn("[TaskChecklist] Delegate counts source failed — reporting 0", {
70
+ error: error instanceof Error ? error.message : String(error),
71
+ });
72
+ return { pending: 0, ready: 0 };
73
+ }
74
+ }
75
+ /**
76
+ * Let the background-command primitive feed `commandsRunning` /
77
+ * `commandsFinished` into every checklist result — the same notification
78
+ * channel the delegate counters use, so the model learns "the build finished"
79
+ * from any `tasks_list` without this module importing the command runtime
80
+ * (which would make a cycle) and without a polling loop.
81
+ */
82
+ export function setChecklistCommandCountsSource(source) {
83
+ commandCountsSource = source;
84
+ }
85
+ function commandCountsFor(sessionId) {
86
+ if (!commandCountsSource) {
87
+ return { running: 0, finished: 0 };
88
+ }
89
+ try {
90
+ return commandCountsSource(sessionId);
91
+ }
92
+ catch (error) {
93
+ logger.warn("[TaskChecklist] Command counts source failed — reporting 0", {
94
+ error: error instanceof Error ? error.message : String(error),
95
+ });
96
+ return { running: 0, finished: 0 };
97
+ }
98
+ }
99
+ function defaultSessionIdFor(host) {
100
+ const existing = hostDefaultSessions.get(host);
101
+ if (existing) {
102
+ return existing;
103
+ }
104
+ hostDefaultCounter += 1;
105
+ const created = `checklist-default-${hostDefaultCounter}`;
106
+ hostDefaultSessions.set(host, created);
107
+ return created;
108
+ }
109
+ function readSessionId(source) {
110
+ const value = source?.sessionId;
111
+ return typeof value === "string" ? value.trim() : "";
112
+ }
113
+ /**
114
+ * Which checklist a tool call belongs to.
115
+ *
116
+ * Execution context wins: a worker created with the shared tool registry runs
117
+ * the host's registered tool closure but arrives with its OWN sessionId, and
118
+ * that worker's checklist must not merge into its parent's. The host's
119
+ * `setToolContext()` session is the next authority, and a per-host key is the
120
+ * last resort so a host that never declared a session still gets ONE list
121
+ * instead of a new one per call.
122
+ */
123
+ export function resolveChecklistSessionId(host, context) {
124
+ const contextRecord = context && typeof context === "object"
125
+ ? context
126
+ : undefined;
127
+ const fromContext = readSessionId(contextRecord);
128
+ if (fromContext && !SYNTHETIC_SESSION_ID.test(fromContext)) {
129
+ return fromContext;
130
+ }
131
+ const fromHost = readSessionId(host.getToolContext());
132
+ if (fromHost && !SYNTHETIC_SESSION_ID.test(fromHost)) {
133
+ return fromHost;
134
+ }
135
+ return defaultSessionIdFor(host);
136
+ }
137
+ function cloneState(state) {
138
+ return {
139
+ sessionId: state.sessionId,
140
+ items: state.items.map((item) => ({ ...item })),
141
+ updatedAt: state.updatedAt,
142
+ };
143
+ }
144
+ function emptyState(sessionId) {
145
+ return { sessionId, items: [], updatedAt: 0 };
146
+ }
147
+ /** Never throws: an unknown session simply has an empty checklist. */
148
+ export function getChecklistState(sessionId) {
149
+ const state = checklists.get(sessionId);
150
+ return state ? cloneState(state) : emptyState(sessionId);
151
+ }
152
+ /** Drop one session's checklist. Returns whether there was one to drop. */
153
+ export function clearChecklistState(sessionId) {
154
+ return checklists.delete(sessionId);
155
+ }
156
+ /** The live state, created on first write. Reads must not create entries. */
157
+ function stateForWrite(sessionId) {
158
+ let state = checklists.get(sessionId);
159
+ if (!state) {
160
+ state = { sessionId, items: [], updatedAt: Date.now() };
161
+ checklists.set(sessionId, state);
162
+ }
163
+ return state;
164
+ }
165
+ /** Continue the id run rather than reusing an id a closed item still holds. */
166
+ function nextItemId(items) {
167
+ let highest = 0;
168
+ for (const item of items) {
169
+ const parsed = Number.parseInt(item.id.slice(ID_PREFIX.length), 10);
170
+ if (Number.isFinite(parsed) && parsed > highest) {
171
+ highest = parsed;
172
+ }
173
+ }
174
+ return `${ID_PREFIX}${highest + 1}`;
175
+ }
176
+ function toResult(state) {
177
+ const counts = {
178
+ pending: 0,
179
+ in_progress: 0,
180
+ done: 0,
181
+ closed: 0,
182
+ };
183
+ for (const item of state.items) {
184
+ counts[item.status] += 1;
185
+ }
186
+ const delegates = delegateCountsFor(state.sessionId);
187
+ const runCommands = commandCountsFor(state.sessionId);
188
+ return {
189
+ items: state.items.map((item) => ({ ...item })),
190
+ counts,
191
+ delegatesPending: delegates.pending,
192
+ delegatesReady: delegates.ready,
193
+ commandsRunning: runCommands.running,
194
+ commandsFinished: runCommands.finished,
195
+ };
196
+ }
197
+ /** Matches `agentToolRegistrar`'s convention: the recovery step is IN the text. */
198
+ function refusal(message) {
199
+ return { isError: true, error: message };
200
+ }
201
+ const CREATE_SCHEMA = z.object({
202
+ titles: z
203
+ .array(z.string())
204
+ .describe("One short imperative title per task, in the order you intend to work them, " +
205
+ 'e.g. ["Check auth changes against the security rules", "Review the migration files"].'),
206
+ });
207
+ const UPDATE_SCHEMA = z.object({
208
+ id: z.string().describe('Task id from the checklist, e.g. "t2".'),
209
+ status: z
210
+ .enum(["pending", "in_progress", "done", "closed"])
211
+ .describe("in_progress when you start it, done when it is finished, closed when it " +
212
+ "will NOT be done (a reason is then required)."),
213
+ note: z
214
+ .string()
215
+ .optional()
216
+ .describe("What you found, or — for closed — why the task will not be done. Required for closed."),
217
+ });
218
+ const LIST_SCHEMA = z.object({});
219
+ /**
220
+ * The three model-facing checklist tools, bound to `host` for session
221
+ * resolution. Register them with `host.registerTool()` (see
222
+ * `NeuroLink.registerTaskTools()`), never on the tool registry directly:
223
+ * only the "user-defined" category reaches the LLM's tool schema.
224
+ */
225
+ export function createChecklistTools(host) {
226
+ return {
227
+ tasks_create: {
228
+ name: "tasks_create",
229
+ description: "Write the checklist of concrete tasks this run must finish. Call it once " +
230
+ "up front, and again only to ADD tasks you discover later — titles are " +
231
+ "appended, never replaced, and the engine assigns the ids. Returns the full " +
232
+ "checklist. Pending tasks mean the work is not finished.",
233
+ inputSchema: CREATE_SCHEMA,
234
+ execute: async (params, context) => {
235
+ const parsed = CREATE_SCHEMA.safeParse(params ?? {});
236
+ if (!parsed.success) {
237
+ return refusal("tasks_create expects { titles: string[] }. Call it again with a non-empty " +
238
+ "array of short task titles.");
239
+ }
240
+ const titles = parsed.data.titles
241
+ .map((title) => title.trim())
242
+ .filter((title) => title.length > 0);
243
+ if (titles.length === 0) {
244
+ return refusal("No task titles were given. Call tasks_create again with at least one " +
245
+ "non-empty title describing work this run must finish.");
246
+ }
247
+ const sessionId = resolveChecklistSessionId(host, context);
248
+ const state = stateForWrite(sessionId);
249
+ const now = Date.now();
250
+ for (const title of titles) {
251
+ state.items.push({
252
+ id: nextItemId(state.items),
253
+ title,
254
+ status: "pending",
255
+ createdAt: now,
256
+ updatedAt: now,
257
+ });
258
+ }
259
+ state.updatedAt = now;
260
+ logger.debug("[TaskChecklist] Items created", {
261
+ sessionId,
262
+ added: titles.length,
263
+ total: state.items.length,
264
+ });
265
+ return toResult(state);
266
+ },
267
+ },
268
+ tasks_update: {
269
+ name: "tasks_update",
270
+ description: "Move one checklist task to a new status: in_progress when you start it, " +
271
+ "done when it is genuinely finished, closed when it will not be done (say " +
272
+ "why in note — a closed task without a reason is refused). Returns the full " +
273
+ "checklist so you always see what is left.",
274
+ inputSchema: UPDATE_SCHEMA,
275
+ execute: async (params, context) => {
276
+ const parsed = UPDATE_SCHEMA.safeParse(params ?? {});
277
+ if (!parsed.success) {
278
+ return refusal(`tasks_update expects { id, status, note? } with status one of ${STATUSES.join(" | ")}. Call tasks_list to see the current ids, then retry.`);
279
+ }
280
+ const { id, status, note } = parsed.data;
281
+ const sessionId = resolveChecklistSessionId(host, context);
282
+ const state = checklists.get(sessionId);
283
+ const item = state?.items.find((candidate) => candidate.id === id);
284
+ if (!state || !item) {
285
+ const valid = state?.items.map((candidate) => candidate.id) ?? [];
286
+ return refusal(valid.length > 0
287
+ ? `No checklist task "${id}". Valid ids are ${valid.join(", ")} — retry with one of them.`
288
+ : `No checklist task "${id}": the checklist is empty. Call tasks_create first.`);
289
+ }
290
+ const reason = note?.trim();
291
+ if (status === "closed" && !reason) {
292
+ return refusal(`Closing "${id}" needs a reason. Call tasks_update again with note set to why ` +
293
+ "this task will not be completed, or finish it and mark it done.");
294
+ }
295
+ item.status = status;
296
+ if (reason) {
297
+ item.note = reason;
298
+ }
299
+ item.updatedAt = Date.now();
300
+ state.updatedAt = item.updatedAt;
301
+ logger.debug("[TaskChecklist] Item updated", {
302
+ sessionId,
303
+ id,
304
+ status,
305
+ });
306
+ return toResult(state);
307
+ },
308
+ },
309
+ tasks_list: {
310
+ name: "tasks_list",
311
+ description: "Read the current checklist — every task with its status, plus how many " +
312
+ "background workers are still running or waiting to be collected and how many " +
313
+ "background commands are still running or have finished unread. Use it to " +
314
+ "re-orient after a long stretch of work; it is cheap and always current.",
315
+ inputSchema: LIST_SCHEMA,
316
+ execute: async (_params, context) => {
317
+ const sessionId = resolveChecklistSessionId(host, context);
318
+ return toResult(checklists.get(sessionId) ?? emptyState(sessionId));
319
+ },
320
+ },
321
+ };
322
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Artifact banking (N3) — bank the whole payload, hand back a pointer.
3
+ *
4
+ * A long-running agent produces outputs that do not fit in a conversation:
5
+ * a worker's full report, a build log, a stage's structured result. The wrong
6
+ * answer is to truncate one and send the head — the discarded bytes are gone
7
+ * and nothing records that they existed. The right answer is to write the
8
+ * payload to disk in full and put a bounded preview plus a read-back call in
9
+ * the conversation, so the model can pull as much of the rest as it needs,
10
+ * whenever it needs it, and compaction can evict the preview without costing
11
+ * anything.
12
+ *
13
+ * Read-back is the tool that already exists: `retrieve_context({ artifactId,
14
+ * offset, limit })` paginates any artifact and reports `totalSize` / `hasMore`.
15
+ * There is no second read tool and no second storage layer — this module is a
16
+ * thin, typed front door onto `LocalTempArtifactStore`, the same store the MCP
17
+ * output normalizer externalizes into.
18
+ *
19
+ * The one thing it adds is that the store no longer has to pre-exist: it is
20
+ * created on first use, whether or not `mcp.outputLimits` was ever configured,
21
+ * and `retrieve_context` is registered along with it.
22
+ *
23
+ * @module artifacts/artifactBanking
24
+ */
25
+ import type { NeuroLink } from "../neurolink.js";
26
+ import type { ArtifactPageRequest, ArtifactStore, BankArtifactOptions, BankedArtifactRef } from "../types/index.js";
27
+ /**
28
+ * The artifact store for this instance, created on first use.
29
+ *
30
+ * Lazy creation lives on the host rather than here because both pieces of it
31
+ * are private to `NeuroLink`: the store field the `retrieve_context` closure
32
+ * reads, and the registration of that tool. Calling it through the host is
33
+ * what makes a banked artifact readable by the model, not just by host code.
34
+ */
35
+ export declare function ensureArtifactStore(host: NeuroLink): ArtifactStore;
36
+ /**
37
+ * Write a payload to the artifact store and return a reference to it.
38
+ *
39
+ * The payload is stored whole. The returned `preview` is a bounded head slice
40
+ * for the conversation, and `readBackHint` is the call that fetches the rest.
41
+ *
42
+ * @param host Instance whose artifact store (and `retrieve_context`) to use.
43
+ * @param payload The complete text or JSON payload. Never truncated.
44
+ * @param options What this is (`kind` / `label`) and how big a preview to cut.
45
+ */
46
+ export declare function bankArtifact(host: NeuroLink, payload: string, options: BankArtifactOptions): Promise<BankedArtifactRef>;
47
+ /**
48
+ * Read a banked payload back from host code — the programmatic twin of the
49
+ * `retrieve_context` tool the model uses.
50
+ *
51
+ * Returns the FULL payload when `page` is omitted; there is no hidden cap,
52
+ * because a host that asks for the artifact is asking for the artifact.
53
+ * Returns null when the id is unknown or the file is gone.
54
+ *
55
+ * @param page Character window. `offset` defaults to 0, `limit` to the rest.
56
+ */
57
+ export declare function readArtifact(host: NeuroLink, id: string, page?: ArtifactPageRequest): Promise<string | null>;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Artifact banking (N3) — bank the whole payload, hand back a pointer.
3
+ *
4
+ * A long-running agent produces outputs that do not fit in a conversation:
5
+ * a worker's full report, a build log, a stage's structured result. The wrong
6
+ * answer is to truncate one and send the head — the discarded bytes are gone
7
+ * and nothing records that they existed. The right answer is to write the
8
+ * payload to disk in full and put a bounded preview plus a read-back call in
9
+ * the conversation, so the model can pull as much of the rest as it needs,
10
+ * whenever it needs it, and compaction can evict the preview without costing
11
+ * anything.
12
+ *
13
+ * Read-back is the tool that already exists: `retrieve_context({ artifactId,
14
+ * offset, limit })` paginates any artifact and reports `totalSize` / `hasMore`.
15
+ * There is no second read tool and no second storage layer — this module is a
16
+ * thin, typed front door onto `LocalTempArtifactStore`, the same store the MCP
17
+ * output normalizer externalizes into.
18
+ *
19
+ * The one thing it adds is that the store no longer has to pre-exist: it is
20
+ * created on first use, whether or not `mcp.outputLimits` was ever configured,
21
+ * and `retrieve_context` is registered along with it.
22
+ *
23
+ * @module artifacts/artifactBanking
24
+ */
25
+ /** Preview length when the caller does not ask for one. */
26
+ const DEFAULT_BANK_PREVIEW_CHARS = 1000;
27
+ /**
28
+ * Ceiling on a preview, however much the caller asks for. A preview is a
29
+ * pointer into the banked file; past a few thousand characters it stops being
30
+ * a pointer and starts being the context pressure banking exists to remove.
31
+ */
32
+ const MAX_BANK_PREVIEW_CHARS = 4000;
33
+ /** `serverId` recorded on banked artifacts — distinguishes them from MCP surrogates. */
34
+ const BANK_SERVER_ID = "neurolink-banking";
35
+ /** Chunk size suggested in the read-back hint (matches retrieve_context's default). */
36
+ const READ_BACK_CHUNK_CHARS = 50_000;
37
+ /**
38
+ * The artifact store for this instance, created on first use.
39
+ *
40
+ * Lazy creation lives on the host rather than here because both pieces of it
41
+ * are private to `NeuroLink`: the store field the `retrieve_context` closure
42
+ * reads, and the registration of that tool. Calling it through the host is
43
+ * what makes a banked artifact readable by the model, not just by host code.
44
+ */
45
+ export function ensureArtifactStore(host) {
46
+ return host.getArtifactStore();
47
+ }
48
+ /** Head slice of the payload, clamped to the caller's budget and the ceiling. */
49
+ function boundedPreview(payload, requested) {
50
+ const chars = Math.min(Math.max(0, requested ?? DEFAULT_BANK_PREVIEW_CHARS), MAX_BANK_PREVIEW_CHARS);
51
+ return payload.length <= chars ? payload : `${payload.slice(0, chars)}…`;
52
+ }
53
+ /**
54
+ * The literal call that reads the payload back.
55
+ *
56
+ * Spelled out rather than described: a model that is handed a preview and the
57
+ * exact next call does not have to guess a tool name, an argument name, or
58
+ * how to page — and the sentence says outright that nothing was discarded, so
59
+ * "the rest is not worth asking for" is never a reasonable inference.
60
+ */
61
+ function readBackHintFor(id, sizeBytes) {
62
+ return (`retrieve_context({ artifactId: "${id}", offset: 0, limit: ${READ_BACK_CHUNK_CHARS} }) ` +
63
+ `— the complete ${sizeBytes}-byte payload is stored; nothing was discarded. ` +
64
+ `Repeat with offset advanced by the characters you received while hasMore is true.`);
65
+ }
66
+ /**
67
+ * Write a payload to the artifact store and return a reference to it.
68
+ *
69
+ * The payload is stored whole. The returned `preview` is a bounded head slice
70
+ * for the conversation, and `readBackHint` is the call that fetches the rest.
71
+ *
72
+ * @param host Instance whose artifact store (and `retrieve_context`) to use.
73
+ * @param payload The complete text or JSON payload. Never truncated.
74
+ * @param options What this is (`kind` / `label`) and how big a preview to cut.
75
+ */
76
+ export async function bankArtifact(host, payload, options) {
77
+ const store = ensureArtifactStore(host);
78
+ const label = options.label.trim() || options.kind;
79
+ const contentType = options.contentType ?? "text";
80
+ const sizeBytes = Buffer.byteLength(payload, "utf-8");
81
+ const ref = await store.store(payload, {
82
+ toolName: `bank:${options.kind}`,
83
+ serverId: BANK_SERVER_ID,
84
+ sessionId: options.sessionId,
85
+ sizeBytes,
86
+ contentType,
87
+ label,
88
+ kind: options.kind,
89
+ });
90
+ return {
91
+ artifactId: ref.id,
92
+ label,
93
+ kind: options.kind,
94
+ sizeBytes,
95
+ preview: boundedPreview(payload, options.previewChars),
96
+ readBackHint: readBackHintFor(ref.id, sizeBytes),
97
+ };
98
+ }
99
+ /**
100
+ * Read a banked payload back from host code — the programmatic twin of the
101
+ * `retrieve_context` tool the model uses.
102
+ *
103
+ * Returns the FULL payload when `page` is omitted; there is no hidden cap,
104
+ * because a host that asks for the artifact is asking for the artifact.
105
+ * Returns null when the id is unknown or the file is gone.
106
+ *
107
+ * @param page Character window. `offset` defaults to 0, `limit` to the rest.
108
+ */
109
+ export async function readArtifact(host, id, page) {
110
+ const store = ensureArtifactStore(host);
111
+ const content = await store.retrieve(id);
112
+ if (content === null) {
113
+ return null;
114
+ }
115
+ if (!page) {
116
+ return content;
117
+ }
118
+ const offset = Math.max(0, page.offset ?? 0);
119
+ if (page.limit === undefined) {
120
+ return content.slice(offset);
121
+ }
122
+ return content.slice(offset, offset + Math.max(0, page.limit));
123
+ }
@@ -22,13 +22,11 @@ import type { ArtifactMeta, ArtifactRef, ArtifactStore } from "../types/index.js
22
22
  * Filesystem-backed artifact store using the OS temp directory.
23
23
  *
24
24
  * Files are written with mode 0o600 (owner read/write only).
25
- * An in-memory index tracks metadata without a separate index file.
26
- *
27
- * Suitable for:
28
- * - CLI usage
29
- * - Single-process SDK deployments
30
- * - Multi-process deployments where each process manages its own artifacts
31
- * (artifacts created in one process are not visible to others)
25
+ * An in-memory index tracks metadata for the fast path; every payload also
26
+ * gets a `<id>.meta.json` sidecar, so an id this process never stored — from
27
+ * another process, or from before a restart — still resolves (see
28
+ * `rehydrate`). `cleanup()` remains index-scoped: it expires what this process
29
+ * knows about, and never walks the directory deleting another process's work.
32
30
  *
33
31
  * @example
34
32
  * ```typescript
@@ -46,10 +44,40 @@ import type { ArtifactMeta, ArtifactRef, ArtifactStore } from "../types/index.js
46
44
  export declare class LocalTempArtifactStore implements ArtifactStore {
47
45
  private readonly dir;
48
46
  private readonly index;
49
- constructor(dir?: string);
47
+ private readonly rehydrateFromDisk;
48
+ /**
49
+ * @param dir - Storage directory; defaults to `tmpdir()/neurolink-artifacts`
50
+ * @param options - `rehydrateFromDisk` (default true) lets `retrieve()` and
51
+ * `delete()` fall back to the on-disk sidecar index on an in-memory miss,
52
+ * which makes artifacts READABLE AND DELETABLE ACROSS PROCESSES sharing
53
+ * the same directory and unix user. Pass `false` — or set
54
+ * `NEUROLINK_ARTIFACT_REHYDRATE=false` — to restore strict per-process
55
+ * isolation: ids not stored by this process resolve to nothing.
56
+ */
57
+ constructor(dir?: string, options?: {
58
+ rehydrateFromDisk?: boolean;
59
+ });
50
60
  generatePreview(payload: string): string;
51
61
  store(payload: string, meta: Omit<ArtifactMeta, "createdAt">): Promise<ArtifactRef>;
52
62
  retrieve(id: string): Promise<string | null>;
53
63
  delete(id: string): Promise<void>;
54
64
  cleanup(olderThanMs: number): Promise<number>;
65
+ /**
66
+ * Record the index row next to the payload.
67
+ *
68
+ * The index is per-process, so without this an artifact written by one
69
+ * process is invisible to every other one — and to the same process after a
70
+ * restart. A failed sidecar write is logged, never fatal: the payload is
71
+ * already safely on disk and this process can still read it from its index.
72
+ */
73
+ private writeSidecar;
74
+ /**
75
+ * Resolve an id the in-memory index does not know: another process stored
76
+ * it, or this process restarted. Reads the sidecar first (full metadata);
77
+ * falls back to probing the payload file itself, so an artifact whose
78
+ * sidecar was lost is still readable with metadata recovered from `stat`.
79
+ *
80
+ * Returns undefined for an unsafe id without touching the filesystem.
81
+ */
82
+ private rehydrate;
55
83
  }