dsh-magpie-connect 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Pure decisions behind the model-list editor.
3
+ *
4
+ * Kept out of the `.tsx` component so `node --test` can import them directly
5
+ * (Node runs `.ts` but not `.tsx`), which is where the visibility rules that
6
+ * the dialog actually depends on get their coverage.
7
+ */
8
+ /** One model row as the models route reports it. */
9
+ export interface ModelRow {
10
+ id: string;
11
+ displayName: string;
12
+ contextWindow?: number;
13
+ maxTokens?: number;
14
+ image: boolean;
15
+ responsesOnly: boolean;
16
+ reasoning: boolean;
17
+ efforts: string[];
18
+ hidden: boolean;
19
+ }
20
+ /** One discovered candidate: a {@link ModelRow} without this plugin's own visibility flag. */
21
+ export type CandidateRow = Omit<ModelRow, 'hidden'>;
22
+ /**
23
+ * Which candidates a fresh fetch should start checked.
24
+ *
25
+ * This plugin's list decides *visibility*, not membership, so the dialog
26
+ * answers "which models should the picker offer" and every candidate the
27
+ * picker already offers starts ticked. The upstream pi-ai editor inverts this
28
+ * (known rows start unchecked) because there a row is an explicit catalog
29
+ * entry — adopting there would rewrite the user's own capacities.
30
+ * @param candidates - the models the gateway just listed.
31
+ * @param visibleIds - ids the picker currently offers.
32
+ * @returns the ids that should start checked.
33
+ */
34
+ export declare function initialPicked(candidates: readonly CandidateRow[], visibleIds: ReadonlySet<string>): Set<string>;
35
+ /**
36
+ * The hidden set an adopted selection produces.
37
+ *
38
+ * Only the offered candidates are considered: the saved hidden set is pruned
39
+ * to the live directory on write, so an id the gateway did not just offer is
40
+ * not something this save should carry. Leaving one in would resurrect a
41
+ * stale entry the settings page can no longer show or clear.
42
+ * @param candidates - the candidates the dialog offered.
43
+ * @param picked - the candidate ids the user left checked.
44
+ * @returns the hidden ids to save.
45
+ */
46
+ export declare function hiddenAfterAdopt(candidates: readonly CandidateRow[], picked: ReadonlySet<string>): string[];
47
+ /**
48
+ * Hide one model. The list shows only enabled models, so its delete button is
49
+ * this: the row leaves the list and rejoins via the fetch dialog.
50
+ * @param hidden - the hidden set before the action.
51
+ * @param id - the model to hide.
52
+ * @returns the hidden set after the action.
53
+ */
54
+ export declare function hideOne(hidden: readonly string[], id: string): string[];
55
+ /**
56
+ * Toggle every visible candidate, used by the dialog's select-all action.
57
+ *
58
+ * Clearing removes only what the current query shows, so a filtered
59
+ * select-all followed by a filtered clear does not silently un-pick matches
60
+ * the query was hiding.
61
+ * @param current - the checked set before the action.
62
+ * @param visible - the candidates the query currently shows.
63
+ * @returns the checked set after the action.
64
+ */
65
+ export declare function toggleAllPicked(current: ReadonlySet<string>, visible: readonly CandidateRow[]): Set<string>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The Magpie glyph in the settings nav rail.
3
+ *
4
+ * `settings.section` projects only `id` / `order` / `label` (see
5
+ * `SettingsRoot` in `dsh-client-ui-settings-general`), and its nav glyph is a
6
+ * hardcoded `navIcon(id)` map — `account`, `models`, `agent-presets`,
7
+ * `plugins`, `archived-sessions`, then a fallback gear for every other id.
8
+ * A registrant cannot supply an icon, so this plugin's row would draw the
9
+ * fallback gear.
10
+ *
11
+ * Until that slot grows an `icon` field, the row is claimed after the dialog
12
+ * mounts and the gear is swapped for the mark: the same technique
13
+ * `dshmarket` and `dsh-better-sidebar` use. Scope is deliberately narrow —
14
+ * only the row whose visible text equals this plugin's own localized section
15
+ * label is marked, the marker and the stylesheet belong to a `ctx.effect` so
16
+ * they are removed with the fiber, and a locale switch re-claims the row
17
+ * through the MutationObserver so the label and the glyph never disagree.
18
+ *
19
+ * Delete this module (and its call in `client/index.ts`) the day
20
+ * `settings.section` grows an `icon` field.
21
+ */
22
+ /**
23
+ * Whether a nav row is this plugin's own.
24
+ *
25
+ * Pure, and the only decision this feature makes: the row whose visible text
26
+ * is the section label the shell is currently projecting. An empty label
27
+ * matches nothing — a locale that has not resolved yet must not mark the
28
+ * whole nav.
29
+ * @param rowText - the row's visible text.
30
+ * @param wantedLabel - this plugin's current section label.
31
+ * @returns whether the row belongs to this plugin.
32
+ */
33
+ export declare function isOwnNavRow(rowText: unknown, wantedLabel: unknown): boolean;
34
+ /** Minimal surface this feature needs from the client context. */
35
+ export interface NavIconContext {
36
+ effect(fn: () => () => void, label?: string): unknown;
37
+ }
38
+ /**
39
+ * Install the nav glyph.
40
+ * @param ctx - client context, for effect ownership.
41
+ * @param resolveLabel - this plugin's current section label (the same thunk the
42
+ * `settings.section` registration passes), re-read on every sync so a locale
43
+ * switch is picked up without re-registering.
44
+ */
45
+ export declare function installSettingsNavIcon(ctx: NavIconContext, resolveLabel: () => string): void;
@@ -0,0 +1,6 @@
1
+ import { NS } from './i18n.ts';
2
+ import { type ModelRow } from './model-list.tsx';
3
+ export declare const PLUGIN_VERSION: string;
4
+ export type { ModelRow };
5
+ export declare function MagpieSettings(): unknown;
6
+ export { NS };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Serialize one value's writes so rapid edits cannot race or lose the last one.
3
+ *
4
+ * The model list autosaves: every remove and every dialog adoption posts the
5
+ * hidden set. Clicking remove three times in a second must not fire three
6
+ * overlapping requests whose responses land out of order — the last state the
7
+ * user saw has to be the last state that reaches the disk.
8
+ *
9
+ * The policy is deliberately "one in flight, keep only the newest pending":
10
+ * intermediate states are not worth uploading, and the final one always is.
11
+ * @param write - performs one write for a value; its rejection is reported.
12
+ * @returns a scheduler with `push`, `idle`, and a `status` reader.
13
+ */
14
+ export declare function createWriteQueue<T>(write: (value: T) => Promise<void>, onSettled?: (outcome: {
15
+ ok: true;
16
+ } | {
17
+ ok: false;
18
+ error: unknown;
19
+ }) => void): {
20
+ push: (value: T) => void;
21
+ /** Resolves when nothing is in flight and nothing is queued. */
22
+ idle: () => Promise<void>;
23
+ /** Whether a write is running or waiting. */
24
+ pending: () => boolean;
25
+ };
@@ -0,0 +1,66 @@
1
+ export interface DshMagpieConnectConfig {
2
+ /**
3
+ * Provider route id registered into DSH (the grouping key in the model
4
+ * picker). Changing it after sessions already reference the old id will
5
+ * orphan those model selections — prefer `displayName` for a cosmetic
6
+ * rename.
7
+ */
8
+ providerId?: string;
9
+ /**
10
+ * Display name shown in the model picker for this provider.
11
+ * Defaults to the explicit `providerId` when one is given,
12
+ * otherwise to `'magpie'`.
13
+ */
14
+ displayName?: string;
15
+ /**
16
+ * Versioned API root, e.g. `http://api.lan/v1`. The version is part of the
17
+ * value, not something this plugin adds: a gateway that later serves `/v2`
18
+ * or `/v3` is reached by changing this one string. Trailing slashes are
19
+ * stripped; the path is kept. Empty = not configured.
20
+ */
21
+ baseUrl?: string;
22
+ /** Gateway credential. Required: empty means not configured (key or URL missing both gate). */
23
+ apiKey?: string;
24
+ /**
25
+ * Plugin state directory (status snapshot, settings file, catalog cache).
26
+ * Defaults to `~/.dsh-magpie-connect`.
27
+ */
28
+ dataDir?: string;
29
+ /** Model list refresh interval in seconds. */
30
+ refreshSeconds?: number;
31
+ /**
32
+ * Connection-setup retries for transient upstream failures (429/5xx with
33
+ * backoff). Turn-level retries stay owned by DSH.
34
+ */
35
+ maxRetries?: number;
36
+ /** Overall upstream request cap in milliseconds. */
37
+ timeoutMs?: number;
38
+ /**
39
+ * Stall watchdog: max wait for the first upstream event in milliseconds
40
+ * (queueing happens here). Non-positive disables that phase.
41
+ */
42
+ firstEventTimeoutMs?: number;
43
+ /**
44
+ * Stall watchdog: max silence between upstream events in milliseconds.
45
+ * Non-positive disables that phase.
46
+ */
47
+ idleTimeoutMs?: number;
48
+ }
49
+ export declare const defaults: {
50
+ readonly providerId: "dsh-magpie-connect";
51
+ readonly displayName: "magpie";
52
+ readonly baseUrl: "";
53
+ readonly apiKey: "";
54
+ readonly refreshSeconds: 300;
55
+ readonly maxRetries: 2;
56
+ readonly timeoutMs: 300000;
57
+ readonly firstEventTimeoutMs: 90000;
58
+ readonly idleTimeoutMs: 60000;
59
+ };
60
+ export type ResolvedConfig = Required<Pick<DshMagpieConnectConfig, 'providerId' | 'refreshSeconds'>> & DshMagpieConnectConfig & {
61
+ displayName: string;
62
+ baseUrl: string;
63
+ apiKey: string;
64
+ dataDir: string;
65
+ };
66
+ export declare function resolveConfig(config?: DshMagpieConnectConfig): ResolvedConfig;
@@ -0,0 +1,88 @@
1
+ import { ModelCatalog } from './adapter/catalog.ts';
2
+ import { type DshMagpieConnectConfig } from './config.ts';
3
+ import { SettingsStore } from './settings.ts';
4
+ /**
5
+ * dsh-magpie-connect DSH cordis plugin entry.
6
+ *
7
+ * Registers a DSH LlmAdapter streaming directly from the Magpie LAN gateway
8
+ * (marketplace shape: no child process, no binary, no proxy). The model
9
+ * catalog warms up in the background (live gateway list with disk cache +
10
+ * static snapshot fallback).
11
+ *
12
+ * The Settings sidebar page (client half) talks to the REST routes below:
13
+ * connection endpoint (baseUrl/apiKey) applies live without restart, and the
14
+ * hidden-model list filters the picker immediately.
15
+ *
16
+ * dispose(): stop the catalog refresh loop. The cordis fiber disposal
17
+ * guarantees this runs on plugin reload/unload and on DSH shutdown.
18
+ */
19
+ export interface PluginContext {
20
+ logger: {
21
+ info(...args: unknown[]): void;
22
+ warn(...args: unknown[]): void;
23
+ error(...args: unknown[]): void;
24
+ };
25
+ llm?: {
26
+ registerAdapter(providers: string[], adapter: unknown): unknown;
27
+ };
28
+ get?(key: string): unknown;
29
+ inject?(deps: string[], fn: (ctx: Record<string, unknown>) => unknown): unknown;
30
+ effect?(fn: () => () => void): unknown;
31
+ on?(event: string, listener: (...args: never[]) => unknown): () => void;
32
+ /**
33
+ * Cordis `ctx.emit` (mixed in from the events service). Used to publish
34
+ * `llm/adapters-updated` when this plugin's own catalog moves, without
35
+ * touching the adapter registry.
36
+ */
37
+ emit?(event: string, ...args: unknown[]): unknown;
38
+ }
39
+ export declare const name = "dsh-magpie-connect";
40
+ export declare const inject: readonly ["llm"];
41
+ /** Browser-facing REST root for the settings page (served under the shared /api channel). */
42
+ export declare const SETTINGS_API = "/api/magpie-settings";
43
+ export declare const MODELS_API = "/api/magpie-models";
44
+ export declare const TEST_API = "/api/magpie-test";
45
+ /**
46
+ * Candidate discovery: asks the endpoint the form currently shows (including a
47
+ * key typed but not yet saved) what it serves. The reply is candidates the
48
+ * user picks from — never configuration written behind them.
49
+ */
50
+ export declare const DISCOVER_API = "/api/magpie-discover";
51
+ /** Backend generation behind the shared settings routes (newest wins). */
52
+ export interface SettingsBackend {
53
+ store: SettingsStore;
54
+ catalog: ModelCatalog;
55
+ patchEndpoint: {
56
+ baseUrl: string;
57
+ apiKey: string;
58
+ };
59
+ effective: () => {
60
+ baseUrl: string;
61
+ apiKey: string;
62
+ };
63
+ applyPageSettings: () => void;
64
+ }
65
+ interface ModelRow {
66
+ id: string;
67
+ displayName: string;
68
+ contextWindow?: number;
69
+ maxTokens?: number;
70
+ image: boolean;
71
+ responsesOnly: boolean;
72
+ reasoning: boolean;
73
+ efforts: string[];
74
+ hidden: boolean;
75
+ }
76
+ /** One discovered candidate: a {@link ModelRow} without this plugin's own visibility flag. */
77
+ export type CandidateRow = Omit<ModelRow, 'hidden'>;
78
+ /** @internal Test hook: forget installed routes so the next apply reinstalls. */
79
+ export declare function __resetSettingsRoutes(): void;
80
+ export declare function apply(ctx: PluginContext, config?: DshMagpieConnectConfig): {
81
+ ready: Promise<{
82
+ version: string;
83
+ }>;
84
+ };
85
+ export { MagpieAdapter } from './adapter/magpie-adapter.ts';
86
+ export { ModelCatalog } from './adapter/catalog.ts';
87
+ export { SettingsStore, isConfigured, resolveEffectiveEndpoint } from './settings.ts';
88
+ export { resolveConfig, type DshMagpieConnectConfig, } from './config.ts';
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Page-owned runtime settings, persisted to a local JSON file.
3
+ *
4
+ * Precedence for the effective endpoint: settings page (this file, only the
5
+ * fields the user actually saved) > cordis patch config > built-in defaults.
6
+ * Hidden models only ever come from this file — the patch config has no such
7
+ * field, so an empty/absent list means "show everything".
8
+ */
9
+ export interface MagpiePageSettings {
10
+ baseUrl?: string;
11
+ apiKey?: string;
12
+ hiddenModels?: string[];
13
+ }
14
+ export interface EffectiveEndpoint {
15
+ baseUrl: string;
16
+ apiKey: string;
17
+ }
18
+ export declare const SETTINGS_FILE = "settings.json";
19
+ export declare function defaultSettingsPath(dataDir: string): string;
20
+ /**
21
+ * Normalize an API base URL: must be http(s), no trailing slash, path kept.
22
+ *
23
+ * The path is load-bearing, not a mistake to be trimmed: the gateway versions
24
+ * its API (`/v1`, and later `/v2`, `/v3`), so this value *is* the prefix every
25
+ * request is built on. The plugin appends only the resource (`/models`,
26
+ * `/chat/completions`), never a version of its own — an origin-only value
27
+ * would make a future `/v2` unreachable.
28
+ */
29
+ export declare function normalizeBaseUrl(raw: unknown): string;
30
+ /** Normalize a hidden-model list: strings only, deduped, order kept. */
31
+ export declare function normalizeHiddenModels(raw: unknown): string[];
32
+ /** Validate + normalize a page payload (full or partial). Unknown keys are dropped. */
33
+ export declare function normalizePageSettings(raw: unknown): MagpiePageSettings;
34
+ /**
35
+ * Merge patch config with page settings: page wins per-field, but only for
36
+ * fields it actually saved (absent page fields fall back to patch/defaults).
37
+ */
38
+ export declare function resolveEffectiveEndpoint(patch: {
39
+ baseUrl: string;
40
+ apiKey: string;
41
+ }, page: MagpiePageSettings): EffectiveEndpoint;
42
+ /** Fully configured only when both URL and key are present. Either missing gates everything. */
43
+ export declare function isConfigured(endpoint: EffectiveEndpoint): boolean;
44
+ /** In-memory settings with file persistence. Synchronous reads for the hot path. */
45
+ export declare class SettingsStore {
46
+ #private;
47
+ constructor(options?: {
48
+ path?: string;
49
+ });
50
+ /** Load persisted settings (missing/corrupt file reads as empty, never throws). */
51
+ load(): Promise<MagpiePageSettings>;
52
+ get(): MagpiePageSettings;
53
+ /** Merge a partial payload, persist, and notify listeners. */
54
+ save(partial: unknown): Promise<MagpiePageSettings>;
55
+ /** Replace the whole document (used at startup after load). */
56
+ replace(next: MagpiePageSettings): void;
57
+ onChange(listener: () => void): () => void;
58
+ persist(): Promise<void>;
59
+ }
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "dsh-magpie-connect",
3
+ "description": "Magpie LAN gateway models for DeepSeek Harness (DSH): native dsh-llm adapter plugin with image + responses + thinking levels.",
4
+ "version": "0.2.0",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/bitxeno/dsh-magpie-connect.git"
10
+ },
11
+ "main": "lib/index.js",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./lib/types/index.d.ts",
15
+ "default": "./lib/index.js"
16
+ },
17
+ "./client": {
18
+ "default": "./lib/client.js"
19
+ },
20
+ "./package.json": "./package.json"
21
+ },
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "dsh": {
26
+ "bundle": {
27
+ "patch": "./cordis.patch.yml"
28
+ },
29
+ "client": {
30
+ "inject": [
31
+ "@deepseek-ai/dsh-client-ui-renderer",
32
+ "@deepseek-ai/dsh-client-locale",
33
+ "@deepseek-ai/dsh-client-ui-primitives"
34
+ ],
35
+ "platform": "web"
36
+ }
37
+ },
38
+ "files": [
39
+ "lib",
40
+ "cordis.patch.yml",
41
+ "dsh.plugin.json",
42
+ "README.md",
43
+ "LICENSE"
44
+ ],
45
+ "scripts": {
46
+ "build": "node build.mjs",
47
+ "deploy:local": "pnpm run build && rsync -a --delete lib/ ~/.dsh/profiles/web/node_modules/dsh-magpie-connect/lib/ && echo 'Deployed to ~/.dsh/profiles/web/node_modules/dsh-magpie-connect/lib — restart dsh web to load the new host half'",
48
+ "typecheck": "tsc --noEmit",
49
+ "test": "node --test \"test/*.test.ts\"",
50
+ "check": "pnpm run typecheck && pnpm run test && pnpm run build"
51
+ },
52
+ "keywords": [
53
+ "dsh",
54
+ "dsh-plugin",
55
+ "deepseek-harness",
56
+ "magpie",
57
+ "lan-gateway",
58
+ "cordis",
59
+ "cordis-plugin",
60
+ "llm-adapter",
61
+ "llm-provider",
62
+ "llm",
63
+ "ai-models",
64
+ "typescript",
65
+ "nodejs"
66
+ ],
67
+ "dependencies": {
68
+ "@earendil-works/pi-ai": "^0.82.1"
69
+ },
70
+ "pnpm": {
71
+ "ignoredBuiltDependencies": [
72
+ "@google/genai",
73
+ "protobufjs"
74
+ ]
75
+ },
76
+ "devDependencies": {
77
+ "@types/node": "^24.0.0",
78
+ "esbuild": "^0.25.0",
79
+ "typescript": "^5.9.0"
80
+ }
81
+ }