@rynx-ai/daemon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Plugin installation — from npm or a local directory.
3
+ *
4
+ * No trust mechanism: installing runs the plugin's code in the daemon process.
5
+ * The user owns that risk.
6
+ *
7
+ * - **npm**: `npm install <spec>` into `~/.rynx/plugins`, then record it.
8
+ * - **local**: register an absolute directory and load it in place (no copy;
9
+ * supports local development / offline). The directory must be a
10
+ * ready package (built, deps installed, exporting `plugin`).
11
+ *
12
+ * After recording, the channel-type catalog is rebuilt so a freshly-installed
13
+ * plugin's types are usable without a daemon restart.
14
+ */
15
+ import { spawn } from "node:child_process";
16
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { getPlugin, removePlugin, upsertPlugin, } from "./plugin-store.js";
21
+ import { importPluginManifest, pluginPkgDir, pluginsDir } from "./registry.js";
22
+ /** A spec is "local" if it's a path/file: URL or an existing directory. */
23
+ function detectSource(spec) {
24
+ if (spec.startsWith("file:"))
25
+ return "local";
26
+ if (spec.startsWith("~") || spec.startsWith("./") || spec.startsWith("../") || isAbsolute(spec)) {
27
+ return "local";
28
+ }
29
+ try {
30
+ if (existsSync(spec) && statSync(spec).isDirectory())
31
+ return "local";
32
+ }
33
+ catch {
34
+ /* not a path */
35
+ }
36
+ return "npm";
37
+ }
38
+ function toLocalDir(spec) {
39
+ let p = spec.startsWith("file:") ? fileURLToPath(spec) : spec;
40
+ if (p === "~" || p.startsWith("~/"))
41
+ p = join(homedir(), p.slice(1));
42
+ return resolve(p);
43
+ }
44
+ /** Package name from an npm spec, dropping any version range (`x@^1` → `x`). */
45
+ function npmPackageName(spec) {
46
+ const at = spec.lastIndexOf("@");
47
+ return at > 0 ? spec.slice(0, at) : spec;
48
+ }
49
+ /**
50
+ * Install (or re-install) a plugin from an npm spec or a local directory.
51
+ * - **local**: copy the package (minus `node_modules`) into `~/.rynx/plugins/<name>`
52
+ * and load it there. The plugin must be self-contained — it bundles its deps at
53
+ * build time (incl. the unpublished workspace `@rynx-ai/core`) and declares a
54
+ * `rynx.plugin` entry; the daemon installs nothing.
55
+ * - **npm**: `npm install` into `~/.rynx/plugins` (deps resolved by npm).
56
+ *
57
+ * `onConflict` is consulted when a plugin of the same `name` already exists; if it
58
+ * returns false the install is aborted with `{ skipped: true }` (nothing changes).
59
+ * `onProgress` streams human-readable lines.
60
+ */
61
+ export async function installPlugin(spec, onProgress = () => { }, opts = {}) {
62
+ const source = detectSource(spec);
63
+ let pkgDir;
64
+ let storedSpec;
65
+ if (source === "local") {
66
+ const srcDir = toLocalDir(spec);
67
+ if (!existsSync(join(srcDir, "package.json")))
68
+ throw new Error(`no package.json at ${srcDir}`);
69
+ const name = (await importPluginManifest(srcDir))?.name;
70
+ if (!name)
71
+ throw new Error(`package at ${srcDir} has no valid \`plugin\` export`);
72
+ if (await declined(name, opts.onConflict))
73
+ return { name, source, skipped: true };
74
+ const dest = join(pluginsDir(), name);
75
+ ensurePluginsRoot(pluginsDir());
76
+ rmSync(dest, { recursive: true, force: true });
77
+ // A self-contained plugin bundles its deps, so node_modules is never copied.
78
+ cpSync(srcDir, dest, { recursive: true, filter: (p) => basename(p) !== "node_modules" });
79
+ onProgress(`copied local plugin "${name}" → ${dest}`);
80
+ pkgDir = dest;
81
+ storedSpec = dest;
82
+ }
83
+ else {
84
+ const dir = pluginsDir();
85
+ ensurePluginsRoot(dir);
86
+ onProgress(`npm install ${spec}`);
87
+ await runNpm(["install", spec, "--prefix", dir, "--no-audit", "--no-fund", "--save"], onProgress, opts.signal);
88
+ storedSpec = npmPackageName(spec);
89
+ pkgDir = pluginPkgDir({ source, spec: storedSpec });
90
+ const name = (await importPluginManifest(pkgDir))?.name;
91
+ if (!name)
92
+ throw new Error(`package at ${pkgDir} has no valid \`plugin\` export`);
93
+ if (await declined(name, opts.onConflict))
94
+ return { name, source, skipped: true };
95
+ }
96
+ const manifest = await importPluginManifest(pkgDir);
97
+ if (!manifest || typeof manifest.name !== "string" || !manifest.name) {
98
+ throw new Error(`package at ${pkgDir} has no valid \`plugin\` export`);
99
+ }
100
+ const version = readVersion(pkgDir);
101
+ upsertPlugin({ name: manifest.name, spec: storedSpec, source, version });
102
+ onProgress(`installed plugin "${manifest.name}"${version ? `@${version}` : ""}`);
103
+ return { name: manifest.name, source, version };
104
+ }
105
+ /** A same-named plugin already exists and `onConflict` says don't overwrite. */
106
+ async function declined(name, onConflict) {
107
+ if (!onConflict)
108
+ return false;
109
+ const existing = getPlugin(name);
110
+ if (!existing)
111
+ return false;
112
+ return !(await onConflict(existing));
113
+ }
114
+ /** Uninstall a plugin: drop its record, npm-uninstall if applicable, refresh catalog. */
115
+ export async function uninstallPlugin(name, onProgress = () => { }) {
116
+ const rec = getPlugin(name);
117
+ if (!rec)
118
+ return false;
119
+ if (rec.source === "npm") {
120
+ try {
121
+ await runNpm(["uninstall", rec.spec, "--prefix", pluginsDir(), "--no-audit", "--no-fund"], onProgress);
122
+ }
123
+ catch (error) {
124
+ onProgress(`npm uninstall warning: ${messageOf(error)}`);
125
+ }
126
+ }
127
+ else {
128
+ // local: remove the copied package dir — but only if it lives under our plugins
129
+ // root (older records may point at an in-place source tree; never delete that).
130
+ const dir = pluginPkgDir(rec);
131
+ if (dirname(dir) === pluginsDir()) {
132
+ try {
133
+ rmSync(dir, { recursive: true, force: true });
134
+ onProgress(`removed ${dir}`);
135
+ }
136
+ catch (error) {
137
+ onProgress(`remove warning: ${messageOf(error)}`);
138
+ }
139
+ }
140
+ }
141
+ removePlugin(name);
142
+ onProgress(`uninstalled plugin "${name}"`);
143
+ return true;
144
+ }
145
+ function ensurePluginsRoot(dir) {
146
+ mkdirSync(dir, { recursive: true });
147
+ const pkg = join(dir, "package.json");
148
+ if (!existsSync(pkg)) {
149
+ writeFileSync(pkg, `${JSON.stringify({ name: "rynx-plugins", private: true, version: "0.0.0" }, null, 2)}\n`);
150
+ }
151
+ }
152
+ function readVersion(pkgDir) {
153
+ try {
154
+ return JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"))
155
+ .version;
156
+ }
157
+ catch {
158
+ return undefined;
159
+ }
160
+ }
161
+ function runNpm(args, onProgress, signal) {
162
+ return new Promise((resolveDone, reject) => {
163
+ const child = spawn("npm", args, { stdio: ["ignore", "pipe", "pipe"] });
164
+ const abort = () => child.kill();
165
+ signal?.addEventListener("abort", abort, { once: true });
166
+ const onData = (buf) => {
167
+ for (const line of buf.toString().split(/\r?\n/)) {
168
+ if (line.trim())
169
+ onProgress(line);
170
+ }
171
+ };
172
+ child.stdout?.on("data", onData);
173
+ child.stderr?.on("data", onData);
174
+ child.on("error", reject);
175
+ child.on("close", (code) => {
176
+ signal?.removeEventListener("abort", abort);
177
+ if (code === 0)
178
+ resolveDone();
179
+ else
180
+ reject(new Error(`npm exited with code ${code ?? "null"}`));
181
+ });
182
+ });
183
+ }
184
+ function messageOf(error) {
185
+ return error instanceof Error ? error.message : String(error);
186
+ }
@@ -0,0 +1,27 @@
1
+ export type PluginSource = "npm" | "local";
2
+ export interface PluginRecord {
3
+ /** Canonical plugin name (short id, e.g. `lark`); primary key. */
4
+ name: string;
5
+ /** Install spec: an npm package spec, or an absolute local directory. */
6
+ spec: string;
7
+ source: PluginSource;
8
+ version?: string;
9
+ enabled: boolean;
10
+ installedAt: string;
11
+ }
12
+ /** All installed plugins, enabled or not. */
13
+ export declare function listPlugins(): PluginRecord[];
14
+ /** Only enabled plugins — the set the loader imports. */
15
+ export declare function listEnabledPlugins(): PluginRecord[];
16
+ export declare function getPlugin(name: string): PluginRecord | undefined;
17
+ /** Insert or replace a plugin record (idempotent install). */
18
+ export declare function upsertPlugin(rec: {
19
+ name: string;
20
+ spec: string;
21
+ source: PluginSource;
22
+ version?: string;
23
+ enabled?: boolean;
24
+ }): void;
25
+ export declare function setPluginEnabled(name: string, enabled: boolean): void;
26
+ /** Remove a plugin record. Returns false if it wasn't present. */
27
+ export declare function removePlugin(name: string): boolean;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * CRUD over the `plugins` table — the registry of installed plugins the loader
3
+ * imports at boot. A plugin is installed either from npm (`source: "npm"`, into
4
+ * `~/.rynx/plugins`) or from a local directory (`source: "local"`, loaded in
5
+ * place). The daemon ships with none; channel plugins are installed by the user.
6
+ */
7
+ import { db } from "./db.js";
8
+ function toRecord(row) {
9
+ return {
10
+ name: row.name,
11
+ spec: row.spec,
12
+ source: row.source,
13
+ version: row.version ?? undefined,
14
+ enabled: row.enabled !== 0,
15
+ installedAt: row.installed_at,
16
+ };
17
+ }
18
+ /** All installed plugins, enabled or not. */
19
+ export function listPlugins() {
20
+ const rows = db().prepare("SELECT * FROM plugins ORDER BY name").all();
21
+ return rows.map(toRecord);
22
+ }
23
+ /** Only enabled plugins — the set the loader imports. */
24
+ export function listEnabledPlugins() {
25
+ return listPlugins().filter((p) => p.enabled);
26
+ }
27
+ export function getPlugin(name) {
28
+ const row = db().prepare("SELECT * FROM plugins WHERE name = ?").get(name);
29
+ return row ? toRecord(row) : undefined;
30
+ }
31
+ /** Insert or replace a plugin record (idempotent install). */
32
+ export function upsertPlugin(rec) {
33
+ db()
34
+ .prepare(`INSERT INTO plugins (name, spec, source, version, enabled, installed_at)
35
+ VALUES (@name, @spec, @source, @version, @enabled, @installedAt)
36
+ ON CONFLICT(name) DO UPDATE SET
37
+ spec = excluded.spec,
38
+ source = excluded.source,
39
+ version = excluded.version,
40
+ enabled = excluded.enabled`)
41
+ .run({
42
+ name: rec.name,
43
+ spec: rec.spec,
44
+ source: rec.source,
45
+ version: rec.version ?? null,
46
+ enabled: rec.enabled === false ? 0 : 1,
47
+ installedAt: new Date().toISOString(),
48
+ });
49
+ }
50
+ export function setPluginEnabled(name, enabled) {
51
+ db().prepare("UPDATE plugins SET enabled = ? WHERE name = ?").run(enabled ? 1 : 0, name);
52
+ }
53
+ /** Remove a plugin record. Returns false if it wasn't present. */
54
+ export function removePlugin(name) {
55
+ return db().prepare("DELETE FROM plugins WHERE name = ?").run(name).changes > 0;
56
+ }
package/dist/pm2.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Build a dialable control-console URL from the daemon's bind host/port. Bind-all
3
+ * addresses (`0.0.0.0`, `::`) aren't dialable, so show a loopback host instead.
4
+ */
5
+ export declare function formatControlUrl(host: string, port: number): string;
6
+ /**
7
+ * Start (or, with `force`, reload) the resident daemon. We recreate the pm2
8
+ * entry from scratch (delete + start) so config flags are always fresh; the
9
+ * `force === false` path short-circuits when it's already online.
10
+ */
11
+ export declare function startDaemon({ force }: {
12
+ force: boolean;
13
+ }): number;
14
+ /** Stop and remove the daemon from pm2's process list. */
15
+ export declare function stopDaemon(): number;
16
+ /** Print a one-line status; exit 0 only when online. */
17
+ export declare function statusDaemon(): number;
18
+ /** Stream the daemon's pm2 logs until the user detaches (Ctrl-C). */
19
+ export declare function streamLogs(): void;
package/dist/pm2.js ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Thin wrapper that supervises the resident daemon via pm2.
3
+ *
4
+ * We drive pm2 through its CLI binary (resolved from the installed `pm2`
5
+ * package) rather than its programmatic API — that keeps us free of pm2's
6
+ * patchy type definitions under `strict`, and `pm2 logs` streaming is awkward
7
+ * via the API anyway. Process config is passed inline as flags, so there is no
8
+ * shipped `ecosystem.cjs` to keep in sync.
9
+ */
10
+ import { spawn, spawnSync } from "node:child_process";
11
+ import { mkdirSync } from "node:fs";
12
+ import { createRequire } from "node:module";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { loadConfig } from "@rynx-ai/core";
17
+ const require = createRequire(import.meta.url);
18
+ /** pm2 app name — single resident daemon per machine. */
19
+ const APP_NAME = "rynx";
20
+ /**
21
+ * Build a dialable control-console URL from the daemon's bind host/port. Bind-all
22
+ * addresses (`0.0.0.0`, `::`) aren't dialable, so show a loopback host instead.
23
+ */
24
+ export function formatControlUrl(host, port) {
25
+ const dialable = host === "0.0.0.0" || host === "::" || host.trim() === "" ? "localhost" : host;
26
+ return `http://${dialable}:${port}/`;
27
+ }
28
+ /** Resolve the URL the daemon will serve the control console at (best-effort). */
29
+ function controlConsoleUrl() {
30
+ try {
31
+ const config = loadConfig();
32
+ return formatControlUrl(config.HOST, config.PORT);
33
+ }
34
+ catch {
35
+ return formatControlUrl("0.0.0.0", 3000); // defaults when env can't be parsed
36
+ }
37
+ }
38
+ /**
39
+ * Highlight the URL bold + underlined bright magenta (matching the console's
40
+ * purple brand) so it stands out as a link. Plain text when stdout isn't a TTY,
41
+ * so piped/redirected output stays free of escape codes.
42
+ */
43
+ function styleUrl(url) {
44
+ return process.stdout.isTTY ? `\x1b[1;4;95m${url}\x1b[0m` : url;
45
+ }
46
+ function pm2Bin() {
47
+ // pm2 ships its CLI at bin/pm2; run it with our own node.
48
+ return require.resolve("pm2/bin/pm2");
49
+ }
50
+ /** `index-daemon.js` sits next to this file in `dist/`. */
51
+ function daemonEntryPath() {
52
+ return join(dirname(fileURLToPath(import.meta.url)), "index-daemon.js");
53
+ }
54
+ function logsDir() {
55
+ const home = process.env.RYNX_HOME?.trim() || join(homedir(), ".rynx");
56
+ const dir = join(home, "logs");
57
+ mkdirSync(dir, { recursive: true });
58
+ return dir;
59
+ }
60
+ /** Run a pm2 command inheriting stdio; returns its exit code. */
61
+ function runPm2(args) {
62
+ const res = spawnSync(process.execPath, [pm2Bin(), ...args], {
63
+ stdio: "inherit",
64
+ env: process.env,
65
+ });
66
+ return res.status ?? 1;
67
+ }
68
+ /** Read the daemon's current pm2 record, or null if not registered. */
69
+ function describe() {
70
+ const res = spawnSync(process.execPath, [pm2Bin(), "jlist"], { encoding: "utf8" });
71
+ if (res.status !== 0 || !res.stdout)
72
+ return null;
73
+ let list;
74
+ try {
75
+ list = JSON.parse(res.stdout);
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ return list.find((proc) => proc.name === APP_NAME) ?? null;
81
+ }
82
+ /**
83
+ * Start (or, with `force`, reload) the resident daemon. We recreate the pm2
84
+ * entry from scratch (delete + start) so config flags are always fresh; the
85
+ * `force === false` path short-circuits when it's already online.
86
+ */
87
+ export function startDaemon({ force }) {
88
+ const existing = describe();
89
+ if (!force && existing?.pm2_env?.status === "online") {
90
+ console.log(`rynx is already running (pid ${existing.pid ?? "?"}). Use \`rynx restart\` to reload.`);
91
+ console.log(`Control console: ${styleUrl(controlConsoleUrl())}`);
92
+ return 0;
93
+ }
94
+ const dir = logsDir();
95
+ if (existing)
96
+ runPm2(["delete", APP_NAME]);
97
+ const status = runPm2([
98
+ "start",
99
+ daemonEntryPath(),
100
+ "--name",
101
+ APP_NAME,
102
+ // Resolve `.env` relative to where the user invoked `rynx start`.
103
+ "--cwd",
104
+ process.cwd(),
105
+ "--max-restarts",
106
+ "10",
107
+ "--restart-delay",
108
+ "3000",
109
+ "--output",
110
+ join(dir, "out.log"),
111
+ "--error",
112
+ join(dir, "err.log"),
113
+ "--merge-logs",
114
+ "--time",
115
+ ]);
116
+ if (status === 0) {
117
+ console.log(`rynx ${force ? "restarted" : "started"} (pm2 app "${APP_NAME}", logs: ${dir}).`);
118
+ console.log(`Control console: ${styleUrl(controlConsoleUrl())}`);
119
+ }
120
+ return status;
121
+ }
122
+ /** Stop and remove the daemon from pm2's process list. */
123
+ export function stopDaemon() {
124
+ if (!describe()) {
125
+ console.log("rynx is not running.");
126
+ return 0;
127
+ }
128
+ const status = runPm2(["delete", APP_NAME]);
129
+ if (status === 0)
130
+ console.log("rynx stopped.");
131
+ return status;
132
+ }
133
+ /** Print a one-line status; exit 0 only when online. */
134
+ export function statusDaemon() {
135
+ const app = describe();
136
+ if (!app) {
137
+ console.log("rynx: stopped (not registered with pm2)");
138
+ return 1;
139
+ }
140
+ const state = app.pm2_env?.status ?? "unknown";
141
+ const pid = app.pid ? ` pid=${app.pid}` : "";
142
+ const restarts = app.pm2_env?.restart_time != null ? ` restarts=${app.pm2_env.restart_time}` : "";
143
+ console.log(`rynx: ${state}${pid}${restarts}`);
144
+ return state === "online" ? 0 : 1;
145
+ }
146
+ /** Stream the daemon's pm2 logs until the user detaches (Ctrl-C). */
147
+ export function streamLogs() {
148
+ const child = spawn(process.execPath, [pm2Bin(), "logs", APP_NAME, "--lines", "50"], {
149
+ stdio: "inherit",
150
+ env: process.env,
151
+ });
152
+ child.on("exit", (code) => process.exit(code ?? 0));
153
+ }
@@ -0,0 +1,23 @@
1
+ import { type ChannelInstanceDescriptor, type ChannelTypeDescriptor, type RynxPlugin } from "@rynx-ai/core";
2
+ import { type PluginRecord } from "./plugin-store.js";
3
+ /** Directory npm-installed plugins live under (override via `RYNX_PLUGINS_DIR`). */
4
+ export declare function pluginsDir(): string;
5
+ /** All channel types currently available (from loaded plugins). */
6
+ export declare function listChannelTypes(): ChannelTypeDescriptor[];
7
+ export declare function getChannelType(type: string): ChannelTypeDescriptor | undefined;
8
+ /** Absolute package directory a plugin record resolves to. */
9
+ export declare function pluginPkgDir(rec: Pick<PluginRecord, "source" | "spec">): string;
10
+ /** Import a package directory's `plugin` manifest export (or null if absent). */
11
+ export declare function importPluginManifest(pkgDir: string): Promise<RynxPlugin | null>;
12
+ /**
13
+ * Rebuild the channel-type catalog from the enabled plugins in the registry.
14
+ * Call at boot and after an install/enable/disable change. Failures to load a
15
+ * single plugin are warned and skipped — one bad plugin can't break the rest.
16
+ */
17
+ export declare function loadPlugins(warn?: (message: string) => void): Promise<void>;
18
+ /**
19
+ * Resolve every enabled instance (joined with its channel) to a mountable
20
+ * descriptor, binding each to the factory its channel `type` resolves to in the
21
+ * catalog. Unknown types (no plugin providing them) are skipped.
22
+ */
23
+ export declare function resolveChannelInstances(warn?: (message: string) => void): Promise<ChannelInstanceDescriptor[]>;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Plugin loader + channel-type catalog.
3
+ *
4
+ * There are no built-in channels: the daemon imports whatever plugins are
5
+ * recorded (enabled) in the `plugins` table, reads each one's `plugin` manifest,
6
+ * and collects the channel types they contribute into an in-memory catalog.
7
+ * `loadPlugins()` (re)builds the catalog from the registry; everything else
8
+ * reads the catalog synchronously. The installer calls `loadPlugins()` after a
9
+ * change so a freshly-installed plugin's types appear without a restart.
10
+ */
11
+ import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { pathToFileURL } from "node:url";
14
+ import { rynxHome, } from "@rynx-ai/core";
15
+ import { listEnabledDescriptors } from "./instance-store.js";
16
+ import { listEnabledPlugins } from "./plugin-store.js";
17
+ /** Directory npm-installed plugins live under (override via `RYNX_PLUGINS_DIR`). */
18
+ export function pluginsDir() {
19
+ return process.env.RYNX_PLUGINS_DIR?.trim() || join(rynxHome(), "plugins");
20
+ }
21
+ /** type → descriptor, rebuilt by {@link loadPlugins}. */
22
+ const catalog = new Map();
23
+ /** All channel types currently available (from loaded plugins). */
24
+ export function listChannelTypes() {
25
+ return [...catalog.values()];
26
+ }
27
+ export function getChannelType(type) {
28
+ return catalog.get(type);
29
+ }
30
+ /** Resolve a plugin record to the file URL of its entry module. */
31
+ function entryUrl(pkgDir) {
32
+ const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
33
+ let rel = pkg.rynx?.plugin;
34
+ if (!rel) {
35
+ const dot = pkg.exports && typeof pkg.exports === "object" && "." in pkg.exports
36
+ ? pkg.exports["."]
37
+ : pkg.exports;
38
+ if (typeof dot === "string") {
39
+ rel = dot;
40
+ }
41
+ else if (dot && typeof dot === "object") {
42
+ const cond = dot;
43
+ rel = cond.import ?? cond.default ?? cond.require;
44
+ }
45
+ rel = rel ?? pkg.main ?? "index.js";
46
+ }
47
+ return pathToFileURL(join(pkgDir, rel)).href;
48
+ }
49
+ /** Absolute package directory a plugin record resolves to. */
50
+ export function pluginPkgDir(rec) {
51
+ // local: spec is an absolute directory; npm: resolve under the plugins prefix.
52
+ return rec.source === "local" ? rec.spec : join(pluginsDir(), "node_modules", ...rec.spec.split("/"));
53
+ }
54
+ /** Import a package directory's `plugin` manifest export (or null if absent). */
55
+ export async function importPluginManifest(pkgDir) {
56
+ const mod = (await import(entryUrl(pkgDir)));
57
+ const plugin = (mod.plugin ?? mod.default);
58
+ return plugin && typeof plugin === "object" ? plugin : null;
59
+ }
60
+ function importPlugin(rec) {
61
+ return importPluginManifest(pluginPkgDir(rec));
62
+ }
63
+ /**
64
+ * Rebuild the channel-type catalog from the enabled plugins in the registry.
65
+ * Call at boot and after an install/enable/disable change. Failures to load a
66
+ * single plugin are warned and skipped — one bad plugin can't break the rest.
67
+ */
68
+ export async function loadPlugins(warn = () => { }) {
69
+ catalog.clear();
70
+ for (const rec of listEnabledPlugins()) {
71
+ let plugin;
72
+ try {
73
+ plugin = await importPlugin(rec);
74
+ }
75
+ catch (error) {
76
+ warn(`plugin "${rec.name}" failed to load: ${messageOf(error)}`);
77
+ continue;
78
+ }
79
+ if (!plugin) {
80
+ warn(`plugin "${rec.name}" has no \`plugin\` export — skipped`);
81
+ continue;
82
+ }
83
+ for (const channel of plugin.channels ?? []) {
84
+ if (catalog.has(channel.type)) {
85
+ warn(`channel type "${channel.type}" already provided; "${rec.name}" ignored for it`);
86
+ continue;
87
+ }
88
+ catalog.set(channel.type, channel);
89
+ }
90
+ }
91
+ }
92
+ /**
93
+ * Resolve every enabled instance (joined with its channel) to a mountable
94
+ * descriptor, binding each to the factory its channel `type` resolves to in the
95
+ * catalog. Unknown types (no plugin providing them) are skipped.
96
+ */
97
+ export async function resolveChannelInstances(warn = () => { }) {
98
+ const instances = [];
99
+ for (const d of listEnabledDescriptors()) {
100
+ const descriptor = catalog.get(d.type);
101
+ if (!descriptor) {
102
+ warn(`unknown channel type "${d.type}" (instance "${d.instanceId}") — no installed plugin provides it; skipped`);
103
+ continue;
104
+ }
105
+ instances.push({
106
+ instanceId: d.instanceId,
107
+ type: d.type,
108
+ factory: descriptor.factory,
109
+ options: d.options,
110
+ agent: d.agent,
111
+ });
112
+ }
113
+ return instances;
114
+ }
115
+ function messageOf(error) {
116
+ return error instanceof Error ? error.message : String(error);
117
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * SQLite-backed {@link SessionLogStore} over the daemon's `session_items` table
3
+ * (`~/.rynx/rynx.db`). The durable home of the canonical conversation log: an
4
+ * append-only stream of {@link SessionItem}s ordered by a monotonic, gap-free
5
+ * `position` per session. Producer
6
+ * identity (`id`/`createdAt`/`data`) is preserved; the store owns only the
7
+ * session-global `position`, assigned under a transaction so concurrent appends
8
+ * can't collide.
9
+ */
10
+ import type { SessionItem, SessionLogStore, SessionLogSummary } from "@rynx-ai/core";
11
+ export declare class SqliteSessionLogStore implements SessionLogStore {
12
+ append(sessionId: string, items: SessionItem[]): Promise<SessionItem[]>;
13
+ list(sessionId: string, opts?: {
14
+ afterId?: string;
15
+ limit?: number;
16
+ }): Promise<SessionItem[]>;
17
+ snapshot(sessionId: string): Promise<SessionItem[]>;
18
+ listSessions(opts?: {
19
+ limit?: number;
20
+ }): Promise<SessionLogSummary[]>;
21
+ deleteSession(sessionId: string): Promise<void>;
22
+ }