@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.
- package/THIRD-PARTY-NOTICES.md +66 -0
- package/dist/chunks/{canvas-preview-rp6fz0w5.js → canvas-preview-2jwbe14k.js} +5 -5
- package/dist/chunks/{canvas-preview-j1tnf51w.js → canvas-preview-3a6tst4d.js} +1 -1
- package/dist/chunks/{canvas-preview-m02v4yy6.js → canvas-preview-55s0y68g.js} +3 -2
- package/dist/chunks/{canvas-preview-xn10d4f8.js → canvas-preview-5ekjx6sw.js} +9 -4
- package/dist/chunks/{canvas-preview-g3qj3y3k.js → canvas-preview-8hr08aeh.js} +13 -3
- package/dist/chunks/{canvas-preview-75db60xy.js → canvas-preview-8xc0vn7v.js} +5 -4
- package/dist/chunks/{canvas-preview-zn2d0k9d.js → canvas-preview-af86jz8t.js} +14 -6
- package/dist/chunks/{canvas-preview-d9wwhbtz.js → canvas-preview-aq5j4p25.js} +1 -1
- package/dist/chunks/{canvas-preview-ewgspmsd.js → canvas-preview-b3r1tna2.js} +1 -1
- package/dist/chunks/canvas-preview-befkjea2.js +60 -0
- package/dist/chunks/canvas-preview-bxnqt8cd.js +103 -0
- package/dist/chunks/{canvas-preview-senpjwep.js → canvas-preview-crmzxxt8.js} +3 -3
- package/dist/chunks/{canvas-preview-ymgz0937.js → canvas-preview-drctegz4.js} +8 -4
- package/dist/chunks/{canvas-preview-yrfz2ak6.js → canvas-preview-e3vnkkc5.js} +6 -6
- package/dist/chunks/canvas-preview-ec2mt7es.js +108 -0
- package/dist/chunks/{canvas-preview-0nrnbt76.js → canvas-preview-eehzfefa.js} +199 -125
- package/dist/chunks/{canvas-preview-5hxat7hh.js → canvas-preview-geg2gckp.js} +8 -6
- package/dist/chunks/{canvas-preview-8xgt67ka.js → canvas-preview-gh7c9831.js} +20 -16
- package/dist/chunks/{canvas-preview-1ta3avah.js → canvas-preview-k2xaz0ym.js} +10 -5
- package/dist/chunks/canvas-preview-kp75gb63.js +141 -0
- package/dist/chunks/{canvas-preview-evh92ywd.js → canvas-preview-mstbvwz3.js} +26 -11
- package/dist/chunks/{canvas-preview-h8hshnff.js → canvas-preview-pkzqg96v.js} +1 -1
- package/dist/chunks/{canvas-preview-ehf3vp31.js → canvas-preview-vm9zdp2t.js} +45 -58
- package/dist/chunks/{canvas-preview-76fwvd6e.js → canvas-preview-w54ymtb3.js} +1 -1
- package/dist/chunks/canvas-preview-y5y6ghaw.js +125 -0
- package/dist/chunks/{canvas-preview-vax9ne4e.js → canvas-preview-y7kq0c8q.js} +22 -11
- package/dist/chunks/{canvas-preview-98aj5p34.js → canvas-preview-ycepb05s.js} +1 -1
- package/dist/src/canvas-preview.js +7 -3
- package/dist/src/deploy/cloudflare/worker.js +1 -1
- package/dist/src/deploy/vercel/server/routes/[...].js +2 -2
- package/dist/src/deploy/vercel/server/utils/host.js +1 -1
- package/dist/src/deploy/vercel/server/workflows/drive.js +3 -3
- package/dist/src/images/exif.d.ts +1 -0
- package/dist/src/images/exif.js +6 -0
- package/dist/src/images/node.d.ts +37 -0
- package/dist/src/images/node.js +12 -0
- package/dist/src/images/photon.d.ts +3 -0
- package/dist/src/images/photon.js +7 -0
- package/dist/src/images/worker.d.ts +1 -0
- package/dist/src/images/worker.js +23 -0
- package/dist/src/index.d.ts +0 -1
- package/dist/src/index.js +23 -22
- package/dist/src/listen-fetch.js +3 -3
- package/dist/src/listen.js +4 -4
- package/dist/src/local/command.d.ts +5 -0
- package/dist/src/local/command.js +6 -2
- package/dist/src/local/files.js +2 -2
- package/dist/src/local/git-watch.d.ts +1 -1
- package/dist/src/local/git-watch.js +1 -1
- package/dist/src/local/git.js +3 -3
- package/dist/src/local/github.js +2 -2
- package/dist/src/local/jobs.d.ts +1 -1
- package/dist/src/local/jobs.js +2 -1
- package/dist/src/local/provider.js +9 -9
- package/dist/src/local/snapshots.js +2 -2
- package/dist/src/local/worktrees.js +2 -2
- package/dist/src/mcp/connect.d.ts +0 -13
- package/dist/src/mcp/connect.js +1 -1
- package/dist/src/mcp/hub.js +2 -2
- package/dist/src/open.d.ts +2 -30
- package/dist/src/open.js +20 -18
- package/dist/src/serve/binding.js +1 -1
- package/dist/src/serve/handler.js +2 -2
- package/dist/src/sqlite.d.ts +2 -1
- package/dist/src/sqlite.js +4 -3
- package/dist/src/store-usage.js +4 -1
- package/dist/src/usage-history.d.ts +54 -0
- package/dist/src/usage-history.js +14 -0
- package/dist/src/usage-service.d.ts +2 -1
- package/dist/src/usage-service.js +7 -3
- package/dist/src/usage-worker.js +8 -4
- package/dist/src/usage.d.ts +150 -27
- package/dist/src/usage.js +12 -2
- package/package.json +16 -8
- package/src/canvas-preview.ts +4 -3
- package/src/images/exif.ts +69 -0
- package/src/images/node.ts +163 -0
- package/src/images/photon.ts +141 -0
- package/src/images/worker.ts +29 -0
- package/src/index.ts +19 -1
- package/src/local/command.ts +64 -12
- package/src/local/files.ts +8 -3
- package/src/local/git-watch.ts +16 -6
- package/src/local/git.ts +7 -2
- package/src/local/github.ts +3 -4
- package/src/local/jobs.ts +55 -69
- package/src/local/snapshots.ts +4 -2
- package/src/local/worktrees.ts +4 -4
- package/src/mcp/connect.ts +1 -14
- package/src/mcp/hub.ts +18 -9
- package/src/open.ts +16 -36
- package/src/serve/binding.ts +4 -3
- package/src/sqlite.ts +46 -9
- package/src/store-usage.ts +5 -2
- package/src/usage-history.ts +219 -0
- package/src/usage-service.ts +12 -3
- package/src/usage.ts +251 -176
- package/dist/chunks/canvas-preview-a3yw0j70.js +0 -72
- package/dist/chunks/canvas-preview-azg56359.js +0 -68
- package/dist/src/store-owner.d.ts +0 -15
- package/dist/src/store-owner.js +0 -9
- 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
|
-
|
|
85
|
+
const path = storePath(home);
|
|
86
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
113
87
|
|
|
114
|
-
return sqliteStore({ path
|
|
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),
|
package/src/serve/binding.ts
CHANGED
|
@@ -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,
|
|
388
|
-
"workspace.patches": async ({ workspace: id,
|
|
389
|
-
mapped(workspace(id).patches(
|
|
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
|
-
/**
|
|
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
|
-
|
|
102
|
-
|
|
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
|
package/src/store-usage.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|
package/src/usage-service.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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,
|