@nyte-ai/host 0.0.18 → 0.0.21

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 (103) hide show
  1. package/THIRD-PARTY-NOTICES.md +66 -0
  2. package/dist/chunks/{canvas-preview-rp6fz0w5.js → canvas-preview-2jwbe14k.js} +5 -5
  3. package/dist/chunks/{canvas-preview-j1tnf51w.js → canvas-preview-3a6tst4d.js} +1 -1
  4. package/dist/chunks/{canvas-preview-m02v4yy6.js → canvas-preview-55s0y68g.js} +3 -2
  5. package/dist/chunks/{canvas-preview-xn10d4f8.js → canvas-preview-5ekjx6sw.js} +9 -4
  6. package/dist/chunks/{canvas-preview-g3qj3y3k.js → canvas-preview-8hr08aeh.js} +13 -3
  7. package/dist/chunks/{canvas-preview-75db60xy.js → canvas-preview-8xc0vn7v.js} +5 -4
  8. package/dist/chunks/{canvas-preview-zn2d0k9d.js → canvas-preview-af86jz8t.js} +14 -6
  9. package/dist/chunks/{canvas-preview-d9wwhbtz.js → canvas-preview-aq5j4p25.js} +1 -1
  10. package/dist/chunks/{canvas-preview-ewgspmsd.js → canvas-preview-b3r1tna2.js} +1 -1
  11. package/dist/chunks/canvas-preview-befkjea2.js +60 -0
  12. package/dist/chunks/canvas-preview-bxnqt8cd.js +103 -0
  13. package/dist/chunks/{canvas-preview-senpjwep.js → canvas-preview-crmzxxt8.js} +3 -3
  14. package/dist/chunks/{canvas-preview-ymgz0937.js → canvas-preview-drctegz4.js} +8 -4
  15. package/dist/chunks/{canvas-preview-yrfz2ak6.js → canvas-preview-e3vnkkc5.js} +6 -6
  16. package/dist/chunks/canvas-preview-ec2mt7es.js +108 -0
  17. package/dist/chunks/{canvas-preview-0nrnbt76.js → canvas-preview-eehzfefa.js} +199 -125
  18. package/dist/chunks/{canvas-preview-5hxat7hh.js → canvas-preview-geg2gckp.js} +8 -6
  19. package/dist/chunks/{canvas-preview-8xgt67ka.js → canvas-preview-gh7c9831.js} +20 -16
  20. package/dist/chunks/{canvas-preview-1ta3avah.js → canvas-preview-k2xaz0ym.js} +10 -5
  21. package/dist/chunks/canvas-preview-kp75gb63.js +141 -0
  22. package/dist/chunks/{canvas-preview-evh92ywd.js → canvas-preview-mstbvwz3.js} +26 -11
  23. package/dist/chunks/{canvas-preview-h8hshnff.js → canvas-preview-pkzqg96v.js} +1 -1
  24. package/dist/chunks/{canvas-preview-ehf3vp31.js → canvas-preview-vm9zdp2t.js} +45 -58
  25. package/dist/chunks/{canvas-preview-76fwvd6e.js → canvas-preview-w54ymtb3.js} +1 -1
  26. package/dist/chunks/canvas-preview-y5y6ghaw.js +125 -0
  27. package/dist/chunks/{canvas-preview-vax9ne4e.js → canvas-preview-y7kq0c8q.js} +22 -11
  28. package/dist/chunks/{canvas-preview-98aj5p34.js → canvas-preview-ycepb05s.js} +1 -1
  29. package/dist/src/canvas-preview.js +7 -3
  30. package/dist/src/deploy/cloudflare/worker.js +1 -1
  31. package/dist/src/deploy/vercel/server/routes/[...].js +2 -2
  32. package/dist/src/deploy/vercel/server/utils/host.js +1 -1
  33. package/dist/src/deploy/vercel/server/workflows/drive.js +3 -3
  34. package/dist/src/images/exif.d.ts +1 -0
  35. package/dist/src/images/exif.js +6 -0
  36. package/dist/src/images/node.d.ts +37 -0
  37. package/dist/src/images/node.js +12 -0
  38. package/dist/src/images/photon.d.ts +3 -0
  39. package/dist/src/images/photon.js +7 -0
  40. package/dist/src/images/worker.d.ts +1 -0
  41. package/dist/src/images/worker.js +23 -0
  42. package/dist/src/index.d.ts +0 -1
  43. package/dist/src/index.js +23 -22
  44. package/dist/src/listen-fetch.js +3 -3
  45. package/dist/src/listen.js +4 -4
  46. package/dist/src/local/command.d.ts +5 -0
  47. package/dist/src/local/command.js +6 -2
  48. package/dist/src/local/files.js +2 -2
  49. package/dist/src/local/git-watch.d.ts +1 -1
  50. package/dist/src/local/git-watch.js +1 -1
  51. package/dist/src/local/git.js +3 -3
  52. package/dist/src/local/github.js +2 -2
  53. package/dist/src/local/jobs.d.ts +1 -1
  54. package/dist/src/local/jobs.js +2 -1
  55. package/dist/src/local/provider.js +9 -9
  56. package/dist/src/local/snapshots.js +2 -2
  57. package/dist/src/local/worktrees.js +2 -2
  58. package/dist/src/mcp/connect.d.ts +0 -13
  59. package/dist/src/mcp/connect.js +1 -1
  60. package/dist/src/mcp/hub.js +2 -2
  61. package/dist/src/open.d.ts +2 -30
  62. package/dist/src/open.js +20 -18
  63. package/dist/src/serve/binding.js +1 -1
  64. package/dist/src/serve/handler.js +2 -2
  65. package/dist/src/sqlite.d.ts +2 -1
  66. package/dist/src/sqlite.js +4 -3
  67. package/dist/src/store-usage.js +4 -1
  68. package/dist/src/usage-history.d.ts +54 -0
  69. package/dist/src/usage-history.js +14 -0
  70. package/dist/src/usage-service.d.ts +2 -1
  71. package/dist/src/usage-service.js +7 -3
  72. package/dist/src/usage-worker.js +8 -4
  73. package/dist/src/usage.d.ts +150 -27
  74. package/dist/src/usage.js +12 -2
  75. package/package.json +16 -8
  76. package/src/canvas-preview.ts +4 -3
  77. package/src/images/exif.ts +69 -0
  78. package/src/images/node.ts +163 -0
  79. package/src/images/photon.ts +141 -0
  80. package/src/images/worker.ts +29 -0
  81. package/src/index.ts +19 -1
  82. package/src/local/command.ts +64 -12
  83. package/src/local/files.ts +8 -3
  84. package/src/local/git-watch.ts +16 -6
  85. package/src/local/git.ts +7 -2
  86. package/src/local/github.ts +3 -4
  87. package/src/local/jobs.ts +55 -69
  88. package/src/local/snapshots.ts +4 -2
  89. package/src/local/worktrees.ts +4 -4
  90. package/src/mcp/connect.ts +1 -14
  91. package/src/mcp/hub.ts +18 -9
  92. package/src/open.ts +16 -36
  93. package/src/serve/binding.ts +4 -3
  94. package/src/sqlite.ts +46 -9
  95. package/src/store-usage.ts +5 -2
  96. package/src/usage-history.ts +219 -0
  97. package/src/usage-service.ts +12 -3
  98. package/src/usage.ts +251 -176
  99. package/dist/chunks/canvas-preview-a3yw0j70.js +0 -72
  100. package/dist/chunks/canvas-preview-azg56359.js +0 -68
  101. package/dist/src/store-owner.d.ts +0 -15
  102. package/dist/src/store-owner.js +0 -9
  103. package/src/store-owner.ts +0 -98
package/src/open.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { watch } from "node:fs";
2
2
  import { mkdir, readFile } from "node:fs/promises";
3
3
  import { homedir, hostname } from "node:os";
4
- import { join } from "node:path";
4
+ import { dirname, join } from "node:path";
5
5
  import process from "node:process";
6
6
  import type { Models, Provider } from "@nyte-ai/ai";
7
7
  import { defaultProviderAuthContext } from "@nyte-ai/ai/auth/context";
@@ -43,55 +43,28 @@ import { localProvider } from "./local/provider.ts";
43
43
  import { importLegacyTrust } from "./local/legacy-trust.ts";
44
44
  import { migrateLocalWorkspaceIdentities } from "./local/workspace-migration.ts";
45
45
  import { listen } from "./listen.ts";
46
+ import { nodeImages } from "./images/node.ts";
46
47
  import { createMcpHub } from "./mcp/hub.ts";
47
48
  import { loadPluginDirectory, pluginLoader } from "./plugins/loader.ts";
48
49
  import { defaultSecrets } from "./secrets.ts";
49
50
  import type { SecretsSource } from "./secrets.ts";
50
- import { sqliteStore } from "./sqlite.ts";
51
+ import { sqliteStore, storePath } from "./sqlite.ts";
51
52
 
52
53
  const RELOAD_DEBOUNCE_MS = 250;
53
54
 
54
55
  const STORE_POLL_MS = 500;
55
56
 
56
- /**
57
- * Describes what `openHost` takes: `OpenOptions` with `store`, `workspace`,
58
- * `models` and `plugins` optional, `secrets` defaulting to this machine's key,
59
- * and the home directory holding `.nyte`. Every `OpenOptions` is one, so the
60
- * sdk's Node `open` passes its options straight through.
61
- */
62
57
  export type OpenHostOptions = Omit<OpenOptions, "store" | "workspace" | "models" | "plugins"> & {
63
- /** The host's store; absent, `sqliteStore` at `<home>/.nyte/nyte.db`, which the host then closes with itself (§51c). */
64
58
  readonly store?: Store;
65
- /** Where a root session acts when `create` names none; default the local folder `process.cwd()` (§51c). */
66
59
  readonly workspace?: WorkspaceTarget;
67
- /** The `@nyte-ai/ai` catalog; absent, `nyteModels` over this host's encrypted credential store (§42e). */
68
60
  readonly models?: Models;
69
- /** Core plugins loaded after the first-party built-ins and before the plugin files; default none. One with a built-in's id replaces that built-in, as a bundled client's `codemode` runtime does, and a `local` environment provider among them replaces the default one. */
70
61
  readonly plugins?: readonly Plugin<"core">[];
71
- /** The directory whose `.nyte` holds the store, settings, plugins, secrets key and identity key; default `os.homedir()`. */
72
62
  readonly home?: string;
73
- /** The login shell the default local provider's pty jobs run, read at each start; absent or null is `$SHELL`. */
74
63
  readonly shell?: () => string | null;
64
+ readonly label?: string;
65
+ readonly onRevoked?: () => void;
75
66
  };
76
67
 
77
- /**
78
- * Opens the Node host the sdk's Node `open` calls: `composeHost` with
79
- * `home` (default `os.homedir()`), `store` (default `<home>/.nyte/nyte.db`,
80
- * checked every 500 ms for another process's commits, which are then read so
81
- * a second process on the file sees the first one's runs live),
82
- * `workspace` (default the local folder `process.cwd()`), `secrets` (default
83
- * `defaultSecrets(home)`), `models` (default `nyteModels`), `localProvider`
84
- * unless a plugin provides the `local` environment, every first-party plugin
85
- * of `@nyte-ai/plugin` that `plugins` does not replace by id, then `plugins`
86
- * and `filePlugins`' files, `settingsSeed`'s seed and
87
- * `hostDescription()`; each file that did not load becomes a
88
- * `server.report` row. The host's `loadPlugins` is `pluginLoader` over
89
- * the plugin directories (§47l), which `watchPlugins` watches for hot reload
90
- * (§49a). `listen` defaults `identity` to `identitySigner(host.hostId, home)`
91
- * and `describe` to the description; nothing here reads the secrets key, even
92
- * with a credential stored (§50d, §55). `close()` stops the watch, closes the
93
- * open listeners, the host, then a store it opened.
94
- */
95
68
  export async function openHost(options: OpenHostOptions): Promise<Host> {
96
69
  const home = options.home ?? homedir();
97
70
  const workspace = options.workspace ?? { kind: "local", path: process.cwd() };
@@ -109,9 +82,10 @@ export async function openHost(options: OpenHostOptions): Promise<Host> {
109
82
  }
110
83
 
111
84
  async function defaultStore(home: string): Promise<Store> {
112
- await mkdir(join(home, ".nyte"), { recursive: true, mode: 0o700 });
85
+ const path = storePath(home);
86
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
113
87
 
114
- return sqliteStore({ path: join(home, ".nyte", "nyte.db") });
88
+ return sqliteStore({ path });
115
89
  }
116
90
 
117
91
  async function composeOpen(
@@ -156,7 +130,6 @@ async function composeOpen(
156
130
  openaiAstraContext,
157
131
  telemetry,
158
132
  canvas,
159
- browserTools,
160
133
  claudeCode.plugin,
161
134
  acp.plugin,
162
135
  ];
@@ -179,6 +152,7 @@ async function composeOpen(
179
152
  models,
180
153
  mcp: options.mcp ?? createMcpHub({ home }),
181
154
  github: options.github ?? (usesLocalProvider ? localGitHubAccount({ home }) : undefined),
155
+ images: options.images ?? nodeImages(),
182
156
  plugins: [...plugins, ...files.plugins],
183
157
  pluginSources: files.sources,
184
158
  secrets,
@@ -186,6 +160,12 @@ async function composeOpen(
186
160
  description,
187
161
  ...(settings.seed === null ? {} : { seed: settings.seed }),
188
162
  ...(owned === null ? {} : { poll: STORE_POLL_MS }),
163
+ member: {
164
+ label: options.label ?? "nyte",
165
+ pid: process.pid,
166
+ capabilities: given.some((plugin) => plugin.id === browserTools.id) ? [browserTools.id] : [],
167
+ },
168
+ revoked: options.onRevoked,
189
169
  });
190
170
 
191
171
  projectDirs = async () =>
@@ -281,7 +261,6 @@ async function nyteModels(input: {
281
261
  readonly store: Store;
282
262
  readonly secrets: SecretsSource;
283
263
  readonly home: string;
284
- /** Providers beside Nyte's catalog, each refreshed offline at open. */
285
264
  readonly providers: readonly Provider[];
286
265
  }): Promise<Models> {
287
266
  const credentials = credentialStore(input.store, input.secrets);
@@ -302,6 +281,7 @@ async function nyteModels(input: {
302
281
  });
303
282
 
304
283
  for (const provider of [...nyteProviders(), ...input.providers]) models.setProvider(provider);
284
+
305
285
  const [stored, selected] = await Promise.all([
306
286
  credentials.list(),
307
287
  input.store.open().then(readModelSelection),
@@ -193,6 +193,7 @@ export function bindConnection(connection: Connection, options: BindingOptions):
193
193
  return null;
194
194
  },
195
195
  "models.setDefault": ({ ref }) => connection.models.setDefault(ref),
196
+ "models.setListing": ({ refs, listing }) => connection.models.setListing(refs, listing),
196
197
  "models.verify": ({ provider }) => connection.models.verify(provider),
197
198
  "models.login": async (input) => {
198
199
  const { provider } = input;
@@ -384,9 +385,9 @@ export function bindConnection(connection: Connection, options: BindingOptions):
384
385
  "mcp.remove": ({ workspace: id, server, scope }) => workspace(id).mcp.remove(server, scope),
385
386
  "workspace.update": ({ workspace: id, patch }) => workspace(id).update(patch),
386
387
  "workspace.reload": ({ workspace: id }) => workspace(id).reload(),
387
- "workspace.changes": ({ workspace: id, run }) => workspace(id).changes(run),
388
- "workspace.patches": async ({ workspace: id, run, paths, options }) =>
389
- mapped(workspace(id).patches(run, paths, options), patchesWire),
388
+ "workspace.changes": ({ workspace: id, source }) => workspace(id).changes(source),
389
+ "workspace.patches": async ({ workspace: id, source, paths, options }) =>
390
+ mapped(workspace(id).patches(source, paths, options), patchesWire),
390
391
  "workspace.files": async ({ workspace: id }) => capabilityWire(await workspace(id).files()),
391
392
  "workspace.git": async ({ workspace: id }) => capabilityWire(await workspace(id).git()),
392
393
  "workspace.github": async ({ workspace: id }) => capabilityWire(await workspace(id).github()),
package/src/sqlite.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
+ import process from "node:process";
3
4
  import { DatabaseSync } from "node:sqlite";
4
5
  import type { SQLInputValue, StatementSync } from "node:sqlite";
5
6
  import { asSeq } from "@nyte-ai/schema";
@@ -34,7 +35,6 @@ import {
34
35
  } from "./store-rows.ts";
35
36
  import { sqliteWorkers } from "./sqlite-workers.ts";
36
37
  import { sessionIndexChanges, sqlSessionIndex } from "./session-index.ts";
37
- import { claimStore } from "./store-owner.ts";
38
38
 
39
39
  const BUSY_TIMEOUT_MS = 5_000;
40
40
 
@@ -43,16 +43,18 @@ const BACKUPS_KEPT = 3;
43
43
  const PUT_BODY =
44
44
  "INSERT INTO objects (id, body) VALUES (?, ?) ON CONFLICT (id) DO UPDATE SET body = excluded.body WHERE objects.body IS NULL";
45
45
 
46
- /** Returns a `Store` over one `node:sqlite` `DatabaseSync`: a file at `path` (WAL mode, opened only by the process that `claimStore` makes its owner, so a second process rejects with `SdkError("unavailable")` before it reads or migrates the file) or an in-memory database. The first `open()` brings it to core's `STORE_VERSION` with `migrateStore` (core's `objects`, `refs`, `log` and `store_meta` tables; each commit one `BEGIN IMMEDIATE`), a file store whose migration rewrites records first saving those refs' rows (name, target, revision; objects are never deleted, so the rows restore them) to `backups/store-<time>-<pid>-<uuid>-v<from>-v<to>.json` beside it, keeping the newest three backups, and rejects with `SdkError("unavailable")` for a store a newer Nyte migrated; a `migrations` table an older Nyte left is kept and ignored. `put` ids the value with core's `objectId` and stores its JSON once; `putBlob` buffers the stream, ids it with `blobId` and stores the bytes once; an object and a blob that share an id share the row, each column filled once (§41f); `blob` reads the bytes back with their size and a null mime. `refs.set` is one `BEGIN IMMEDIATE` transaction with `RefStore.set`'s semantics, each revision a random UUID, that first rejects with `SdkError("unavailable")` when a newer Nyte has migrated the store since it opened. `list(prefix)` reads the name range from `prefix` in name order (BINARY, by code point); `log` returns the events after `after` in seq order up to `limit`, each validated as a `SessionEvent`; `tail` is the session's last seq, or 0; `commitMark` is the writer connection's `PRAGMA data_version`, which moves only for another connection's commit. `checkpoints` keeps one fold checkpoint per session in `fold_checkpoints`, a write at a lower seq leaving the kept one, and `gap` counts a session's events past a seq and sums their UTF-8 bytes. File stores run `list` and `log` on a read-only worker and WAL checkpoints on a separate worker, checking at 1,000 frames and after 100 ms idle with a 10,000-frame writer backstop; unavailable workers warn and fall back to the main connection. Checkpoints use fullfsync on macOS, WAL reuse retains at most 32 MiB, and opening refreshes planner statistics. Writes binding more than 64 KiB use uncached statements. Every `open()` returns a connection over the one writer; `close()` waits for opening and worker shutdown before closing it, and an open during close waits to reopen. */
46
+ /** The local store's file: `NYTE_DB` when set, so a source run keeps its own chats, else `.nyte/nyte.db` under `home`. */
47
+ export function storePath(home: string): string {
48
+ return process.env["NYTE_DB"] ?? join(home, ".nyte", "nyte.db");
49
+ }
50
+
47
51
  export function sqliteStore(input: { readonly path: string } | { readonly memory: true }): Store {
48
52
  let database: DatabaseSync | null = null;
49
53
  let opening: Promise<StoreConnection> | null = null;
50
54
  let closing: Promise<void> | null = null;
51
55
  let workers: ReturnType<typeof sqliteWorkers> | null = null;
52
- let release: (() => void) | null = null;
53
56
 
54
57
  const open = async (): Promise<StoreConnection> => {
55
- const claim = "path" in input ? claimStore(input.path) : null;
56
58
  let created: DatabaseSync | undefined;
57
59
 
58
60
  try {
@@ -69,13 +71,11 @@ export function sqliteStore(input: { readonly path: string } | { readonly memory
69
71
  created.exec("PRAGMA optimize = 0x10012");
70
72
  } catch (error) {
71
73
  created?.close();
72
- claim?.();
73
74
 
74
75
  throw error;
75
76
  }
76
77
 
77
78
  database = created;
78
- release = claim;
79
79
  workers = "path" in input ? sqliteWorkers(created, input.path) : null;
80
80
 
81
81
  return connect(created, workers);
@@ -98,11 +98,10 @@ export function sqliteStore(input: { readonly path: string } | { readonly memory
98
98
  closing ??= (async () => {
99
99
  await opening?.catch(() => undefined);
100
100
  await workers?.close();
101
- database?.close();
102
- release?.();
101
+
102
+ if (database !== null) closeDatabase(database);
103
103
  workers = null;
104
104
  database = null;
105
- release = null;
106
105
  opening = null;
107
106
  })().finally(() => {
108
107
  closing = null;
@@ -113,6 +112,17 @@ export function sqliteStore(input: { readonly path: string } | { readonly memory
113
112
  };
114
113
  }
115
114
 
115
+ function closeDatabase(database: DatabaseSync): void {
116
+ if ("bun" in process.versions) {
117
+ const empty = new DatabaseSync(":memory:");
118
+ empty.exec("CREATE TABLE closed (id INTEGER)");
119
+ database.deserialize(empty.serialize());
120
+ empty.close();
121
+ }
122
+
123
+ database.close();
124
+ }
125
+
116
126
  function hasTable(database: DatabaseSync, name: string): boolean {
117
127
  return (
118
128
  database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) !==
@@ -189,6 +199,7 @@ function sqliteTarget(database: DatabaseSync, path: string | null): MigrationTar
189
199
 
190
200
  return "conflict";
191
201
  }
202
+
192
203
  updateSessionIndex(database, moved.name, moved.id, mintRevision());
193
204
  }
194
205
 
@@ -308,6 +319,7 @@ function connect(
308
319
  );
309
320
 
310
321
  const deleteRef = database.prepare("DELETE FROM refs WHERE name = ?");
322
+ const audit = auditing(database);
311
323
  const append = database.prepare("INSERT INTO log (session, seq, at, event) VALUES (?, ?, ?, ?)");
312
324
 
313
325
  const readLog = database.prepare(
@@ -327,6 +339,7 @@ function connect(
327
339
  const readGap = database.prepare(
328
340
  "SELECT COUNT(*) AS events, COALESCE(SUM(LENGTH(CAST(event AS BLOB))), 0) AS bytes FROM log WHERE session = ? AND seq > ?",
329
341
  );
342
+
330
343
  const readVersion = database.prepare("SELECT version FROM store_meta WHERE id = 1");
331
344
  const readDataVersion = database.prepare("PRAGMA data_version");
332
345
 
@@ -422,11 +435,23 @@ function connect(
422
435
  }
423
436
 
424
437
  const touched = new Map<SessionId, Revision>();
438
+ const current = new Map([...before].map(([name, row]) => [name, row?.revision ?? null]));
439
+
425
440
  const revisions = updates.map((update) => {
426
441
  const revision = mintRevision();
427
442
 
428
443
  if (update.target === null) deleteRef.run(update.name);
429
444
  else putRef.run(update.name, update.target, revision);
445
+ audit?.run(
446
+ Date.now(),
447
+ process.pid,
448
+ fenceNames([update]).find((name) => name.startsWith("processes/")) ?? null,
449
+ update.name,
450
+ current.get(update.name) ?? null,
451
+ update.target === null ? null : revision,
452
+ update.target,
453
+ );
454
+ current.set(update.name, update.target === null ? null : revision);
430
455
  updateSessionIndex(database, update.name, update.target, revision);
431
456
  const session = parseSessionRef(update.name);
432
457
 
@@ -496,6 +521,17 @@ function connect(
496
521
  };
497
522
  }
498
523
 
524
+ function auditing(database: DatabaseSync): StatementSync | null {
525
+ if (process.env.NYTE_STORE_AUDIT !== "1") return null;
526
+ database.exec(
527
+ "CREATE TABLE IF NOT EXISTS store_audit (id INTEGER PRIMARY KEY, at INTEGER NOT NULL, pid INTEGER NOT NULL, writer TEXT, name TEXT NOT NULL, before TEXT, after TEXT, target TEXT)",
528
+ );
529
+
530
+ return database.prepare(
531
+ "INSERT INTO store_audit (at, pid, writer, name, before, after, target) VALUES (?, ?, ?, ?, ?, ?, ?)",
532
+ );
533
+ }
534
+
499
535
  function updateSessionIndex(
500
536
  database: DatabaseSync,
501
537
  name: string,
@@ -503,6 +539,7 @@ function updateSessionIndex(
503
539
  revision: Revision,
504
540
  ): void {
505
541
  if (sessionIndexRef(name) === null) return;
542
+
506
543
  const body =
507
544
  target === null
508
545
  ? null
@@ -1,5 +1,4 @@
1
1
  import { existsSync } from "node:fs";
2
- import { join } from "node:path";
3
2
  import { DatabaseSync } from "node:sqlite";
4
3
  import {
5
4
  decodeObject,
@@ -17,6 +16,7 @@ import type {
17
16
  UsageTotals,
18
17
  UsageWindow,
19
18
  } from "./usage-types.ts";
19
+ import { storePath } from "./sqlite.ts";
20
20
  import { cacheSavings, usageTokens } from "./usage-summary.ts";
21
21
  import { decodeEvent, parseNumber, parseText } from "./store-rows.ts";
22
22
 
@@ -129,6 +129,7 @@ export function projectUsageReport(
129
129
 
130
130
  for (const spend of session.records) {
131
131
  const { usage } = spend;
132
+
132
133
  const amounts = [
133
134
  usage.total,
134
135
  usage.input,
@@ -201,7 +202,7 @@ export function projectUsageReport(
201
202
 
202
203
  export function readNyteUsage(home: string, window: UsageWindow): UsageReport {
203
204
  const readAt = Date.now();
204
- const path = join(home, ".nyte", "nyte.db");
205
+ const path = storePath(home);
205
206
 
206
207
  if (!existsSync(path)) return projectUsageReport([], window, readAt);
207
208
  let database: DatabaseSync | undefined;
@@ -268,11 +269,13 @@ export function readNyteUsage(home: string, window: UsageWindow): UsageReport {
268
269
  const session = parseSessionRef(`sessions/${parseText(row.session) ?? ""}`);
269
270
 
270
271
  if (session === null) throw new Error("Cannot read usage session.");
272
+
271
273
  const event = decodeEvent(
272
274
  parseNumber(row.seq),
273
275
  parseNumber(row.at),
274
276
  parseText(row.event) ?? "",
275
277
  );
278
+
276
279
  let heads = models.get(session);
277
280
 
278
281
  if (heads === undefined) {
@@ -0,0 +1,219 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { randomUUID } from "node:crypto";
3
+ import { constants } from "node:fs";
4
+ import type { Stats } from "node:fs";
5
+ import { mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
6
+ import { dirname } from "node:path";
7
+ import { Type, type TSchema } from "typebox";
8
+
9
+ export interface HistoryLine {
10
+ readonly text: string;
11
+ /** False for a last line without its newline: its writer may still be appending it. */
12
+ readonly terminated: boolean;
13
+ }
14
+
15
+ export interface HistoryFold<Scan> {
16
+ line(line: HistoryLine): void;
17
+ scan(): Scan;
18
+ }
19
+
20
+ /** Folds a history file's lines into a scan. `scan()` returns a copy, so folding goes on without changing it. */
21
+ export type HistoryParser<Scan> = (from: Scan | undefined) => HistoryFold<Scan>;
22
+
23
+ export interface FileScan<Scan> {
24
+ readonly size: number;
25
+ readonly mtimeMs: number;
26
+ /** The byte after the last newline, where a grown file resumes. */
27
+ readonly position: number;
28
+ /** The scan of every line up to `position`. */
29
+ readonly committed: Scan;
30
+ }
31
+
32
+ export function fileScanSchema<Scan extends TSchema>(scan: Scan) {
33
+ return Type.Object({
34
+ size: Type.Number({ minimum: 0 }),
35
+ mtimeMs: Type.Number(),
36
+ position: Type.Number({ minimum: 0 }),
37
+ committed: scan,
38
+ });
39
+ }
40
+
41
+ /** Scans keyed by path. A scan answers while its file keeps the size and mtime it was read at. */
42
+ export class UsageScanCache<Scan> {
43
+ readonly #files: Map<string, FileScan<Scan>>;
44
+ #changed = false;
45
+
46
+ constructor(files: Iterable<readonly [string, FileScan<Scan>]> = []) {
47
+ this.#files = new Map(files);
48
+ }
49
+
50
+ keys(): IterableIterator<string> {
51
+ return this.#files.keys();
52
+ }
53
+
54
+ entries(): IterableIterator<[string, FileScan<Scan>]> {
55
+ return this.#files.entries();
56
+ }
57
+
58
+ get(path: string): FileScan<Scan> | undefined {
59
+ return this.#files.get(path);
60
+ }
61
+
62
+ set(path: string, scan: FileScan<Scan>): void {
63
+ if (this.#files.get(path) === scan) return;
64
+ this.#files.set(path, scan);
65
+ this.#changed = true;
66
+ }
67
+
68
+ /** Forgets every file a read did not reach. */
69
+ keepOnly(seen: ReadonlySet<string>): void {
70
+ for (const path of this.#files.keys()) {
71
+ if (seen.has(path)) continue;
72
+ this.#files.delete(path);
73
+ this.#changed = true;
74
+ }
75
+ }
76
+
77
+ /** Whether a scan was added or forgotten since the last {@link markSaved}. */
78
+ get changed(): boolean {
79
+ return this.#changed;
80
+ }
81
+
82
+ markSaved(): void {
83
+ this.#changed = false;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Reads a history file through `cache`. An unchanged file answers from the cache without being
89
+ * opened, and a file that only grew is read from its last newline on; any other change rereads it.
90
+ * A last line without its newline is folded into the answer but not the cache, since its writer may
91
+ * still be appending it: the next read starts from that line again.
92
+ */
93
+ export async function readHistory<Scan>(
94
+ path: string,
95
+ info: Stats,
96
+ parser: HistoryParser<Scan>,
97
+ cache: UsageScanCache<Scan> | undefined,
98
+ signal: AbortSignal | undefined,
99
+ ): Promise<Scan> {
100
+ const hit = cache?.get(path);
101
+ const unchanged = hit !== undefined && hit.size === info.size && hit.mtimeMs === info.mtimeMs;
102
+
103
+ if (unchanged && hit.position === info.size) return hit.committed;
104
+ const from = unchanged || (hit !== undefined && info.size > hit.size) ? hit : undefined;
105
+ const fold = parser(from?.committed);
106
+ let position = from?.position ?? 0;
107
+ let unterminated: HistoryLine | undefined;
108
+
109
+ for await (const line of historyLines(path, position, signal)) {
110
+ signal?.throwIfAborted();
111
+
112
+ if (!line.terminated) {
113
+ unterminated = line;
114
+ continue;
115
+ }
116
+
117
+ fold.line(line);
118
+ position = line.end;
119
+ }
120
+
121
+ const committed = fold.scan();
122
+ cache?.set(
123
+ path,
124
+ unchanged ? hit : { size: info.size, mtimeMs: info.mtimeMs, position, committed },
125
+ );
126
+
127
+ if (unterminated === undefined) return committed;
128
+ fold.line(unterminated);
129
+
130
+ return fold.scan();
131
+ }
132
+
133
+ /** Yields a history file's lines from byte `start` up to the size it had when opened, though its writer may keep appending. */
134
+ async function* historyLines(path: string, start: number, signal: AbortSignal | undefined) {
135
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
136
+
137
+ try {
138
+ const stat = await file.stat();
139
+
140
+ if (!stat.isFile()) throw new Error("History is not a regular file.");
141
+ const buffer = Buffer.alloc(64 * 1024);
142
+ let pending = Buffer.alloc(0);
143
+ let pendingStart = start;
144
+ let position = start;
145
+
146
+ while (position < stat.size) {
147
+ signal?.throwIfAborted();
148
+
149
+ const { bytesRead } = await file.read(
150
+ buffer,
151
+ 0,
152
+ Math.min(buffer.length, stat.size - position),
153
+ position,
154
+ );
155
+
156
+ if (bytesRead === 0) break;
157
+ position += bytesRead;
158
+ const read = buffer.subarray(0, bytesRead);
159
+ const chunk = pending.length === 0 ? read : Buffer.concat([pending, read]);
160
+ let from = 0;
161
+
162
+ // oxlint-disable-next-line nyte-code/no-index-of -- a byte search keeps offsets exact, which decoded text cannot
163
+ for (let newline = chunk.indexOf(0x0a); newline !== -1; newline = chunk.indexOf(0x0a, from)) {
164
+ yield {
165
+ text: chunk.toString("utf8", from, newline),
166
+ terminated: true,
167
+ end: pendingStart + newline + 1,
168
+ };
169
+ from = newline + 1;
170
+ }
171
+
172
+ pendingStart += from;
173
+ pending = Buffer.from(chunk.subarray(from));
174
+ }
175
+
176
+ if (pending.length > 0)
177
+ yield {
178
+ text: pending.toString("utf8"),
179
+ terminated: false,
180
+ end: pendingStart + pending.length,
181
+ };
182
+ } finally {
183
+ await file.close();
184
+ }
185
+ }
186
+
187
+ /** Reads a cache document written by {@link writeCacheDocument}; a missing, torn or outdated one reads as `undefined`. */
188
+ export async function readCacheDocument<Document>(
189
+ path: string,
190
+ validator: { Check(value: unknown): value is Document },
191
+ ): Promise<Document | undefined> {
192
+ try {
193
+ const value: unknown = JSON.parse(await readFile(path, "utf8"));
194
+
195
+ return validator.Check(value) ? value : undefined;
196
+ } catch {
197
+ return undefined;
198
+ }
199
+ }
200
+
201
+ /** Replaces the cache document whole, so a reader never sees half of one. Resolves whether the write landed. */
202
+ export async function writeCacheDocument<Document>(
203
+ path: string,
204
+ document: Document,
205
+ ): Promise<boolean> {
206
+ const temporary = `${path}.${randomUUID()}.tmp`;
207
+
208
+ try {
209
+ await mkdir(dirname(path), { recursive: true });
210
+ await writeFile(temporary, JSON.stringify(document), { mode: 0o600 });
211
+ await rename(temporary, path);
212
+
213
+ return true;
214
+ } catch {
215
+ await rm(temporary, { force: true }).catch(() => undefined);
216
+
217
+ return false;
218
+ }
219
+ }
@@ -5,7 +5,12 @@ import type { Models } from "@nyte-ai/ai";
5
5
  import { serial } from "@nyte-ai/schema/serial";
6
6
  import type { Api, Model } from "@nyte-ai/schema/model";
7
7
  import { readNyteUsage } from "./store-usage.ts";
8
- import { createUsageScanCaches, readLocalUsage } from "./usage.ts";
8
+ import {
9
+ loadUsageScanCaches,
10
+ readLocalUsage,
11
+ saveUsageScanCaches,
12
+ type UsageScanCaches,
13
+ } from "./usage.ts";
9
14
  import type { UsageSnapshot, UsageWindow } from "./usage-types.ts";
10
15
 
11
16
  export interface UsageScanRequest {
@@ -26,12 +31,14 @@ export function catalogForUsage(models: Pick<Models, "getModels">): readonly Mod
26
31
 
27
32
  export class UsageScanner implements UsageScanReader {
28
33
  private readonly home: string;
29
- private readonly caches = createUsageScanCaches();
34
+ private readonly cachePath: string;
35
+ private caches: UsageScanCaches | undefined;
30
36
  private readonly models: ReturnType<typeof createModels>;
31
37
  private readonly scans = serial();
32
38
 
33
39
  constructor(home: string) {
34
40
  this.home = home;
41
+ this.cachePath = join(home, ".nyte", "usage-scan-cache.json");
35
42
  this.models = createModels({
36
43
  modelsStore: new FileModelsStore(join(home, ".nyte", "models-store.json")),
37
44
  });
@@ -56,8 +63,10 @@ export class UsageScanner implements UsageScanReader {
56
63
  catalog.filter((model) => provider === undefined || model.provider === provider),
57
64
  };
58
65
 
59
- const local = await readLocalUsage({ models, caches: this.caches });
66
+ const caches = (this.caches ??= await loadUsageScanCaches(this.cachePath));
67
+ const local = await readLocalUsage({ models, window: request.window, caches });
60
68
  const report = readNyteUsage(this.home, request.window);
69
+ void this.scans.run(() => saveUsageScanCaches(this.cachePath, caches));
61
70
 
62
71
  return {
63
72
  ...report,