mslxdff 0.1.45 → 0.1.55
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/README.md +162 -162
- package/bin/mslxdff.js +202 -21
- package/docs/adr/0001-reasoning-content-injection.md +13 -13
- package/docs/adr/0002-models-free-filter.md +11 -11
- package/docs/adr/0003-zero-state-no-auth.md +9 -9
- package/docs/adr/0004-bearer-token.md +17 -17
- package/docs/agents/domain.md +50 -50
- package/docs/agents/issue-tracker.md +29 -29
- package/docs/agents/triage-labels.md +14 -14
- package/package.json +1 -1
- package/src/auto.js +20 -2
- package/src/chooser.js +12 -6
- package/src/daemon.js +84 -84
- package/src/logs.js +41 -2
- package/src/models.js +127 -127
- package/src/reasoning.js +32 -32
- package/src/routes/chat/broadband-handler.js +88 -0
- package/src/routes/chat/exhausted-handler.js +41 -0
- package/src/routes/chat/hedge-handler.js +142 -0
- package/src/routes/chat/index.js +178 -0
- package/src/routes/chat/local-handler.js +88 -0
- package/src/routes/chat/peer-handler.js +58 -0
- package/src/routes/chat.js +3 -342
- package/src/routes/hedge.js +251 -0
- package/src/routes/peers.js +49 -15
- package/src/server.js +57 -57
- package/src/state.js +285 -152
- package/src/sync-workbuddy.js +104 -0
- package/src/upstream.js +311 -212
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
# ADR-0001: Inject a reasoning_content placeholder on outbound assistant messages
|
|
2
|
-
|
|
3
|
-
The Zen upstream's thinking-mode models (deepseek-family at minimum) return
|
|
4
|
-
`400 "The reasoning_content in the thinking mode must be passed back"` when a
|
|
5
|
-
multi-turn request echoes an assistant message without its `reasoning_content`.
|
|
6
|
-
Clients speaking plain OpenAI format never send that field, so the proxy writes
|
|
7
|
-
a `" "` placeholder into assistant messages before forwarding. Scope is `all`
|
|
8
|
-
for deepseek-family models and `tool_calls` for kimi-family models; messages
|
|
9
|
-
that already carry non-empty `reasoning_content` are left untouched.
|
|
10
|
-
|
|
11
|
-
The alternative — telling clients to manage `reasoning_content` themselves —
|
|
12
|
-
would break standard OpenAI-compatible clients, so the proxy eats this
|
|
13
|
-
compatibility cost instead. Matches `/root/9router` v0.5.45
|
|
1
|
+
# ADR-0001: Inject a reasoning_content placeholder on outbound assistant messages
|
|
2
|
+
|
|
3
|
+
The Zen upstream's thinking-mode models (deepseek-family at minimum) return
|
|
4
|
+
`400 "The reasoning_content in the thinking mode must be passed back"` when a
|
|
5
|
+
multi-turn request echoes an assistant message without its `reasoning_content`.
|
|
6
|
+
Clients speaking plain OpenAI format never send that field, so the proxy writes
|
|
7
|
+
a `" "` placeholder into assistant messages before forwarding. Scope is `all`
|
|
8
|
+
for deepseek-family models and `tool_calls` for kimi-family models; messages
|
|
9
|
+
that already carry non-empty `reasoning_content` are left untouched.
|
|
10
|
+
|
|
11
|
+
The alternative — telling clients to manage `reasoning_content` themselves —
|
|
12
|
+
would break standard OpenAI-compatible clients, so the proxy eats this
|
|
13
|
+
compatibility cost instead. Matches `/root/9router` v0.5.45
|
|
14
14
|
`open-sse/utils/reasoningContentInjector.js`.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
# ADR-0002: /v1/models exposes only free models, matched by suffix or whitelist
|
|
2
|
-
|
|
3
|
-
The upstream `/zen/v1/models` list contains ~60 models; exposing them all would
|
|
4
|
-
pollute clients with paid models this proxy can't serve for free. `/v1/models`
|
|
5
|
-
therefore filters to: `id` ending in `-free`, OR the explicit whitelist entry
|
|
6
|
-
`big-pickle`. The whitelist exists because `big-pickle` is a free model without
|
|
7
|
-
the `-free` suffix, and a suffix-only filter would silently drop it.
|
|
8
|
-
|
|
9
|
-
A plain `endsWith("-free")` filter was considered and rejected for exactly that
|
|
10
|
-
reason. Matches `/root/9router` v0.5.45
|
|
11
|
-
`src/app/api/providers/suggested-models/filters.js`
|
|
1
|
+
# ADR-0002: /v1/models exposes only free models, matched by suffix or whitelist
|
|
2
|
+
|
|
3
|
+
The upstream `/zen/v1/models` list contains ~60 models; exposing them all would
|
|
4
|
+
pollute clients with paid models this proxy can't serve for free. `/v1/models`
|
|
5
|
+
therefore filters to: `id` ending in `-free`, OR the explicit whitelist entry
|
|
6
|
+
`big-pickle`. The whitelist exists because `big-pickle` is a free model without
|
|
7
|
+
the `-free` suffix, and a suffix-only filter would silently drop it.
|
|
8
|
+
|
|
9
|
+
A plain `endsWith("-free")` filter was considered and rejected for exactly that
|
|
10
|
+
reason. Matches `/root/9router` v0.5.45
|
|
11
|
+
`src/app/api/providers/suggested-models/filters.js`
|
|
12
12
|
(`KNOWN_FREE_OPENCODE_MODELS = ["big-pickle"]`).
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# ADR-0003: Zero-state, no-DB, no-account local proxy
|
|
2
|
-
|
|
3
|
-
Status: partially superseded by [ADR-0004](./0004-bearer-token.md) — the
|
|
4
|
-
no-auth clause below is replaced; the zero-DB / no-account / no-cloud principles
|
|
5
|
-
stand.
|
|
6
|
-
|
|
7
|
-
By design this proxy holds no database, no token store, no account rotation,
|
|
8
|
-
and no cloud sync. A single static bearer token is the only credential, kept
|
|
9
|
-
in a 0600 state file (see ADR-0004); everything else is stateless per-process
|
|
1
|
+
# ADR-0003: Zero-state, no-DB, no-account local proxy
|
|
2
|
+
|
|
3
|
+
Status: partially superseded by [ADR-0004](./0004-bearer-token.md) — the
|
|
4
|
+
no-auth clause below is replaced; the zero-DB / no-account / no-cloud principles
|
|
5
|
+
stand.
|
|
6
|
+
|
|
7
|
+
By design this proxy holds no database, no token store, no account rotation,
|
|
8
|
+
and no cloud sync. A single static bearer token is the only credential, kept
|
|
9
|
+
in a 0600 state file (see ADR-0004); everything else is stateless per-process
|
|
10
10
|
memory at most.
|
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
# ADR-0004: Single static bearer token, persisted in a 0600 state file
|
|
2
|
-
|
|
3
|
-
The proxy requires a bearer token on `/v1/*` so an accidentally-exposed port
|
|
4
|
-
isn't an open relay. There is no account system: the token is a random
|
|
5
|
-
`crypto` 32-byte value (hex), generated once on first run, persisted to a
|
|
6
|
-
state file (default `~/.config/mslxdff/state.json`, `0600`, path overridable
|
|
7
|
-
via `MSLXDFF_STATE_FILE`), and printed to stdout on creation. Rotate with
|
|
8
|
-
`mslxdff -refresh-token`, which regenerates, rewrites the file, prints the
|
|
9
|
-
new token, and exits (does not start the server).
|
|
10
|
-
|
|
11
|
-
Auth is enforced with a constant-time string compare on
|
|
12
|
-
`Authorization: Bearer <token>`; mismatches get `401` with `WWW-Authenticate`.
|
|
13
|
-
`/health` stays public (no token). Tokens never appear in logs.
|
|
14
|
-
|
|
15
|
-
Alternatives rejected: a fixed default token (same key on every install),
|
|
16
|
-
per-user accounts (needs a DB — that's the 9Router provisioning surface we
|
|
17
|
-
rejected in ADR-0003), and unauthenticated local-only binding (fragile;
|
|
1
|
+
# ADR-0004: Single static bearer token, persisted in a 0600 state file
|
|
2
|
+
|
|
3
|
+
The proxy requires a bearer token on `/v1/*` so an accidentally-exposed port
|
|
4
|
+
isn't an open relay. There is no account system: the token is a random
|
|
5
|
+
`crypto` 32-byte value (hex), generated once on first run, persisted to a
|
|
6
|
+
state file (default `~/.config/mslxdff/state.json`, `0600`, path overridable
|
|
7
|
+
via `MSLXDFF_STATE_FILE`), and printed to stdout on creation. Rotate with
|
|
8
|
+
`mslxdff -refresh-token`, which regenerates, rewrites the file, prints the
|
|
9
|
+
new token, and exits (does not start the server).
|
|
10
|
+
|
|
11
|
+
Auth is enforced with a constant-time string compare on
|
|
12
|
+
`Authorization: Bearer <token>`; mismatches get `401` with `WWW-Authenticate`.
|
|
13
|
+
`/health` stays public (no token). Tokens never appear in logs.
|
|
14
|
+
|
|
15
|
+
Alternatives rejected: a fixed default token (same key on every install),
|
|
16
|
+
per-user accounts (needs a DB — that's the 9Router provisioning surface we
|
|
17
|
+
rejected in ADR-0003), and unauthenticated local-only binding (fragile;
|
|
18
18
|
a proxy relay deserves an explicit secret even on localhost).
|
package/docs/agents/domain.md
CHANGED
|
@@ -1,51 +1,51 @@
|
|
|
1
|
-
# Domain Docs
|
|
2
|
-
|
|
3
|
-
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
|
|
4
|
-
|
|
5
|
-
## Before exploring, read these
|
|
6
|
-
|
|
7
|
-
- **`CONTEXT.md`** at the repo root, or
|
|
8
|
-
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
|
|
9
|
-
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
|
|
10
|
-
|
|
11
|
-
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
|
|
12
|
-
|
|
13
|
-
## File structure
|
|
14
|
-
|
|
15
|
-
Single-context repo (most repos):
|
|
16
|
-
|
|
17
|
-
```
|
|
18
|
-
/
|
|
19
|
-
├── CONTEXT.md
|
|
20
|
-
├── docs/adr/
|
|
21
|
-
│ ├── 0001-event-sourced-orders.md
|
|
22
|
-
│ └── 0002-postgres-for-write-model.md
|
|
23
|
-
└── src/
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
|
|
27
|
-
|
|
28
|
-
```
|
|
29
|
-
/
|
|
30
|
-
├── CONTEXT-MAP.md
|
|
31
|
-
├── docs/adr/ ← system-wide decisions
|
|
32
|
-
└── src/
|
|
33
|
-
├── ordering/
|
|
34
|
-
│ ├── CONTEXT.md
|
|
35
|
-
│ └── docs/adr/ ← context-specific decisions
|
|
36
|
-
└── billing/
|
|
37
|
-
├── CONTEXT.md
|
|
38
|
-
└── docs/adr/
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
## Use the glossary's vocabulary
|
|
42
|
-
|
|
43
|
-
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
|
|
44
|
-
|
|
45
|
-
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
|
|
46
|
-
|
|
47
|
-
## Flag ADR conflicts
|
|
48
|
-
|
|
49
|
-
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
|
|
50
|
-
|
|
1
|
+
# Domain Docs
|
|
2
|
+
|
|
3
|
+
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
|
|
4
|
+
|
|
5
|
+
## Before exploring, read these
|
|
6
|
+
|
|
7
|
+
- **`CONTEXT.md`** at the repo root, or
|
|
8
|
+
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
|
|
9
|
+
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
|
|
10
|
+
|
|
11
|
+
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
|
|
12
|
+
|
|
13
|
+
## File structure
|
|
14
|
+
|
|
15
|
+
Single-context repo (most repos):
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
/
|
|
19
|
+
├── CONTEXT.md
|
|
20
|
+
├── docs/adr/
|
|
21
|
+
│ ├── 0001-event-sourced-orders.md
|
|
22
|
+
│ └── 0002-postgres-for-write-model.md
|
|
23
|
+
└── src/
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
/
|
|
30
|
+
├── CONTEXT-MAP.md
|
|
31
|
+
├── docs/adr/ ← system-wide decisions
|
|
32
|
+
└── src/
|
|
33
|
+
├── ordering/
|
|
34
|
+
│ ├── CONTEXT.md
|
|
35
|
+
│ └── docs/adr/ ← context-specific decisions
|
|
36
|
+
└── billing/
|
|
37
|
+
├── CONTEXT.md
|
|
38
|
+
└── docs/adr/
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Use the glossary's vocabulary
|
|
42
|
+
|
|
43
|
+
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
|
|
44
|
+
|
|
45
|
+
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
|
|
46
|
+
|
|
47
|
+
## Flag ADR conflicts
|
|
48
|
+
|
|
49
|
+
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
|
|
50
|
+
|
|
51
51
|
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
|
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
# Issue tracker: Local Markdown
|
|
2
|
-
|
|
3
|
-
Issues and specs (you may know a spec as a PRD) for this repo live as markdown files in `.scratch/`.
|
|
4
|
-
|
|
5
|
-
## Conventions
|
|
6
|
-
|
|
7
|
-
- One feature per directory: `.scratch/<feature-slug>/`
|
|
8
|
-
- The spec is `.scratch/<feature-slug>/spec.md`
|
|
9
|
-
- Implementation issues are one file per ticket at `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, numbered from `01` — never a single combined tickets file
|
|
10
|
-
- Triage state is recorded as a `Status:` line near the top of each issue file (see `triage-labels.md` for the role strings)
|
|
11
|
-
- Comments and conversation history append to the bottom of the file under a `## Comments` heading
|
|
12
|
-
|
|
13
|
-
## When a skill says "publish to the issue tracker"
|
|
14
|
-
|
|
15
|
-
Create a new file under `.scratch/<feature-slug>/` (creating the directory if needed).
|
|
16
|
-
|
|
17
|
-
## When a skill says "fetch the relevant ticket"
|
|
18
|
-
|
|
19
|
-
Read the file at the referenced path. The user will normally pass the path or the issue number directly.
|
|
20
|
-
|
|
21
|
-
## Wayfinding operations
|
|
22
|
-
|
|
23
|
-
Used by `/wayfinder`. The **map** is a file with one **child** file per ticket.
|
|
24
|
-
|
|
25
|
-
- **Map**: `.scratch/<effort>/map.md` — the Notes / Decisions-so-far / Fog body.
|
|
26
|
-
- **Child ticket**: `.scratch/<effort>/issues/NN-<slug>.md`, numbered from `01`, with the question in the body. A `Type:` line records the ticket type (`research`/`prototype`/`grilling`/`task`); a `Status:` line records `claimed`/`resolved`.
|
|
27
|
-
- **Blocking**: a `Blocked by: NN, NN` line near the top. A ticket is unblocked when every file it lists is `resolved`.
|
|
28
|
-
- **Frontier**: scan `.scratch/<effort>/issues/` for files that are open, unblocked, and unclaimed; first by number wins.
|
|
29
|
-
- **Claim**: set `Status: claimed` and save before any work.
|
|
1
|
+
# Issue tracker: Local Markdown
|
|
2
|
+
|
|
3
|
+
Issues and specs (you may know a spec as a PRD) for this repo live as markdown files in `.scratch/`.
|
|
4
|
+
|
|
5
|
+
## Conventions
|
|
6
|
+
|
|
7
|
+
- One feature per directory: `.scratch/<feature-slug>/`
|
|
8
|
+
- The spec is `.scratch/<feature-slug>/spec.md`
|
|
9
|
+
- Implementation issues are one file per ticket at `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, numbered from `01` — never a single combined tickets file
|
|
10
|
+
- Triage state is recorded as a `Status:` line near the top of each issue file (see `triage-labels.md` for the role strings)
|
|
11
|
+
- Comments and conversation history append to the bottom of the file under a `## Comments` heading
|
|
12
|
+
|
|
13
|
+
## When a skill says "publish to the issue tracker"
|
|
14
|
+
|
|
15
|
+
Create a new file under `.scratch/<feature-slug>/` (creating the directory if needed).
|
|
16
|
+
|
|
17
|
+
## When a skill says "fetch the relevant ticket"
|
|
18
|
+
|
|
19
|
+
Read the file at the referenced path. The user will normally pass the path or the issue number directly.
|
|
20
|
+
|
|
21
|
+
## Wayfinding operations
|
|
22
|
+
|
|
23
|
+
Used by `/wayfinder`. The **map** is a file with one **child** file per ticket.
|
|
24
|
+
|
|
25
|
+
- **Map**: `.scratch/<effort>/map.md` — the Notes / Decisions-so-far / Fog body.
|
|
26
|
+
- **Child ticket**: `.scratch/<effort>/issues/NN-<slug>.md`, numbered from `01`, with the question in the body. A `Type:` line records the ticket type (`research`/`prototype`/`grilling`/`task`); a `Status:` line records `claimed`/`resolved`.
|
|
27
|
+
- **Blocking**: a `Blocked by: NN, NN` line near the top. A ticket is unblocked when every file it lists is `resolved`.
|
|
28
|
+
- **Frontier**: scan `.scratch/<effort>/issues/` for files that are open, unblocked, and unclaimed; first by number wins.
|
|
29
|
+
- **Claim**: set `Status: claimed` and save before any work.
|
|
30
30
|
- **Resolve**: append the answer under an `## Answer` heading, set `Status: resolved`, then append a context pointer (gist + link) to the map's Decisions-so-far in `map.md`.
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
# Triage Labels
|
|
2
|
-
|
|
3
|
-
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
|
|
4
|
-
|
|
5
|
-
| Label in mattpocock/skills | Label in our tracker | Meaning |
|
|
6
|
-
| -------------------------- | -------------------- | ---------------------------------------- |
|
|
7
|
-
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
|
|
8
|
-
| `needs-info` | `needs-info` | Waiting on reporter for more information |
|
|
9
|
-
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
|
|
10
|
-
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
|
11
|
-
| `wontfix` | `wontfix` | Will not be actioned |
|
|
12
|
-
|
|
13
|
-
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
|
|
14
|
-
|
|
1
|
+
# Triage Labels
|
|
2
|
+
|
|
3
|
+
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
|
|
4
|
+
|
|
5
|
+
| Label in mattpocock/skills | Label in our tracker | Meaning |
|
|
6
|
+
| -------------------------- | -------------------- | ---------------------------------------- |
|
|
7
|
+
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
|
|
8
|
+
| `needs-info` | `needs-info` | Waiting on reporter for more information |
|
|
9
|
+
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
|
|
10
|
+
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
|
11
|
+
| `wontfix` | `wontfix` | Will not be actioned |
|
|
12
|
+
|
|
13
|
+
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
|
|
14
|
+
|
|
15
15
|
Edit the right-hand column to match whatever vocabulary you actually use.
|
package/package.json
CHANGED
package/src/auto.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { statSync } from "node:fs";
|
|
2
|
-
import { loadModelErrors, saveModelErrors, loadModelLatencies, saveModelLatencies, loadPreferredModel, defaultStateFile } from "./state.js";
|
|
2
|
+
import { loadModelErrors, saveModelErrors, loadModelLatencies, saveModelLatencies, loadPreferredModel, loadModelPicks, saveModelPicks, defaultStateFile } from "./state.js";
|
|
3
3
|
|
|
4
4
|
// 出厂默认首选模型(state.json 的 preferredModel / env MSLXDFF_PREFERRED_MODEL 可覆盖)
|
|
5
5
|
export const DEFAULT_PREFERRED_MODEL = "big-pickle";
|
|
@@ -129,6 +129,8 @@ export function createAutoSelector({
|
|
|
129
129
|
latencies: seedLatencies,
|
|
130
130
|
persist = (errors, f = file) => saveModelErrors(errors, f ? { file: f } : {}),
|
|
131
131
|
persistLatencies = (latencies, f = file) => saveModelLatencies(latencies, f ? { file: f } : {}),
|
|
132
|
+
loadPicks = () => (file ? loadModelPicks({ file }) : []),
|
|
133
|
+
persistPicks = (picks) => (file ? saveModelPicks(picks, { file }) : picks),
|
|
132
134
|
} = {}) {
|
|
133
135
|
const lastErrorAt = { ...(seedErrors ?? loadModelErrors(file ? { file } : {})) };
|
|
134
136
|
const latencies = { ...(seedLatencies ?? loadModelLatencies(file ? { file } : {})) };
|
|
@@ -144,13 +146,29 @@ export function createAutoSelector({
|
|
|
144
146
|
return [...new Set(list)].filter(Boolean);
|
|
145
147
|
}
|
|
146
148
|
|
|
149
|
+
// 勾选集 = auto 候选池白名单:只在勾选的模型里择优;空勾选或勾选中无可用模型时回退全量
|
|
150
|
+
async function pickedPool(list) {
|
|
151
|
+
const picks = loadPicks();
|
|
152
|
+
if (!picks.length) return list;
|
|
153
|
+
const pickedSet = new Set(picks);
|
|
154
|
+
const filtered = list.filter((id) => pickedSet.has(id));
|
|
155
|
+
return filtered.length ? filtered : list;
|
|
156
|
+
}
|
|
157
|
+
|
|
147
158
|
async function candidates() {
|
|
148
|
-
|
|
159
|
+
const list = await loadList();
|
|
160
|
+
const pool = await pickedPool(list);
|
|
161
|
+
return rankModels(pool, lastErrorAt, { now: now(), cooldownMs, slowCooldownMs, latencies, preferred: getPreferredModel({ file: file ?? undefined }) });
|
|
149
162
|
}
|
|
150
163
|
|
|
151
164
|
async function candidatesFor(requested) {
|
|
152
165
|
if (!requested) return candidates();
|
|
153
166
|
const list = await loadList();
|
|
167
|
+
// 显式指定某模型 = 认可它,自动加入勾选集(仅当它是真实上游 free 模型时,避免垃圾 id 污染)
|
|
168
|
+
const picks = loadPicks();
|
|
169
|
+
if (list.includes(requested) && !picks.includes(requested) && persistPicks) {
|
|
170
|
+
await persistPicks([...picks, requested]);
|
|
171
|
+
}
|
|
154
172
|
const all = list.includes(requested) ? list : [requested, ...list];
|
|
155
173
|
const others = rankModels(all.filter((id) => id !== requested), lastErrorAt, {
|
|
156
174
|
now: now(),
|
package/src/chooser.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// 交互式模型选择器的纯渲染逻辑(便于测试);键盘循环在 bin/mslxdff.js
|
|
2
2
|
|
|
3
|
-
// items: [{ id, status?, ms?, fail?, current? }];cursor 当前高亮行
|
|
3
|
+
// items: [{ id, status?, ms?, fail?, current?, picked? }];cursor 当前高亮行
|
|
4
|
+
// multi=true 时多选勾选(picked 显示 [x]/[ ]),否则单选默认模型(当前显示 ✓)
|
|
4
5
|
// 返回行数组(无 ANSI 颜色,纯文本标记,Windows 终端友好)
|
|
5
|
-
export function renderChooser(items, cursor = 0) {
|
|
6
|
+
export function renderChooser(items, cursor = 0, { multi = false } = {}) {
|
|
6
7
|
return items.map((it, i) => {
|
|
7
8
|
const arrow = i === cursor ? "❯" : " ";
|
|
8
|
-
const check =
|
|
9
|
+
const check = multi
|
|
10
|
+
? (it.picked ? " [✓]" : " [ ]")
|
|
11
|
+
: (it.current ? " ✓ (current)" : "");
|
|
9
12
|
let state = "";
|
|
10
13
|
if (it.fail) state = ` [fail: ${it.fail}]`;
|
|
11
14
|
else if (it.ms != null) state = ` [${it.ms}ms]`;
|
|
@@ -14,16 +17,19 @@ export function renderChooser(items, cursor = 0) {
|
|
|
14
17
|
});
|
|
15
18
|
}
|
|
16
19
|
|
|
17
|
-
export function renderChooserHelp() {
|
|
18
|
-
return
|
|
20
|
+
export function renderChooserHelp(multi = false) {
|
|
21
|
+
return multi
|
|
22
|
+
? ["", "↑/↓ move · Space toggle pick · Enter save picks · q/Esc cancel"]
|
|
23
|
+
: ["", "↑/↓ move · Enter select as default · q/Esc cancel"];
|
|
19
24
|
}
|
|
20
25
|
|
|
21
|
-
// 解析按键:返回 "up" | "down" | "enter" | "cancel" | null(忽略)
|
|
26
|
+
// 解析按键:返回 "up" | "down" | "enter" | "cancel" | "space" | null(忽略)
|
|
22
27
|
export function parseKey(str) {
|
|
23
28
|
if (!str) return null;
|
|
24
29
|
if (str === "\x1b[A" || str === "k") return "up";
|
|
25
30
|
if (str === "\x1b[B" || str === "j") return "down";
|
|
26
31
|
if (str === "\r" || str === "\n") return "enter";
|
|
32
|
+
if (str === " " || str === "\x1b[32") return "space"; // 空格(32=0x20 的 ASC 表示)或某些终端 terminator
|
|
27
33
|
if (str === "\x1b" || str === "q" || str === "\x03") return "cancel";
|
|
28
34
|
return null;
|
|
29
35
|
}
|
package/src/daemon.js
CHANGED
|
@@ -1,84 +1,84 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
3
|
-
import { join, dirname } from "node:path";
|
|
4
|
-
import os from "node:os";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
|
|
7
|
-
export function daemonDir() {
|
|
8
|
-
return process.env.MSLXDFF_DAEMON_DIR || join(os.homedir(), ".config", "mslxdff");
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function pidFile() {
|
|
12
|
-
return join(daemonDir(), "daemon.pid");
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function logFile() {
|
|
16
|
-
return join(daemonDir(), "daemon.log");
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function startDaemon(args = []) {
|
|
20
|
-
const here = fileURLToPath(import.meta.url);
|
|
21
|
-
const entry = here.endsWith("bin/mslxdff.js")
|
|
22
|
-
? here
|
|
23
|
-
: join(dirname(here), "..", "bin", "mslxdff.js");
|
|
24
|
-
const dir = daemonDir();
|
|
25
|
-
mkdirSync(dir, { recursive: true });
|
|
26
|
-
const logFd = openSync(logFile(), "a", 0o600);
|
|
27
|
-
const env = { ...process.env, MSLXDFF_DAEMON: "1" };
|
|
28
|
-
// a -debug foreground session wouldn't pass MSLXDFF_DEBUG to the
|
|
29
|
-
// background daemon it restores (that flag means "print events to stdout")
|
|
30
|
-
delete env.MSLXDFF_DEBUG;
|
|
31
|
-
const child = spawn(process.execPath, [entry, ...args, "--daemon"], {
|
|
32
|
-
detached: true,
|
|
33
|
-
stdio: ["ignore", logFd, logFd],
|
|
34
|
-
env,
|
|
35
|
-
});
|
|
36
|
-
child.unref();
|
|
37
|
-
return child.pid;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function writePid(pid, version) {
|
|
41
|
-
const dir = daemonDir();
|
|
42
|
-
mkdirSync(dir, { recursive: true });
|
|
43
|
-
writeFileSync(pidFile(), version ? `${pid}\n${version}` : String(pid), { mode: 0o600 });
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function readPid() {
|
|
47
|
-
if (!existsSync(pidFile())) return null;
|
|
48
|
-
const raw = readFileSync(pidFile(), "utf8").trim();
|
|
49
|
-
const n = Number(raw.split("\n")[0]);
|
|
50
|
-
return Number.isInteger(n) && n > 0 ? n : null;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function readPidVersion() {
|
|
54
|
-
if (!existsSync(pidFile())) return null;
|
|
55
|
-
const raw = readFileSync(pidFile(), "utf8");
|
|
56
|
-
const lines = raw.split("\n");
|
|
57
|
-
return lines.length > 1 && lines[1].trim() ? lines[1].trim() : null;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Best-effort liveness check (signal 0); ESRCH means the process is gone.
|
|
61
|
-
export function isPidAlive(pid) {
|
|
62
|
-
try {
|
|
63
|
-
process.kill(pid, 0);
|
|
64
|
-
return true;
|
|
65
|
-
} catch (err) {
|
|
66
|
-
return err.code !== "ESRCH";
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function stopDaemon() {
|
|
71
|
-
const pid = readPid();
|
|
72
|
-
if (!pid) return { stopped: false, reason: "no pid file" };
|
|
73
|
-
try {
|
|
74
|
-
process.kill(pid, "SIGTERM");
|
|
75
|
-
} catch (err) {
|
|
76
|
-
if (err.code !== "ESRCH") throw err;
|
|
77
|
-
}
|
|
78
|
-
try {
|
|
79
|
-
unlinkSync(pidFile());
|
|
80
|
-
} catch {
|
|
81
|
-
// already gone
|
|
82
|
-
}
|
|
83
|
-
return { stopped: true, pid };
|
|
84
|
-
}
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
export function daemonDir() {
|
|
8
|
+
return process.env.MSLXDFF_DAEMON_DIR || join(os.homedir(), ".config", "mslxdff");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function pidFile() {
|
|
12
|
+
return join(daemonDir(), "daemon.pid");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function logFile() {
|
|
16
|
+
return join(daemonDir(), "daemon.log");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function startDaemon(args = []) {
|
|
20
|
+
const here = fileURLToPath(import.meta.url);
|
|
21
|
+
const entry = here.endsWith("bin/mslxdff.js")
|
|
22
|
+
? here
|
|
23
|
+
: join(dirname(here), "..", "bin", "mslxdff.js");
|
|
24
|
+
const dir = daemonDir();
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
const logFd = openSync(logFile(), "a", 0o600);
|
|
27
|
+
const env = { ...process.env, MSLXDFF_DAEMON: "1" };
|
|
28
|
+
// a -debug foreground session wouldn't pass MSLXDFF_DEBUG to the
|
|
29
|
+
// background daemon it restores (that flag means "print events to stdout")
|
|
30
|
+
delete env.MSLXDFF_DEBUG;
|
|
31
|
+
const child = spawn(process.execPath, [entry, ...args, "--daemon"], {
|
|
32
|
+
detached: true,
|
|
33
|
+
stdio: ["ignore", logFd, logFd],
|
|
34
|
+
env,
|
|
35
|
+
});
|
|
36
|
+
child.unref();
|
|
37
|
+
return child.pid;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function writePid(pid, version) {
|
|
41
|
+
const dir = daemonDir();
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
writeFileSync(pidFile(), version ? `${pid}\n${version}` : String(pid), { mode: 0o600 });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readPid() {
|
|
47
|
+
if (!existsSync(pidFile())) return null;
|
|
48
|
+
const raw = readFileSync(pidFile(), "utf8").trim();
|
|
49
|
+
const n = Number(raw.split("\n")[0]);
|
|
50
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function readPidVersion() {
|
|
54
|
+
if (!existsSync(pidFile())) return null;
|
|
55
|
+
const raw = readFileSync(pidFile(), "utf8");
|
|
56
|
+
const lines = raw.split("\n");
|
|
57
|
+
return lines.length > 1 && lines[1].trim() ? lines[1].trim() : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Best-effort liveness check (signal 0); ESRCH means the process is gone.
|
|
61
|
+
export function isPidAlive(pid) {
|
|
62
|
+
try {
|
|
63
|
+
process.kill(pid, 0);
|
|
64
|
+
return true;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
return err.code !== "ESRCH";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function stopDaemon() {
|
|
71
|
+
const pid = readPid();
|
|
72
|
+
if (!pid) return { stopped: false, reason: "no pid file" };
|
|
73
|
+
try {
|
|
74
|
+
process.kill(pid, "SIGTERM");
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (err.code !== "ESRCH") throw err;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
unlinkSync(pidFile());
|
|
80
|
+
} catch {
|
|
81
|
+
// already gone
|
|
82
|
+
}
|
|
83
|
+
return { stopped: true, pid };
|
|
84
|
+
}
|
package/src/logs.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { appendFileSync, readFileSync, mkdirSync, existsSync, writeFileSync, statSync } from "node:fs";
|
|
2
|
+
import { appendFile, stat, readFile, writeFile } from "node:fs/promises";
|
|
2
3
|
import { dirname, join } from "node:path";
|
|
3
4
|
import os from "node:os";
|
|
4
5
|
import { defaultStateFile } from "./state.js";
|
|
@@ -40,10 +41,48 @@ function trimIfOversized(file, maxBytes = MAX_BYTES) {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
async function trimIfOversizedAsync(file, maxBytes = MAX_BYTES) {
|
|
45
|
+
try {
|
|
46
|
+
const st = await stat(file);
|
|
47
|
+
if (st.size <= maxBytes) return;
|
|
48
|
+
const text = await readFile(file, "utf8");
|
|
49
|
+
const lines = text.split("\n");
|
|
50
|
+
const keep = lines.slice(-100);
|
|
51
|
+
await writeFile(file, keep.join("\n"));
|
|
52
|
+
} catch {
|
|
53
|
+
// ignore
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function shouldSync(file) {
|
|
58
|
+
if (process.env.MSLXDFF_LOGS_SYNC === "1") return true;
|
|
59
|
+
if (process.env.MSLXDFF_DAEMON_DIR) {
|
|
60
|
+
const dir = process.env.MSLXDFF_DAEMON_DIR;
|
|
61
|
+
if (file.startsWith(dir)) return true;
|
|
62
|
+
}
|
|
63
|
+
// 显式 tmp 文件(所有 test 的 mkdtemp 前缀)走同步,保证 read-after-write 可见
|
|
64
|
+
const low = file.toLowerCase();
|
|
65
|
+
if (low.includes("mslxdff-") || low.includes("tmp") || low.includes("temp")) return true;
|
|
66
|
+
// 非默认目录的文件一律同步(测试传入的临时路径)
|
|
67
|
+
try {
|
|
68
|
+
const def = logDir().toLowerCase();
|
|
69
|
+
if (!low.startsWith(def)) return true;
|
|
70
|
+
} catch {}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
43
74
|
function appendLine(file, entry) {
|
|
44
75
|
ensureDir(dirname(file));
|
|
45
|
-
|
|
46
|
-
|
|
76
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n";
|
|
77
|
+
if (shouldSync(file)) {
|
|
78
|
+
appendFileSync(file, line);
|
|
79
|
+
trimIfOversized(file);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// 线上异步:不阻塞事件循环
|
|
83
|
+
appendFile(file, line)
|
|
84
|
+
.catch(() => {})
|
|
85
|
+
.then(() => trimIfOversizedAsync(file).catch(() => {}));
|
|
47
86
|
}
|
|
48
87
|
|
|
49
88
|
export function appendCall(entry, { file = callsFile() } = {}) {
|