@mlx-node/agent 0.0.8 → 0.0.10

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 (49) hide show
  1. package/dist/catalog.d.ts +15 -0
  2. package/dist/catalog.d.ts.map +1 -1
  3. package/dist/catalog.js +15 -0
  4. package/dist/cold-tier.d.ts +99 -0
  5. package/dist/cold-tier.d.ts.map +1 -0
  6. package/dist/cold-tier.js +155 -0
  7. package/dist/extensions/local-image-input.d.ts +24 -0
  8. package/dist/extensions/local-image-input.d.ts.map +1 -0
  9. package/dist/extensions/local-image-input.js +114 -0
  10. package/dist/extensions/subagent.d.ts +20 -1
  11. package/dist/extensions/subagent.d.ts.map +1 -1
  12. package/dist/extensions/subagent.js +46 -6
  13. package/dist/paths.d.ts +13 -0
  14. package/dist/paths.d.ts.map +1 -0
  15. package/dist/paths.js +18 -0
  16. package/dist/provider/chat-config.d.ts +1 -1
  17. package/dist/provider/chat-config.d.ts.map +1 -1
  18. package/dist/provider/chat-config.js +15 -3
  19. package/dist/provider/events.d.ts +9 -0
  20. package/dist/provider/events.d.ts.map +1 -1
  21. package/dist/provider/events.js +15 -0
  22. package/dist/provider/index.d.ts +12 -1
  23. package/dist/provider/index.d.ts.map +1 -1
  24. package/dist/provider/index.js +153 -7
  25. package/dist/provider/metrics-trace.d.ts +274 -0
  26. package/dist/provider/metrics-trace.d.ts.map +1 -0
  27. package/dist/provider/metrics-trace.js +174 -0
  28. package/dist/provider/mlx-identity.d.ts +16 -0
  29. package/dist/provider/mlx-identity.d.ts.map +1 -0
  30. package/dist/provider/mlx-identity.js +15 -0
  31. package/dist/provider/model-host.d.ts +45 -3
  32. package/dist/provider/model-host.d.ts.map +1 -1
  33. package/dist/provider/model-host.js +34 -2
  34. package/dist/provider/model-registry-filter.d.ts +74 -18
  35. package/dist/provider/model-registry-filter.d.ts.map +1 -1
  36. package/dist/provider/model-registry-filter.js +229 -38
  37. package/dist/provider/models.d.ts +2 -3
  38. package/dist/provider/models.d.ts.map +1 -1
  39. package/dist/provider/models.js +46 -20
  40. package/dist/provider/stream-adapter.d.ts +37 -4
  41. package/dist/provider/stream-adapter.d.ts.map +1 -1
  42. package/dist/provider/stream-adapter.js +82 -7
  43. package/dist/provider/warm-reuse.d.ts +11 -8
  44. package/dist/provider/warm-reuse.d.ts.map +1 -1
  45. package/dist/provider/warm-reuse.js +14 -7
  46. package/dist/run-agent.d.ts +12 -3
  47. package/dist/run-agent.d.ts.map +1 -1
  48. package/dist/run-agent.js +31 -5
  49. package/package.json +10 -5
@@ -51,6 +51,16 @@ function safeString(read, fallback) {
51
51
  return fallback;
52
52
  }
53
53
  }
54
+ /** Read numeric model metadata without trusting a user-configurable getter. */
55
+ function readPositiveSafeInteger(read) {
56
+ try {
57
+ const value = read();
58
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined;
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ }
54
64
  /**
55
65
  * Publish the native model's load-time physical context limit onto pi's shared
56
66
  * model object. The parent session and every in-process subagent resolve this
@@ -62,13 +72,18 @@ function safeString(read, fallback) {
62
72
  * discovery-time limit here, and never let metadata synchronization break an
63
73
  * otherwise valid inference turn.
64
74
  */
65
- function publishEffectiveContextWindow(model, session) {
75
+ function publishEffectiveContextWindow(model, session, configuredModelMaxTokens) {
66
76
  try {
67
77
  const effective = Math.floor(session.contextLimits()?.effectiveWindowTokens ?? 0);
68
78
  if (!Number.isSafeInteger(effective) || effective <= 0)
69
79
  return;
70
80
  model.contextWindow = Math.min(model.contextWindow, effective);
71
- model.maxTokens = Math.min(model.maxTokens, model.contextWindow);
81
+ // Use the already-validated snapshot. Reading the shared metadata here
82
+ // would let Math.min coerce invalid user configuration into a valid value
83
+ // that a later turn would then trust.
84
+ if (configuredModelMaxTokens !== undefined) {
85
+ model.maxTokens = Math.min(configuredModelMaxTokens, model.contextWindow);
86
+ }
72
87
  }
73
88
  catch {
74
89
  // Exact native preflight still protects capacity; keep serving the turn.
@@ -125,7 +140,7 @@ function failsafeMessage(model, reason, message) {
125
140
  timestamp: Date.now(),
126
141
  };
127
142
  }
128
- export function makeMlxStreamSimple(host, onPerformance, resolveRootCacheOwner) {
143
+ export function makeMlxStreamSimple(host, onPerformance, resolveRootCacheOwner, onTurnRecord, onTurnStart, resolveRootSessionFile) {
129
144
  return (model, context, options) => {
130
145
  const stream = createAssistantMessageEventStream();
131
146
  /**
@@ -138,6 +153,7 @@ export function makeMlxStreamSimple(host, onPerformance, resolveRootCacheOwner)
138
153
  let emitter;
139
154
  let signal;
140
155
  let rootCacheOwnerId;
156
+ let rootSessionFile;
141
157
  let resolvedReasoning;
142
158
  let detachAbort;
143
159
  /**
@@ -208,8 +224,11 @@ export function makeMlxStreamSimple(host, onPerformance, resolveRootCacheOwner)
208
224
  signal = options?.signal;
209
225
  // Snapshot the top-level owner before this request can queue behind
210
226
  // another inference. A later /new or /resume must not relabel an older
211
- // request that was already submitted under the previous root.
227
+ // request that was already submitted under the previous root. The metrics
228
+ // root id reuses this same session_start value; only the JSONL path is a
229
+ // separate snapshot, taken here so it can never drift from the id.
212
230
  rootCacheOwnerId = resolveRootCacheOwner?.();
231
+ rootSessionFile = resolveRootSessionFile?.();
213
232
  // Snapshot once: the native config and the replay provenance must describe
214
233
  // the same resolved template mode. Presence alone is wrong for Pi's
215
234
  // minimal/low levels, both of which resolve to disabled thinking.
@@ -238,13 +257,42 @@ export function makeMlxStreamSimple(host, onPerformance, resolveRootCacheOwner)
238
257
  }
239
258
  void (async () => {
240
259
  let sawNativeFinal = false;
241
- await host.runWithResident(model.id, async (session) => {
260
+ // Bracket the whole serialized turn (queue wait + resident selection +
261
+ // prefill + decode) so a MetricsTrace record can report wall-clock
262
+ // duration. Read at the terminal only on the success path below.
263
+ const turnStartedAt = Date.now();
264
+ await host.runWithResident(model.id, async (session, resident) => {
265
+ // Callback entry: the queue wait + any cold model load/swap have now
266
+ // resolved, but no native work (prime/prefill/decode) has started. This
267
+ // is the seam that separates queue+load latency from execution time —
268
+ // `durationMs` (read at the terminal) still brackets the whole turn.
269
+ const execStartedAt = Date.now();
270
+ const queueMs = Math.max(0, execStartedAt - turnStartedAt);
242
271
  // Terminated while queued behind earlier inference/loading (or
243
272
  // between stages below): the terminal already went out — skip ALL
244
273
  // session work (no warm-reset, no prime, no stream).
245
274
  if (terminated)
246
275
  return;
247
- publishEffectiveContextWindow(model, session);
276
+ // Serialized-turn start: fire before any native work so a metrics
277
+ // consumer can snapshot process-wide cold-tier counters here and diff
278
+ // them at the success terminal. Because turns are serialized, a prior
279
+ // turn that aborted/errored has already fully drained by now, so its
280
+ // activity is baked into this snapshot and never attributed to this
281
+ // turn's delta. Best-effort — a throwing hook must not break inference.
282
+ if (onTurnStart) {
283
+ try {
284
+ onTurnStart();
285
+ }
286
+ catch {
287
+ // Telemetry snapshot is best-effort; never break inference.
288
+ }
289
+ }
290
+ // Snapshot the composed budget before publishing native context limits.
291
+ // The advisory publisher must not read this raw value itself:
292
+ // Math.min can coerce hostile metadata (for example "512" or Infinity)
293
+ // into a finite number that a later turn would then trust.
294
+ const configuredModelMaxTokens = readPositiveSafeInteger(() => model.maxTokens);
295
+ publishEffectiveContextWindow(model, session, configuredModelMaxTokens);
248
296
  const supportsImages = publishImageCapability(model, session);
249
297
  const discovered = host.modelInfo(model.id);
250
298
  if (!discovered) {
@@ -271,7 +319,7 @@ export function makeMlxStreamSimple(host, onPerformance, resolveRootCacheOwner)
271
319
  return;
272
320
  try {
273
321
  session.primeHistory(contextToChatMessages(context, supportsImages));
274
- const config = buildChatConfig(discovered.modelType, options, toolsToDefinitions(context.tools), rootCacheOwnerId, resolvedReasoning);
322
+ const config = buildChatConfig(discovered.modelType, options, toolsToDefinitions(context.tools), rootCacheOwnerId, resolvedReasoning, configuredModelMaxTokens);
275
323
  for await (const event of session.startFromHistoryStream(config, signal)) {
276
324
  if (event.done) {
277
325
  if (event.finishReason === 'error') {
@@ -308,6 +356,33 @@ export function makeMlxStreamSimple(host, onPerformance, resolveRootCacheOwner)
308
356
  detachAbort = undefined;
309
357
  try {
310
358
  turn.onFinal(event);
359
+ // Durable per-turn telemetry: only a successful final gets
360
+ // here. Guarded independently of onFinal so a throwing
361
+ // recorder cannot derail the (already-emitted) terminal.
362
+ if (onTurnRecord) {
363
+ try {
364
+ onTurnRecord({
365
+ traceId: turn.traceId,
366
+ sessionId: options?.sessionId,
367
+ // Root correlation is the SUBMIT-time snapshot, not the
368
+ // live root, so a /new or /resume that landed while this
369
+ // turn ran can't reattribute it (id == cacheOwner root).
370
+ rootSessionId: rootCacheOwnerId,
371
+ rootSessionFile,
372
+ model: model.id,
373
+ final: event,
374
+ durationMs: Math.max(0, Date.now() - turnStartedAt),
375
+ // Queue+load wait captured at callback entry, and the
376
+ // warm/cold distinction from `runWithResident` — so the
377
+ // record can attribute wait vs execution latency.
378
+ queueMs,
379
+ resident,
380
+ });
381
+ }
382
+ catch {
383
+ // Telemetry is best-effort; never break inference.
384
+ }
385
+ }
311
386
  }
312
387
  catch (err) {
313
388
  pushFailsafeTerminal('error', coerceErrorMessage(err));
@@ -18,18 +18,19 @@
18
18
  * the reused prefix and skip the corresponding re-prefill.
19
19
  *
20
20
  * Fields accessed: `inFlight`, `history`, `lastImagesKey`, `lastAudioKey`, `turnCount`,
21
- * `unresolvedOkToolCallCount`, `needsFullReplay`. These are TypeScript `private` fields on
22
- * `ChatSession` (compile-time only) — at runtime they are ordinary
23
- * properties. The cast through {@link ChatSessionWarmReuseInternals}
24
- * gives this helper a typed view of the instance without relaxing the
25
- * class's `private` declarations. The field names MUST stay in sync
26
- * with `packages/lm/src/chat-session.ts`; a mismatch would silently
27
- * skip the intended state wipe — the drift test in
21
+ * `unresolvedOkToolCallCount`, `needsFullReplay`, `defaultConfig`, `activeTools`.
22
+ * These are TypeScript `private` fields on `ChatSession` (compile-time
23
+ * only) — at runtime they are ordinary properties. The cast through
24
+ * {@link ChatSessionWarmReuseInternals} gives this helper a typed view
25
+ * of the instance without relaxing the class's `private` declarations.
26
+ * The field names MUST stay in sync with
27
+ * `packages/lm/src/chat-session.ts`; a mismatch would silently skip
28
+ * the intended state wipe — the drift test in
28
29
  * `packages/agent/__test__/warm-reuse.test.ts` checks every name in
29
30
  * {@link WARM_REUSE_TOUCHED_FIELDS} against a real `ChatSession`
30
31
  * instance.
31
32
  */
32
- import type { ChatSession, SessionCapableModel } from '@mlx-node/lm';
33
+ import type { ChatConfig, ChatSession, SessionCapableModel } from '@mlx-node/lm';
33
34
  /**
34
35
  * Private structural view of the `ChatSession` JS-side state that the
35
36
  * warm-reuse helper needs to wipe. Mirrors the internal state
@@ -45,6 +46,8 @@ interface ChatSessionWarmReuseInternals {
45
46
  turnCount: number;
46
47
  unresolvedOkToolCallCount: number | null;
47
48
  needsFullReplay: boolean;
49
+ defaultConfig?: ChatConfig;
50
+ activeTools: ChatConfig['tools'];
48
51
  }
49
52
  /**
50
53
  * Runtime list of the `ChatSession` private field names this module
@@ -1 +1 @@
1
- {"version":3,"file":"warm-reuse.d.ts","sourceRoot":"","sources":["../../src/provider/warm-reuse.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAErE;;;;;;GAMG;AACH,UAAU,6BAA6B;IACrC,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,OAAO,EAAE,CAAC;IACnB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,yBAAyB,EAAE,MAAM,GAAG,IAAI,CAAC;IACzC,eAAe,EAAE,OAAO,CAAC;CAC1B;AAkBD;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,EAAgD,aAAa,CACjG,MAAM,6BAA6B,CACpC,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,sCAAsC,CAAC,CAAC,SAAS,mBAAmB,EACxF,OAAO,EAAE,WAAW,CAAC,CAAC,CAAC,GACtB,OAAO,CAAC,IAAI,CAAC,CAqBf"}
1
+ {"version":3,"file":"warm-reuse.d.ts","sourceRoot":"","sources":["../../src/provider/warm-reuse.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAEjF;;;;;;GAMG;AACH,UAAU,6BAA6B;IACrC,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,OAAO,EAAE,CAAC;IACnB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,yBAAyB,EAAE,MAAM,GAAG,IAAI,CAAC;IACzC,eAAe,EAAE,OAAO,CAAC;IACzB,aAAa,CAAC,EAAE,UAAU,CAAC;IAC3B,WAAW,EAAE,UAAU,CAAC,OAAO,CAAC,CAAC;CAClC;AAoBD;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,EAAgD,aAAa,CACjG,MAAM,6BAA6B,CACpC,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,sCAAsC,CAAC,CAAC,SAAS,mBAAmB,EACxF,OAAO,EAAE,WAAW,CAAC,CAAC,CAAC,GACtB,OAAO,CAAC,IAAI,CAAC,CAyBf"}
@@ -18,13 +18,14 @@
18
18
  * the reused prefix and skip the corresponding re-prefill.
19
19
  *
20
20
  * Fields accessed: `inFlight`, `history`, `lastImagesKey`, `lastAudioKey`, `turnCount`,
21
- * `unresolvedOkToolCallCount`, `needsFullReplay`. These are TypeScript `private` fields on
22
- * `ChatSession` (compile-time only) — at runtime they are ordinary
23
- * properties. The cast through {@link ChatSessionWarmReuseInternals}
24
- * gives this helper a typed view of the instance without relaxing the
25
- * class's `private` declarations. The field names MUST stay in sync
26
- * with `packages/lm/src/chat-session.ts`; a mismatch would silently
27
- * skip the intended state wipe — the drift test in
21
+ * `unresolvedOkToolCallCount`, `needsFullReplay`, `defaultConfig`, `activeTools`.
22
+ * These are TypeScript `private` fields on `ChatSession` (compile-time
23
+ * only) — at runtime they are ordinary properties. The cast through
24
+ * {@link ChatSessionWarmReuseInternals} gives this helper a typed view
25
+ * of the instance without relaxing the class's `private` declarations.
26
+ * The field names MUST stay in sync with
27
+ * `packages/lm/src/chat-session.ts`; a mismatch would silently skip
28
+ * the intended state wipe — the drift test in
28
29
  * `packages/agent/__test__/warm-reuse.test.ts` checks every name in
29
30
  * {@link WARM_REUSE_TOUCHED_FIELDS} against a real `ChatSession`
30
31
  * instance.
@@ -43,6 +44,8 @@ const WARM_REUSE_TOUCHED_FIELD_SET = {
43
44
  turnCount: true,
44
45
  unresolvedOkToolCallCount: true,
45
46
  needsFullReplay: true,
47
+ defaultConfig: true,
48
+ activeTools: true,
46
49
  };
47
50
  /**
48
51
  * Runtime list of the `ChatSession` private field names this module
@@ -85,4 +88,8 @@ export async function resetPreservingNativeCacheForWarmReuse(session) {
85
88
  internals.turnCount = 0;
86
89
  internals.unresolvedOkToolCallCount = null;
87
90
  internals.needsFullReplay = false;
91
+ // Tools are conversation state. A provider replay can switch to an
92
+ // unrelated history, so restore constructor defaults exactly like
93
+ // ChatSession.reset() instead of leaking the prior committed overlay.
94
+ internals.activeTools = internals.defaultConfig?.tools;
88
95
  }
@@ -17,7 +17,7 @@
17
17
  * critical on pi's hard `process.exit()` paths.
18
18
  */
19
19
  import type { InlineExtension } from '@earendil-works/pi-coding-agent';
20
- import { type FilterableModelRegistryConstructor } from './provider/model-registry-filter.js';
20
+ import { type FilterableModelRuntimeConstructor } from './provider/model-registry-filter.js';
21
21
  import type { MlxModelInfo } from './provider/models.js';
22
22
  /** Shape of pi's `main(argv, { extensionFactories })` — also the test seam. */
23
23
  export type RunAgentMain = (args: string[], opts: {
@@ -25,11 +25,11 @@ export type RunAgentMain = (args: string[], opts: {
25
25
  }) => Promise<void>;
26
26
  export interface RunAgentPi {
27
27
  main: RunAgentMain;
28
- ModelRegistry: FilterableModelRegistryConstructor;
28
+ ModelRuntime: FilterableModelRuntimeConstructor;
29
29
  }
30
30
  /** @internal Narrow lifecycle seam for the agent's paged config overlays. */
31
31
  export interface AgentPagedConfigOverrides {
32
- resolve(modelPath: string, modelType?: string): Promise<string>;
32
+ resolve(modelPath: string, modelType?: string, persistPagedCache?: boolean): Promise<string>;
33
33
  cleanup(): Promise<void>;
34
34
  }
35
35
  export interface RunAgentOptions {
@@ -41,6 +41,15 @@ export interface RunAgentOptions {
41
41
  argv: string[];
42
42
  /** Native inference-log path to surface after Pi takes over the TUI. */
43
43
  traceLogFile?: string;
44
+ /**
45
+ * Enable the SSD cold tier by default (the agent's default; the CLI sets it
46
+ * false for `--no-persist-cache`). Forwarded to {@link MlxModelHost}, which
47
+ * applies this ONE value to every load whose family is in
48
+ * `COLD_TIER_RESTORE_FAMILIES` — not to qwen3 alone. Families off that list
49
+ * are handed no policy because they can never persist, not because this flag
50
+ * spares them. `undefined` keeps the host's on-by-default behavior.
51
+ */
52
+ persistPagedCache?: boolean;
44
53
  /** @internal Test seam; when set, the pi dynamic import is skipped entirely. */
45
54
  piImpl?: RunAgentPi;
46
55
  /** @internal Test seam for paged model-path resolution and cleanup. */
@@ -1 +1 @@
1
- {"version":3,"file":"run-agent.d.ts","sourceRoot":"","sources":["../src/run-agent.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAKH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AASvE,OAAO,EACL,KAAK,kCAAkC,EAExC,MAAM,qCAAqC,CAAC;AAC7C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAEzD,+EAA+E;AAC/E,MAAM,MAAM,YAAY,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE;IAAE,kBAAkB,EAAE,eAAe,EAAE,CAAA;CAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAE9G,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,YAAY,CAAC;IACnB,aAAa,EAAE,kCAAkC,CAAC;CACnD;AAED,6EAA6E;AAC7E,MAAM,WAAW,yBAAyB;IACxC,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAChE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAED,MAAM,WAAW,eAAe;IAC9B,2FAA2F;IAC3F,SAAS,EAAE,MAAM,CAAC;IAClB,wEAAwE;IACxE,MAAM,EAAE,YAAY,EAAE,CAAC;IACvB,yDAAyD;IACzD,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,wEAAwE;IACxE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gFAAgF;IAChF,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,uEAAuE;IACvE,oBAAoB,CAAC,EAAE,yBAAyB,CAAC;CAClD;AAED,mFAAmF;AACnF,wBAAgB,sBAAsB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CAEpF;AAED;;;;;;;;;GASG;AACH,wBAAsB,QAAQ,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAmDnE"}
1
+ {"version":3,"file":"run-agent.d.ts","sourceRoot":"","sources":["../src/run-agent.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAKH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAWvE,OAAO,EACL,KAAK,iCAAiC,EAEvC,MAAM,qCAAqC,CAAC;AAC7C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAEzD,+EAA+E;AAC/E,MAAM,MAAM,YAAY,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE;IAAE,kBAAkB,EAAE,eAAe,EAAE,CAAA;CAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAE9G,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,YAAY,CAAC;IACnB,YAAY,EAAE,iCAAiC,CAAC;CACjD;AAED,6EAA6E;AAC7E,MAAM,WAAW,yBAAyB;IACxC,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,EAAE,iBAAiB,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC7F,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAED,MAAM,WAAW,eAAe;IAC9B,2FAA2F;IAC3F,SAAS,EAAE,MAAM,CAAC;IAClB,wEAAwE;IACxE,MAAM,EAAE,YAAY,EAAE,CAAC;IACvB,yDAAyD;IACzD,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,wEAAwE;IACxE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,gFAAgF;IAChF,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,uEAAuE;IACvE,oBAAoB,CAAC,EAAE,yBAAyB,CAAC;CAClD;AAED,mFAAmF;AACnF,wBAAgB,sBAAsB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CAEpF;AAED;;;;;;;;;GASG;AACH,wBAAsB,QAAQ,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CA2EnE"}
package/dist/run-agent.js CHANGED
@@ -18,7 +18,9 @@
18
18
  */
19
19
  import { homedir } from 'node:os';
20
20
  import { join } from 'node:path';
21
+ import { coldCacheDrain } from '@mlx-node/core';
21
22
  import { PagedConfigOverrideManager } from '@mlx-node/lm';
23
+ import { createLocalImageInputExtension } from './extensions/local-image-input.js';
22
24
  import { createPermissionGateExtension } from './extensions/permission-gate.js';
23
25
  import { createSubagentExtension } from './extensions/subagent.js';
24
26
  import { createTerminalTitleExtension } from './extensions/terminal-title.js';
@@ -46,6 +48,15 @@ export async function runAgent(opts) {
46
48
  // Mirrors `mlx launch claude`: chunked paged prefill keeps long-prompt
47
49
  // TTFT bounded on the default paged path.
48
50
  process.env.MLX_PAGED_PREFILL_CHUNK_SIZE ??= '2048';
51
+ // Hard offline invariant — NOT a user-overridable default, hence `=` not `??=`.
52
+ // `mlx agent` is local-only: no cloud provider may ever be contacted. pi 0.81.1's
53
+ // interactive and RPC startup call `ModelRuntime.refresh()`, which when PI_OFFLINE
54
+ // is unset fetches remote provider catalogs from pi.dev and can refresh a persisted
55
+ // cloud credential — a network path the mlx-only prototype filter does NOT cover
56
+ // (it patches the read methods, not `refresh`). Forcing PI_OFFLINE=1 here, before pi
57
+ // is imported below, also pins every ModelRuntime in this process to `allowNetwork`
58
+ // off, so no ambient/prior cloud credential can leak outbound traffic.
59
+ process.env.PI_OFFLINE = '1';
49
60
  // Force every paged-capable agent family through an isolated config clone.
50
61
  // This includes quantized LFM2 (whose standalone default is deliberately
51
62
  // flat) and Qwen3.5 dense/MoE (whose text-only defaults are flat). Gemma4's
@@ -55,19 +66,23 @@ export async function runAgent(opts) {
55
66
  const preserveEmbeddedGemmaDraft = agentGemmaDraftEnabled();
56
67
  const pagedConfigOverrides = opts.pagedConfigOverrides ?? new PagedConfigOverrideManager({ preserveEmbeddedGemmaDraft });
57
68
  const modelHost = new MlxModelHost(opts.models.map((model) => model.discovered), {
58
- resolveModelPathFn: (model) => pagedConfigOverrides.resolve(model.path, model.modelType),
69
+ resolveModelPathFn: (model, policy) => pagedConfigOverrides.resolve(model.path, model.modelType, policy?.persistPagedCache),
59
70
  requirePagedCache: true,
71
+ persistPagedCache: opts.persistPagedCache,
60
72
  });
61
- // Keep the pi import strictly behind the seam. The seam carries BOTH main
62
- // and its registry class, so tests and production exercise the same policy
63
- // installation/lifecycle instead of being able to bypass it accidentally.
73
+ // Keep the pi import strictly behind the seam. The seam carries BOTH main and
74
+ // the ModelRuntime class, so tests and production exercise the same policy
75
+ // installation/lifecycle instead of being able to bypass it accidentally. The
76
+ // filter patches the runtime prototype (not the extension-only ModelRegistry
77
+ // facade), which is where the selector / listing / resolution paths read.
64
78
  const pi = opts.piImpl ?? (await import('@earendil-works/pi-coding-agent'));
65
- const restoreModelRegistry = installMlxOnlyModelRegistryFilter(pi.ModelRegistry, opts.models.map((model) => model.discovered.name));
79
+ const restoreModelRegistry = installMlxOnlyModelRegistryFilter(pi.ModelRuntime, opts.models.map((model) => model.discovered.name));
66
80
  const subagentsEnabled = opts.models.length > 0 && !opts.argv.includes('--no-extensions') && !opts.argv.includes('-ne');
67
81
  try {
68
82
  await pi.main(opts.argv, {
69
83
  extensionFactories: [
70
84
  createMlxProviderExtension(opts.models, modelHost),
85
+ createLocalImageInputExtension(),
71
86
  createPermissionGateExtension(),
72
87
  ...(subagentsEnabled ? [createSubagentExtension()] : []),
73
88
  ...(opts.traceLogFile !== undefined ? [createTraceNoticeExtension(opts.traceLogFile)] : []),
@@ -81,6 +96,17 @@ export async function runAgent(opts) {
81
96
  }
82
97
  finally {
83
98
  await pagedConfigOverrides.cleanup();
99
+ // `mlx agent -p` is one-shot: flush any accepted cold-tier prefix blocks
100
+ // to disk before the process exits, otherwise a prompt's just-persisted
101
+ // KV could still be queued/mid-write when we return. No-op when the tier
102
+ // was never opened, bounded so a stuck fsync can't hang exit, and never
103
+ // allowed to throw out of cleanup (best-effort durability).
104
+ try {
105
+ coldCacheDrain(5000);
106
+ }
107
+ catch {
108
+ // Best-effort: a drain failure must never mask the real exit path.
109
+ }
84
110
  }
85
111
  }
86
112
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mlx-node/agent",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "homepage": "https://github.com/mlx-node/mlx-node",
5
5
  "bugs": {
6
6
  "url": "https://github.com/mlx-node/mlx-node/issues"
@@ -21,16 +21,21 @@
21
21
  ".": {
22
22
  "types": "./dist/index.d.ts",
23
23
  "import": "./dist/index.js"
24
+ },
25
+ "./catalog": {
26
+ "types": "./dist/catalog.d.ts",
27
+ "default": "./dist/catalog.js"
24
28
  }
25
29
  },
26
30
  "scripts": {
27
31
  "build": "tsc -b"
28
32
  },
29
33
  "dependencies": {
30
- "@earendil-works/pi-ai": "0.80.6",
31
- "@earendil-works/pi-coding-agent": "0.80.6",
32
- "@mlx-node/lm": "0.0.8",
33
- "@mlx-node/server": "0.0.8",
34
+ "@earendil-works/pi-ai": "0.81.1",
35
+ "@earendil-works/pi-coding-agent": "0.81.1",
36
+ "@mlx-node/core": "0.0.10",
37
+ "@mlx-node/lm": "0.0.10",
38
+ "@mlx-node/server": "0.0.10",
34
39
  "typebox": "1.3.6"
35
40
  },
36
41
  "devDependencies": {