@khorsheed/dsh-local-agent-kimi 0.1.0-rc.6

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 (50) hide show
  1. package/README.en.md +132 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +132 -0
  4. package/cordis.patch.yml +34 -0
  5. package/lib/client.js +1066 -0
  6. package/lib/index.js +4716 -0
  7. package/lib/invariant.js +31 -0
  8. package/lib/types/client/SettingsCard.d.ts +77 -0
  9. package/lib/types/client/SettingsCard.js +197 -0
  10. package/lib/types/client/icons.d.ts +18 -0
  11. package/lib/types/client/icons.js +2 -0
  12. package/lib/types/client/index.d.ts +42 -0
  13. package/lib/types/client/index.js +89 -0
  14. package/lib/types/client/locales.d.ts +64 -0
  15. package/lib/types/client/locales.js +55 -0
  16. package/lib/types/credential-guard.d.ts +29 -0
  17. package/lib/types/credential-guard.js +107 -0
  18. package/lib/types/index.d.ts +69 -0
  19. package/lib/types/index.js +270 -0
  20. package/lib/types/invariant.d.ts +16 -0
  21. package/lib/types/invariant.js +33 -0
  22. package/lib/types/kimi-cli-provider.d.ts +215 -0
  23. package/lib/types/kimi-cli-provider.js +855 -0
  24. package/lib/types/live-driver.d.ts +245 -0
  25. package/lib/types/live-driver.js +1312 -0
  26. package/lib/types/live-switch.d.ts +78 -0
  27. package/lib/types/live-switch.js +112 -0
  28. package/lib/types/member-bridge-config.d.ts +43 -0
  29. package/lib/types/member-bridge-config.js +80 -0
  30. package/lib/types/model-broker.d.ts +97 -0
  31. package/lib/types/model-broker.js +205 -0
  32. package/lib/types/model-catalog.d.ts +26 -0
  33. package/lib/types/model-catalog.js +86 -0
  34. package/lib/types/model-configuration.d.ts +11 -0
  35. package/lib/types/model-configuration.js +55 -0
  36. package/lib/types/preset-tools.d.ts +20 -0
  37. package/lib/types/preset-tools.js +36 -0
  38. package/lib/types/provision.d.ts +131 -0
  39. package/lib/types/provision.js +363 -0
  40. package/lib/types/records.d.ts +35 -0
  41. package/lib/types/records.js +85 -0
  42. package/lib/types/session-configuration.d.ts +5 -0
  43. package/lib/types/session-configuration.js +26 -0
  44. package/lib/types/session-mirror.d.ts +180 -0
  45. package/lib/types/session-mirror.js +446 -0
  46. package/lib/types/session-view.d.ts +121 -0
  47. package/lib/types/session-view.js +423 -0
  48. package/locale/en.json +6 -0
  49. package/locale/zh.json +6 -0
  50. package/package.json +107 -0
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The settings-driven live-driver switch: owns the active driver generation
3
+ * and retires old ones on a settings change, without a plugin reload.
4
+ *
5
+ * Toggle OFF (or unload): the retiring generation is DRAINED, not killed —
6
+ * new rounds are refused (the provider's catch falls back to exec), in-flight
7
+ * rounds finish on their runtime, idle runtimes are reclaimed at once.
8
+ * Toggling ON builds the next generation lazily (no process until the first
9
+ * round). Legacy granularity settings are ignored.
10
+ *
11
+ * @module @khorsheed/dsh-local-agent-kimi — internal, unit-tested directly.
12
+ */
13
+ import type { KimiNativeConfiguration } from './model-catalog.ts';
14
+ import type { Context } from '@deepseek-ai/cordis';
15
+ import type { SettingsFace } from '@khorsheed/dsh-local-agent';
16
+ import { KimiAcpLiveDriver } from './live-driver.ts';
17
+ import type { KimiLiveMirrorGranularity } from './live-driver.ts';
18
+ /** The resolved live settings (rc.1: volatile row config; 0.1.5: schema defaults ← YAML base ← user layer). */
19
+ export interface KimiLiveSettings {
20
+ live: boolean;
21
+ liveMirrorGranularity: KimiLiveMirrorGranularity;
22
+ /** The configured model, when the card or the YAML base names one. */
23
+ model?: string;
24
+ /** Model identifiers the card has saved before (its input's suggestions). */
25
+ recentModels?: readonly string[];
26
+ }
27
+ export declare class LiveDriverSwitch {
28
+ private readonly ctx;
29
+ private readonly liveIdleMs;
30
+ /**
31
+ * Per-spawn model resolver, handed to every driver generation. Member-aware:
32
+ * the driver's runtime binds whatever the member's layers resolve to.
33
+ */
34
+ private readonly model?;
35
+ private liveOn;
36
+ private active;
37
+ /** Retiring generations still draining their in-flight rounds. */
38
+ private readonly draining;
39
+ private readonly unwatch;
40
+ constructor(ctx: Context, face: SettingsFace<KimiLiveSettings>, liveIdleMs: number | undefined,
41
+ /**
42
+ * Per-spawn model resolver, handed to every driver generation. Member-aware:
43
+ * the driver's runtime binds whatever the member's layers resolve to.
44
+ */
45
+ model?: ((childSessionId: string) => string | undefined) | undefined);
46
+ /**
47
+ * Resolve the live driver for one round's member (the provider's call
48
+ * site). A retiring generation still hosting the member gates the new one —
49
+ * the round falls back to exec rather than double-loading the same kimi
50
+ * session into two processes.
51
+ */
52
+ readonly resolve: (childSessionId: string) => KimiAcpLiveDriver | undefined;
53
+ /** Mirror the resolved settings into the driver generation. */
54
+ private apply;
55
+ /**
56
+ * Retire one member's runtime on the ACTIVE generation (a composer-driven
57
+ * model switch): the next round respawns onto the new model. A member still
58
+ * hosted by a draining generation is left alone — its rounds already gate
59
+ * to exec, and the drain reclaims the process.
60
+ */
61
+ retireMemberRuntime(childSessionId: string): Promise<void>;
62
+ /**
63
+ * The model the member's active-generation runtime is bound to, or
64
+ * undefined when no live runtime serves the member (the broker's
65
+ * same-model no-op check).
66
+ */
67
+ memberRuntimeModel(childSessionId: string): string | undefined;
68
+ /**
69
+ * Whether the active generation hosts (or is spawning) the member's
70
+ * runtime — the broker's retire decision needs this apart from the bound
71
+ * model, since a runtime that bound NO model reports undefined either way.
72
+ */
73
+ memberHasRuntime(childSessionId: string): boolean;
74
+ memberRuntimeConfiguration(childSessionId: string): KimiNativeConfiguration | undefined;
75
+ /** Plugin unload: interrupt whatever survives (disposeAll, not drain). */
76
+ dispose(): void;
77
+ }
78
+ //# sourceMappingURL=live-switch.d.ts.map
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The settings-driven live-driver switch: owns the active driver generation
3
+ * and retires old ones on a settings change, without a plugin reload.
4
+ *
5
+ * Toggle OFF (or unload): the retiring generation is DRAINED, not killed —
6
+ * new rounds are refused (the provider's catch falls back to exec), in-flight
7
+ * rounds finish on their runtime, idle runtimes are reclaimed at once.
8
+ * Toggling ON builds the next generation lazily (no process until the first
9
+ * round). Legacy granularity settings are ignored.
10
+ *
11
+ * @module @khorsheed/dsh-local-agent-kimi — internal, unit-tested directly.
12
+ */
13
+ import { KimiAcpLiveDriver } from './live-driver.js';
14
+ export class LiveDriverSwitch {
15
+ ctx;
16
+ liveIdleMs;
17
+ model;
18
+ liveOn = false;
19
+ active;
20
+ /** Retiring generations still draining their in-flight rounds. */
21
+ draining = new Set();
22
+ unwatch;
23
+ constructor(ctx, face, liveIdleMs,
24
+ /**
25
+ * Per-spawn model resolver, handed to every driver generation. Member-aware:
26
+ * the driver's runtime binds whatever the member's layers resolve to.
27
+ */
28
+ model) {
29
+ this.ctx = ctx;
30
+ this.liveIdleMs = liveIdleMs;
31
+ this.model = model;
32
+ this.apply(face.get());
33
+ this.unwatch = face.watch((next) => { this.apply(next); });
34
+ }
35
+ /**
36
+ * Resolve the live driver for one round's member (the provider's call
37
+ * site). A retiring generation still hosting the member gates the new one —
38
+ * the round falls back to exec rather than double-loading the same kimi
39
+ * session into two processes.
40
+ */
41
+ resolve = (childSessionId) => {
42
+ if (this.active === undefined)
43
+ return undefined;
44
+ for (const retiring of this.draining) {
45
+ if (retiring.hasRuntime(childSessionId))
46
+ return undefined;
47
+ }
48
+ return this.active;
49
+ };
50
+ /** Mirror the resolved settings into the driver generation. */
51
+ apply(next) {
52
+ if (next.live === this.liveOn) {
53
+ return;
54
+ }
55
+ this.liveOn = next.live;
56
+ const retiring = this.active;
57
+ this.active = next.live
58
+ ? new KimiAcpLiveDriver(this.ctx, {
59
+ ...this.liveIdleMs === undefined ? {} : { liveIdleMs: this.liveIdleMs },
60
+ ...this.model === undefined ? {} : { model: this.model },
61
+ })
62
+ : undefined;
63
+ if (retiring !== undefined) {
64
+ this.draining.add(retiring);
65
+ void retiring.drain().then(() => { this.draining.delete(retiring); }, (error) => {
66
+ this.draining.delete(retiring);
67
+ this.ctx.logger.warn(`local-agent-kimi: live driver drain failed: ${error instanceof Error ? error.message : String(error)}`);
68
+ });
69
+ }
70
+ }
71
+ /**
72
+ * Retire one member's runtime on the ACTIVE generation (a composer-driven
73
+ * model switch): the next round respawns onto the new model. A member still
74
+ * hosted by a draining generation is left alone — its rounds already gate
75
+ * to exec, and the drain reclaims the process.
76
+ */
77
+ async retireMemberRuntime(childSessionId) {
78
+ await this.active?.retireRuntime(childSessionId);
79
+ }
80
+ /**
81
+ * The model the member's active-generation runtime is bound to, or
82
+ * undefined when no live runtime serves the member (the broker's
83
+ * same-model no-op check).
84
+ */
85
+ memberRuntimeModel(childSessionId) {
86
+ return this.active?.runtimeModel(childSessionId);
87
+ }
88
+ /**
89
+ * Whether the active generation hosts (or is spawning) the member's
90
+ * runtime — the broker's retire decision needs this apart from the bound
91
+ * model, since a runtime that bound NO model reports undefined either way.
92
+ */
93
+ memberHasRuntime(childSessionId) {
94
+ return this.active?.hasRuntime(childSessionId) ?? false;
95
+ }
96
+ memberRuntimeConfiguration(childSessionId) {
97
+ for (const driver of [this.active, ...this.draining]) {
98
+ const configuration = driver?.runtimeConfiguration(childSessionId);
99
+ if (configuration !== undefined)
100
+ return configuration;
101
+ }
102
+ return undefined;
103
+ }
104
+ /** Plugin unload: interrupt whatever survives (disposeAll, not drain). */
105
+ dispose() {
106
+ this.unwatch();
107
+ void this.active?.disposeAll();
108
+ for (const retiring of this.draining)
109
+ void retiring.disposeAll();
110
+ this.draining.clear();
111
+ }
112
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Kimi scoped-home MCP config injection for the member bridge. Kimi declares
3
+ * stdio MCP servers in `$KIMI_CODE_HOME/mcp.json` (`mcpServers` map); the file
4
+ * is read once per CLI session start, and edits never interrupt an open
5
+ * session — so the family writes one entry PER RUN (keyed by the run's token
6
+ * suffix, keeping concurrent delegations of the same scoped home race-free),
7
+ * prunes it when the run settles, and drops stale family entries (crashed
8
+ * host) on every write. User-declared servers are preserved verbatim.
9
+ * @module @khorsheed/dsh-local-agent-kimi/member-bridge-config
10
+ */
11
+ /** Server-key prefix marking the family's per-run bridge entries. */
12
+ export declare const MEMBER_BRIDGE_SERVER_PREFIX = "dsh-member-";
13
+ /**
14
+ * The per-run MCP server key. The run's token suffix keeps concurrent runs
15
+ * distinct; each `kimi -p` round is a fresh CLI session, so per-run (rather
16
+ * than per-member) naming costs nothing.
17
+ * @param token - the run's member-channel token.
18
+ * @returns the mcp.json server key.
19
+ */
20
+ export declare function memberBridgeServerKey(token: string): string;
21
+ /**
22
+ * Declare the member bridge for one run: set the per-run server entry and
23
+ * prune other family entries (settled or crashed runs — an open session's
24
+ * already-registered server is unaffected, per kimi's mid-session semantics).
25
+ * Synchronous read-modify-write: single-process callers race-free by event
26
+ * loop; the file is small and written once per run.
27
+ * @param homeDir - the kimi scoped home.
28
+ * @param key - this run's server key ({@link memberBridgeServerKey}).
29
+ * @param command - the bridge spawn command (`node` + entry path).
30
+ * @param env - the bridge environment (socket path + per-run token).
31
+ */
32
+ export declare function injectMemberBridge(homeDir: string, key: string, command: {
33
+ command: string;
34
+ args: string[];
35
+ }, env: Record<string, string>): void;
36
+ /**
37
+ * Remove one run's bridge entry (settle path). Absent entries and a missing
38
+ * config are no-ops.
39
+ * @param homeDir - the kimi scoped home.
40
+ * @param key - the run's server key.
41
+ */
42
+ export declare function removeMemberBridge(homeDir: string, key: string): void;
43
+ //# sourceMappingURL=member-bridge-config.d.ts.map
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Kimi scoped-home MCP config injection for the member bridge. Kimi declares
3
+ * stdio MCP servers in `$KIMI_CODE_HOME/mcp.json` (`mcpServers` map); the file
4
+ * is read once per CLI session start, and edits never interrupt an open
5
+ * session — so the family writes one entry PER RUN (keyed by the run's token
6
+ * suffix, keeping concurrent delegations of the same scoped home race-free),
7
+ * prunes it when the run settles, and drops stale family entries (crashed
8
+ * host) on every write. User-declared servers are preserved verbatim.
9
+ * @module @khorsheed/dsh-local-agent-kimi/member-bridge-config
10
+ */
11
+ import { readFileSync, writeFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ /** Kimi's user-level MCP config filename inside the scoped home. */
14
+ const MCP_CONFIG_FILENAME = 'mcp.json';
15
+ /** Server-key prefix marking the family's per-run bridge entries. */
16
+ export const MEMBER_BRIDGE_SERVER_PREFIX = 'dsh-member-';
17
+ /**
18
+ * The per-run MCP server key. The run's token suffix keeps concurrent runs
19
+ * distinct; each `kimi -p` round is a fresh CLI session, so per-run (rather
20
+ * than per-member) naming costs nothing.
21
+ * @param token - the run's member-channel token.
22
+ * @returns the mcp.json server key.
23
+ */
24
+ export function memberBridgeServerKey(token) {
25
+ return `${MEMBER_BRIDGE_SERVER_PREFIX}${token.slice(0, 8)}`;
26
+ }
27
+ /** Read the scoped mcp.json; a missing or malformed file reads as empty. */
28
+ function readMcpConfig(file) {
29
+ let parsed;
30
+ try {
31
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
32
+ }
33
+ catch {
34
+ return {};
35
+ }
36
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
37
+ return {};
38
+ return parsed;
39
+ }
40
+ /** Write the config back, preserving every non-family key. */
41
+ function writeMcpConfig(file, config) {
42
+ writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
43
+ }
44
+ /**
45
+ * Declare the member bridge for one run: set the per-run server entry and
46
+ * prune other family entries (settled or crashed runs — an open session's
47
+ * already-registered server is unaffected, per kimi's mid-session semantics).
48
+ * Synchronous read-modify-write: single-process callers race-free by event
49
+ * loop; the file is small and written once per run.
50
+ * @param homeDir - the kimi scoped home.
51
+ * @param key - this run's server key ({@link memberBridgeServerKey}).
52
+ * @param command - the bridge spawn command (`node` + entry path).
53
+ * @param env - the bridge environment (socket path + per-run token).
54
+ */
55
+ export function injectMemberBridge(homeDir, key, command, env) {
56
+ const file = join(homeDir, MCP_CONFIG_FILENAME);
57
+ const config = readMcpConfig(file);
58
+ const servers = { ...(config.mcpServers ?? {}) };
59
+ for (const name of Object.keys(servers)) {
60
+ if (name.startsWith(MEMBER_BRIDGE_SERVER_PREFIX) && name !== key)
61
+ delete servers[name];
62
+ }
63
+ servers[key] = { command: command.command, args: command.args, env };
64
+ writeMcpConfig(file, { ...config, mcpServers: servers });
65
+ }
66
+ /**
67
+ * Remove one run's bridge entry (settle path). Absent entries and a missing
68
+ * config are no-ops.
69
+ * @param homeDir - the kimi scoped home.
70
+ * @param key - the run's server key.
71
+ */
72
+ export function removeMemberBridge(homeDir, key) {
73
+ const file = join(homeDir, MCP_CONFIG_FILENAME);
74
+ const config = readMcpConfig(file);
75
+ if (config.mcpServers === undefined || !(key in config.mcpServers))
76
+ return;
77
+ const servers = { ...config.mcpServers };
78
+ delete servers[key];
79
+ writeMcpConfig(file, { ...config, mcpServers: servers });
80
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The kimi harness's model broker: the member/harness model surface the
3
+ * family gateway routes to (`harnessModel` for the settings card,
4
+ * `memberModel`/`setMemberModel` for the composer picker). Resolution follows
5
+ * the family's fixed order — the session-level override (the composer's
6
+ * picker write) first, then the delegation's recorded model, then the
7
+ * plugin-config `model` key, then the scoped config's own `default_model`,
8
+ * and last the CLI's built-in default, which names nothing.
9
+ *
10
+ * Per-member state is IN MEMORY by design: the session-level override is a
11
+ * composer gesture that deliberately does not survive a host restart, and the
12
+ * start-model ledger only mirrors what the delegation records already persist
13
+ * (the core hands the recorded value back as `delegationModel` on reads).
14
+ * The spawn side of a switch is lazy: `setMemberModel` retires the member's
15
+ * resident runtime when its bound model no longer matches, and the member's
16
+ * next round respawns onto the new model, `session/load`-ing the same CLI
17
+ * session — the conversation carries over, the process fact changes.
18
+ * @module @khorsheed/dsh-local-agent-kimi/model-broker
19
+ */
20
+ import type { MemberConfigurationAdapter } from '@khorsheed/dsh-local-agent';
21
+ import type { LocalAgentMemberBinding } from '@khorsheed/dsh-local-agent/types';
22
+ import type { KimiModelCatalog } from './model-catalog.ts';
23
+ import type { Context } from '@deepseek-ai/cordis';
24
+ import type { LocalAgentModelBroker, LocalAgentModelInfo, LocalAgentModelDirectory } from '@khorsheed/dsh-local-agent/types';
25
+ import type { LiveDriverSwitch } from './live-switch.ts';
26
+ /**
27
+ * The kimi harness's {@link LocalAgentModelBroker}. Also the provider-facing
28
+ * member-model ledger: the provider records each delegation's start model
29
+ * here and reads the session-level override for its exec-path resolution, so
30
+ * the picker write reaches the very next round on either driver.
31
+ */
32
+ export declare class KimiModelBroker implements LocalAgentModelBroker {
33
+ private readonly ctx;
34
+ private readonly deps;
35
+ /** Session-level overrides per member (the composer picker's writes). */
36
+ private readonly overrides;
37
+ /** Each member's START model — the model its delegation's first round named. */
38
+ private readonly startModels;
39
+ /**
40
+ * @param ctx - plugin context carrying the family registry (the in-flight
41
+ * read behind `switchable`).
42
+ * @param deps - the harness's live readers: the scoped home, the settings
43
+ * layer, the card's recent-model memory, the live toggle, and the driver
44
+ * switch a switch retires runtimes through.
45
+ */
46
+ constructor(ctx: Context, deps: {
47
+ /** The default scope's scoped home (cliDefault + discovery reads). */
48
+ homeDir: (childSessionId?: string) => string;
49
+ catalog?: KimiModelCatalog;
50
+ /** The plugin-config `model` key, resolved per read. */
51
+ settingsModel: () => string | undefined;
52
+ /** The settings card's recently saved identifiers (choices memory). */
53
+ recentModels: () => readonly string[];
54
+ /** Whether the live driver is currently on. */
55
+ isLive: () => boolean;
56
+ /** The driver generations a model switch retires a member runtime through. */
57
+ liveSwitch: LiveDriverSwitch;
58
+ });
59
+ configurationAdapter(binding: LocalAgentMemberBinding): MemberConfigurationAdapter;
60
+ modelDirectory(childSessionId?: string, refresh?: boolean): Promise<LocalAgentModelDirectory>;
61
+ followModelDirectory(childSessionId: string | undefined, signal: AbortSignal): AsyncIterable<LocalAgentModelDirectory>;
62
+ /**
63
+ * Record the model a member's delegation STARTED with (the provider's call
64
+ * site, fresh and resume rounds alike). The live driver's spawn resolver
65
+ * ranks it between the override and the settings layer.
66
+ */
67
+ noteStartModel(childSessionId: string, model: string): void;
68
+ /**
69
+ * The member's session-level override, when the composer picker set one —
70
+ * the exec path's top layer (it outranks even the delegation's recorded
71
+ * model, so the provider folds it into the requested slot).
72
+ */
73
+ overrideFor(childSessionId: string): string | undefined;
74
+ /**
75
+ * The member's spawn-binding resolver (the live driver config): override,
76
+ * then the member's start model, then the settings layer. Undefined writes
77
+ * nothing — the scoped config's own `default_model` decides, as it always
78
+ * did.
79
+ */
80
+ readonly spawnModel: (childSessionId: string) => string | undefined;
81
+ /**
82
+ * Read the model surface. With no member this is the harness default (the
83
+ * settings card's "what would a round run with"): override and delegation
84
+ * stay absent by construction.
85
+ */
86
+ modelInfo(childSessionId?: string, delegationModel?: string): Promise<LocalAgentModelInfo>;
87
+ /**
88
+ * Set (or clear) the member's session-level override. Throws while a round
89
+ * is in flight for the member — the switch retires the resident runtime,
90
+ * and retiring mid-round would kill the run. A same-value set (or clearing
91
+ * an unset override) is a no-op; otherwise a live runtime whose bound model
92
+ * differs from the new effective model is retired so the next round
93
+ * respawns onto it, resuming the same CLI session.
94
+ */
95
+ setMemberModel(childSessionId: string, model: string | undefined): Promise<void>;
96
+ }
97
+ //# sourceMappingURL=model-broker.d.ts.map
@@ -0,0 +1,205 @@
1
+ /**
2
+ * The kimi harness's model broker: the member/harness model surface the
3
+ * family gateway routes to (`harnessModel` for the settings card,
4
+ * `memberModel`/`setMemberModel` for the composer picker). Resolution follows
5
+ * the family's fixed order — the session-level override (the composer's
6
+ * picker write) first, then the delegation's recorded model, then the
7
+ * plugin-config `model` key, then the scoped config's own `default_model`,
8
+ * and last the CLI's built-in default, which names nothing.
9
+ *
10
+ * Per-member state is IN MEMORY by design: the session-level override is a
11
+ * composer gesture that deliberately does not survive a host restart, and the
12
+ * start-model ledger only mirrors what the delegation records already persist
13
+ * (the core hands the recorded value back as `delegationModel` on reads).
14
+ * The spawn side of a switch is lazy: `setMemberModel` retires the member's
15
+ * resident runtime when its bound model no longer matches, and the member's
16
+ * next round respawns onto the new model, `session/load`-ing the same CLI
17
+ * session — the conversation carries over, the process fact changes.
18
+ * @module @khorsheed/dsh-local-agent-kimi/model-broker
19
+ */
20
+ import { extendModelDirectory } from '@khorsheed/dsh-local-agent';
21
+ import { readKimiModelConfiguration } from './model-configuration.js';
22
+ import { listKimiConfigModels, readKimiDefaultModel } from './provision.js';
23
+ /** The refusal a mid-round switch gets (in flight = the runtime must not die). */
24
+ const SWITCH_IN_FLIGHT_REASON = '该成员有进行中的委派轮次,等其完成后再切换模型';
25
+ /** Dedupe a candidate list, dropping blanks and keeping first-seen order. */
26
+ function dedupe(candidates) {
27
+ const seen = new Set();
28
+ const out = [];
29
+ for (const candidate of candidates) {
30
+ const value = candidate?.trim();
31
+ if (value === undefined || value === '' || seen.has(value))
32
+ continue;
33
+ seen.add(value);
34
+ out.push(value);
35
+ }
36
+ return out;
37
+ }
38
+ /**
39
+ * The kimi harness's {@link LocalAgentModelBroker}. Also the provider-facing
40
+ * member-model ledger: the provider records each delegation's start model
41
+ * here and reads the session-level override for its exec-path resolution, so
42
+ * the picker write reaches the very next round on either driver.
43
+ */
44
+ export class KimiModelBroker {
45
+ ctx;
46
+ deps;
47
+ /** Session-level overrides per member (the composer picker's writes). */
48
+ overrides = new Map();
49
+ /** Each member's START model — the model its delegation's first round named. */
50
+ startModels = new Map();
51
+ /**
52
+ * @param ctx - plugin context carrying the family registry (the in-flight
53
+ * read behind `switchable`).
54
+ * @param deps - the harness's live readers: the scoped home, the settings
55
+ * layer, the card's recent-model memory, the live toggle, and the driver
56
+ * switch a switch retires runtimes through.
57
+ */
58
+ constructor(ctx, deps) {
59
+ this.ctx = ctx;
60
+ this.deps = deps;
61
+ }
62
+ configurationAdapter(binding) {
63
+ const home = this.ctx.localAgent.homeDir('kimi', binding.scope);
64
+ const resolve = async (selection) => {
65
+ const model = selection.model.mode === 'value' ? selection.model.value
66
+ : (selection.model.mode === 'inherit' ? binding.model : undefined) ?? this.deps.settingsModel() ?? await readKimiDefaultModel(home);
67
+ const configured = await readKimiModelConfiguration(home, model);
68
+ const native = this.deps.liveSwitch.memberRuntimeConfiguration(binding.childSessionId);
69
+ const entry = native?.directory.entries.find(entry => entry.value === model)
70
+ ?? configured.entries.find(entry => entry.value === model);
71
+ const effort = (selection.effort.mode === 'value' ? selection.effort.value : selection.effort.mode === 'inherit' ? binding.effort : undefined) ?? configured.effort;
72
+ if (effort !== undefined && entry?.reasoning !== undefined && !entry.reasoning.options.some(option => option.value === effort)) {
73
+ throw new Error('Kimi has not advertised this reasoning effort for the selected model');
74
+ }
75
+ if (!this.deps.isLive() && effort !== undefined && configured.protocol !== 'kimi') {
76
+ throw new Error('This model protocol has no Kimi exec effort override; use ACP live mode');
77
+ }
78
+ return { ...model === undefined ? {} : { model }, ...effort === undefined ? {} : { effort } };
79
+ };
80
+ return {
81
+ validate: async (selection) => { await resolve(selection); }, prepare: resolve,
82
+ apply: async (selection) => { const resolved = await resolve(selection); await this.deps.liveSwitch.retireMemberRuntime(binding.childSessionId); return resolved; },
83
+ reconcile: async (state) => {
84
+ if (this.ctx.localAgent.isDelegationActive(binding.childSessionId))
85
+ return { active: true, matches: 'unknown', resolved: {} };
86
+ await this.deps.liveSwitch.retireMemberRuntime(binding.childSessionId);
87
+ return { active: false, matches: 'current', resolved: await resolve(state.current.selection) };
88
+ },
89
+ };
90
+ }
91
+ async modelDirectory(childSessionId, refresh = false) {
92
+ if (refresh)
93
+ await this.deps.catalog?.refresh(childSessionId);
94
+ const info = await this.modelInfo(childSessionId);
95
+ return info.directory ?? { entries: [], complete: false, customInput: true, status: 'unsupported', refreshing: false, revision: 0 };
96
+ }
97
+ async *followModelDirectory(childSessionId, signal) {
98
+ if (this.deps.catalog === undefined) {
99
+ yield await this.modelDirectory(childSessionId);
100
+ return;
101
+ }
102
+ for await (const _snapshot of this.deps.catalog.follow(childSessionId, signal))
103
+ yield await this.modelDirectory(childSessionId);
104
+ }
105
+ /**
106
+ * Record the model a member's delegation STARTED with (the provider's call
107
+ * site, fresh and resume rounds alike). The live driver's spawn resolver
108
+ * ranks it between the override and the settings layer.
109
+ */
110
+ noteStartModel(childSessionId, model) {
111
+ const value = model.trim();
112
+ if (value !== '')
113
+ this.startModels.set(childSessionId, value);
114
+ }
115
+ /**
116
+ * The member's session-level override, when the composer picker set one —
117
+ * the exec path's top layer (it outranks even the delegation's recorded
118
+ * model, so the provider folds it into the requested slot).
119
+ */
120
+ overrideFor(childSessionId) {
121
+ return this.overrides.get(childSessionId);
122
+ }
123
+ /**
124
+ * The member's spawn-binding resolver (the live driver config): override,
125
+ * then the member's start model, then the settings layer. Undefined writes
126
+ * nothing — the scoped config's own `default_model` decides, as it always
127
+ * did.
128
+ */
129
+ spawnModel = (childSessionId) => this.overrideFor(childSessionId)
130
+ ?? this.startModels.get(childSessionId)
131
+ ?? this.deps.settingsModel();
132
+ /**
133
+ * Read the model surface. With no member this is the harness default (the
134
+ * settings card's "what would a round run with"): override and delegation
135
+ * stay absent by construction.
136
+ */
137
+ async modelInfo(childSessionId, delegationModel) {
138
+ const override = childSessionId === undefined ? undefined : this.overrideFor(childSessionId);
139
+ const delegation = childSessionId === undefined
140
+ ? undefined
141
+ : delegationModel ?? this.startModels.get(childSessionId);
142
+ const settings = this.deps.settingsModel();
143
+ const homeDir = this.deps.homeDir(childSessionId);
144
+ const native = this.deps.catalog?.read(childSessionId);
145
+ const [cliDefault, discovered] = await Promise.all([
146
+ readKimiDefaultModel(homeDir).catch(() => undefined),
147
+ listKimiConfigModels(homeDir).catch(() => []),
148
+ ]);
149
+ const choices = dedupe([settings, cliDefault, ...discovered, ...(native?.entries.filter(entry => !entry.hidden).map(entry => entry.value) ?? []), ...this.deps.recentModels()]);
150
+ const [effective, source] = override !== undefined ? [override, 'override']
151
+ : delegation !== undefined ? [delegation, 'delegation']
152
+ : settings !== undefined ? [settings, 'settings']
153
+ : cliDefault !== undefined ? [cliDefault, 'cli-config']
154
+ : [undefined, 'cli-builtin'];
155
+ const inFlight = childSessionId !== undefined
156
+ && this.ctx.localAgent.activeDelegations().includes(childSessionId);
157
+ return {
158
+ ...effective === undefined ? {} : { effective },
159
+ source,
160
+ ...override === undefined ? {} : { override },
161
+ ...delegation === undefined ? {} : { delegation },
162
+ ...settings === undefined ? {} : { settings },
163
+ ...cliDefault === undefined ? {} : { cliDefault },
164
+ choices,
165
+ ...native === undefined ? {} : { directory: extendModelDirectory(native, [settings, cliDefault, ...discovered], this.deps.recentModels()) },
166
+ live: this.deps.isLive(),
167
+ switchable: !inFlight,
168
+ ...inFlight ? { reason: SWITCH_IN_FLIGHT_REASON } : {},
169
+ };
170
+ }
171
+ /**
172
+ * Set (or clear) the member's session-level override. Throws while a round
173
+ * is in flight for the member — the switch retires the resident runtime,
174
+ * and retiring mid-round would kill the run. A same-value set (or clearing
175
+ * an unset override) is a no-op; otherwise a live runtime whose bound model
176
+ * differs from the new effective model is retired so the next round
177
+ * respawns onto it, resuming the same CLI session.
178
+ */
179
+ async setMemberModel(childSessionId, model) {
180
+ if (typeof this.ctx.localAgent.setMemberModel === 'function')
181
+ return this.ctx.localAgent.setMemberModel(childSessionId, model);
182
+ if (this.ctx.localAgent.activeDelegations().includes(childSessionId)) {
183
+ throw new Error(SWITCH_IN_FLIGHT_REASON);
184
+ }
185
+ const value = model?.trim();
186
+ const previous = this.overrides.get(childSessionId);
187
+ if (value === undefined || value === '') {
188
+ if (previous === undefined)
189
+ return;
190
+ this.overrides.delete(childSessionId);
191
+ }
192
+ else {
193
+ if (previous === value)
194
+ return;
195
+ this.overrides.set(childSessionId, value);
196
+ }
197
+ // Retire only when the runtime's bound model no longer matches what the
198
+ // member's next round resolves to — a same-model switch keeps the process.
199
+ const effective = (await this.modelInfo(childSessionId)).effective;
200
+ if (this.deps.liveSwitch.memberHasRuntime(childSessionId)
201
+ && this.deps.liveSwitch.memberRuntimeModel(childSessionId) !== effective) {
202
+ await this.deps.liveSwitch.retireMemberRuntime(childSessionId);
203
+ }
204
+ }
205
+ }
@@ -0,0 +1,26 @@
1
+ import type { LocalAgentModelDirectory, LocalAgentModelDirectoryData } from '@khorsheed/dsh-local-agent/types';
2
+ export interface KimiNativeConfiguration {
3
+ directory: LocalAgentModelDirectoryData;
4
+ modelConfigId?: string;
5
+ effortConfigId?: string;
6
+ currentModel?: string;
7
+ currentEffort?: string;
8
+ }
9
+ /** ACP v1 configOptions are authoritative; old models metadata is a read-only fallback. */
10
+ export declare function kimiNativeConfiguration(value: unknown): KimiNativeConfiguration | undefined;
11
+ /** Reads configuration before a session exists; never starts a disposable ACP session. */
12
+ export declare class KimiModelCatalog {
13
+ private readonly context;
14
+ private readonly cache;
15
+ constructor(context: (childSessionId?: string) => {
16
+ homeDir: string;
17
+ cwd?: string;
18
+ native?: KimiNativeConfiguration;
19
+ });
20
+ private key;
21
+ read(childSessionId?: string): LocalAgentModelDirectory;
22
+ refresh(childSessionId?: string): Promise<LocalAgentModelDirectory>;
23
+ follow(childSessionId: string | undefined, signal: AbortSignal): AsyncIterable<LocalAgentModelDirectory>;
24
+ dispose(): void;
25
+ }
26
+ //# sourceMappingURL=model-catalog.d.ts.map