pi-free-catalog 0.1.1 → 0.1.3
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/CHANGELOG.md +19 -1
- package/README.md +125 -59
- package/package.json +1 -3
- package/scripts/verify-ui-thinking-live.mjs +55 -0
- package/src/catalog.js +11 -4
- package/src/footer.js +42 -0
- package/src/index.js +24 -8
- package/src/thinking-sync.js +80 -0
- package/test/catalog.test.mjs +19 -2
- package/test/english-only.test.mjs +35 -0
- package/test/extension.test.mjs +2 -1
- package/test/footer.test.mjs +33 -0
- package/test/package.test.mjs +9 -1
- package/test/pi-session.test.mjs +41 -0
- package/test/readme.test.mjs +33 -0
- package/test/sdk.test.mjs +2 -2
- package/test/thinking-sync.test.mjs +39 -0
- package/test/transport.test.mjs +1 -1
- package/PUBLISHING-fa.md +0 -115
- package/README-fa.md +0 -127
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.3 — prepared for release
|
|
4
|
+
|
|
5
|
+
- Removed the legacy non-English publishing guide and its package manifest entry. Replaced synthetic non-English test strings with English text plus emoji, preserving Unicode coverage. Added a source/fixture language regression check.
|
|
6
|
+
- Rewrote the public English README for clarity and natural phrasing. Removed local documentation links that resolve incorrectly on npm/Pi catalog pages; added link and version-example regression tests.
|
|
7
|
+
- Removed the Persian README and its live link/package inclusion; all non-English documentation has now been removed.
|
|
8
|
+
- Hide only the free-provider prefix in the TUI footer using Pi's public custom-footer API and exported renderer; preserve provider identities, picker, statistics and warnings. Add `/free-footer compact|default` for footer-extension coexistence.
|
|
9
|
+
- Two-way native/Pi thinking synchronization through `setThinkingLevel` and `thinking_level_select`. A later Pi change releases stale per-model overrides; exact toggle/effort equivalence only. Model switching preserves saved native preferences without feedback loops.
|
|
10
|
+
- Fix Pi `off` serialization: omitted SDK `reasoning` now selects an advertised disabled mode instead of unintentionally preserving the provider default.
|
|
11
|
+
- Explicit status for provider-default/unmapped future modes; no invented native settings or hidden models.
|
|
12
|
+
- Serialize preference writes and drain them on session shutdown.
|
|
13
|
+
- Regression tests include the real Pi AgentSession/event runner/SDK with mocked HTTP, plus the actual stock footer renderer. Live tests are reported separately.
|
|
14
|
+
|
|
15
|
+
## 0.1.2 — 2026-10-17
|
|
16
|
+
|
|
17
|
+
- Storage: the saved catalog state is now gzipped and compact instead of pretty-printed. On a real catalog this is ~27 MB → ~1.3 MB (about 95% smaller), cutting startup load time and the bytes rewritten on every refresh. `load()` accepts gzipped state and plain JSON, so pre-0.1.2 catalogs still load unchanged.
|
|
18
|
+
- Fixed: `atomicJSON` wrote machine-read state with 2-space indentation, roughly doubling the file for no benefit. Indentation remains off for the catalog; user-facing exports (`/free-export`, `scripts/catalog.mjs`) stay readable plain JSON.
|
|
19
|
+
- Tests: added a regression test asserting the saved state is gzip-magic, far smaller than pretty JSON, round-trips through `load()`, and that a legacy plain-JSON cache still loads.
|
|
20
|
+
|
|
3
21
|
## 0.1.1 — 2026-10-17
|
|
4
22
|
|
|
5
23
|
- Public npm release of the unscoped package `pi-free-catalog` (MIT), superseding 0.1.0.
|
|
6
24
|
- Fixed: `test/package.test.mjs` assumed `npm pack --json` returns an array. npm 12 returns an object keyed by package name, so the assertion threw. It now accepts both shapes, which means `prepublishOnly` verification passes on npm 11 and npm 12.
|
|
7
|
-
- Docs: removed the false "has not been published / provisional name" statements from
|
|
25
|
+
- Docs: removed the false "has not been published / provisional name" statements from the release documentation; install instructions now reference the real published name and version.
|
|
8
26
|
- package.json: added `author`; host-provided peer packages now use the `"*"` range prescribed by Pi's `docs/packages.md`.
|
|
9
27
|
|
|
10
28
|
## 0.1.0 — 2026-10-17 — first published release
|
package/README.md
CHANGED
|
@@ -1,115 +1,181 @@
|
|
|
1
1
|
# pi-free-catalog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Find free models from **Kilo** and **OpenCode Zen** in Pi, without keeping a model list up to date yourself.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The package adds two providers, `free-kilo` and `free-zen`. It checks live catalogs and pricing information whenever Pi loads the extension, then makes the confirmed-free models available in the model picker. Models with missing or conflicting prices stay visible in the catalog report, but cannot be called as free.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
No personal API key, account setup, or running OpenCode CLI is needed for the public endpoints used here. This is an independent community integration, not an official Kilo or OpenCode client. Upstream availability, quotas, and access policies still apply.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
**Requirements:** Pi 1.3.x and Node.js 22.19 or later. Licensed under MIT; maintained by `aminmza`.
|
|
10
|
+
|
|
11
|
+
## Get started
|
|
10
12
|
|
|
11
13
|
```bash
|
|
12
|
-
pi install
|
|
14
|
+
pi install npm:pi-free-catalog
|
|
13
15
|
pi
|
|
14
16
|
```
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
Open Pi's model picker and choose a model from `free-kilo` or `free-zen`.
|
|
19
|
+
|
|
20
|
+
For a reproducible install, specify the version:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pi install npm:pi-free-catalog@0.1.3
|
|
24
|
+
```
|
|
17
25
|
|
|
18
|
-
|
|
26
|
+
To try the published package for one run without saving it to your settings:
|
|
19
27
|
|
|
20
28
|
```bash
|
|
21
|
-
pi
|
|
29
|
+
pi -e npm:pi-free-catalog
|
|
22
30
|
```
|
|
23
31
|
|
|
24
|
-
|
|
32
|
+
This is a Pi extension, not a standalone agent. Install it with `pi install`, rather than `npm install -g`.
|
|
33
|
+
|
|
34
|
+
### Use a local copy
|
|
35
|
+
|
|
36
|
+
For development, extract the release source ZIP and keep the extracted folder somewhere permanent:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pi install ./pi-free-catalog-0.1.3
|
|
40
|
+
pi
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Pi loads local packages from their original folder, so do not move or delete it while the package is installed. If you are replacing an npm installation, remove that installation first:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pi remove npm:pi-free-catalog
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Avoid loading both copies at once. From the source folder, `pi -e .` is another way to test a local copy without saving a new package entry.
|
|
50
|
+
|
|
51
|
+
## What appears in the catalog?
|
|
25
52
|
|
|
26
|
-
|
|
53
|
+
The goal is to keep the full picture visible, not just the models that happened to work in a test.
|
|
27
54
|
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
- Native classifiers are registered separately (currently Zen `jev-1.13-free`, using Pi's TypeSafe System One classifier implementation). They are not disguised as chat models.
|
|
35
|
-
- An unknown future operation/protocol is retained with an explicit transport-not-implemented notice. This is not a promise to support future, unknowable wire protocols or metadata schemas automatically.
|
|
55
|
+
- Every live model ID is checked. The words `free` or `paid` in a model's name do not determine its classification.
|
|
56
|
+
- All currently listed, confirmed-free chat models are registered, including models with unknown limits, limited tool support, or previous inference errors.
|
|
57
|
+
- Models with uncertain or conflicting prices remain in `/free-catalog` and `/free-export`, with calls blocked until they can be verified.
|
|
58
|
+
- Previously free, missing, deprecated, and retired models remain in the report and history. They do not quietly disappear from the evidence.
|
|
59
|
+
- Native classifiers, such as Zen's `jev-1.13-free`, are registered separately rather than presented as chat models.
|
|
60
|
+
- An unfamiliar operation or transport stays in the report with a clear unsupported notice. Being listed is not a promise that every future protocol is implemented.
|
|
36
61
|
|
|
37
|
-
|
|
62
|
+
### How free pricing is checked
|
|
38
63
|
|
|
39
|
-
|
|
64
|
+
For **Kilo**, the catalog's explicit `isFree: true` flag is the starting point. A positive charge conflicts with that flag and blocks the model. Zero prices alongside `isFree: false` are also treated as a conflict. Missing or dynamic prices are retained as evidence when the explicit free flag is present.
|
|
40
65
|
|
|
41
|
-
|
|
66
|
+
For **Zen**, the live model list is joined by exact ID with official OpenCode metadata, models.dev, and Pi's model metadata. The check includes input, output, cache, and tiered charges. Missing prices are unknown, not assumed to be zero; conflicting prices are reported and blocked.
|
|
42
67
|
|
|
43
|
-
|
|
68
|
+
The extension cannot verify information that an upstream service does not publish. It keeps that uncertainty visible instead of guessing.
|
|
44
69
|
|
|
45
|
-
|
|
46
|
-
- Missing from a successful catalog: **not-listed**, NOT proven retired; removed from callable models, retained in history. Reappearance restores it automatically.
|
|
47
|
-
- Deprecated but still listed/free: still available, labelled deprecated.
|
|
48
|
-
- Free-to-paid change: removed from callable free models, retained in report/history.
|
|
49
|
-
- HTTP 404/429, capacity failures, timeouts: never taken as retirement proof.
|
|
50
|
-
- Catalog network/JSON/schema/empty-list failures: keep cached entries visible, prominently mark freshness failure, block unverified calls. Do not mass-retire cached models.
|
|
51
|
-
- A cancelled Pi background refresh never invalidates a completed good startup snapshot.
|
|
70
|
+
## Refreshes and model history
|
|
52
71
|
|
|
53
|
-
|
|
72
|
+
All five sources are fetched on every extension load, before initial model selection, including noninteractive runs. An existing cache does not skip that startup check. After five minutes, the next agent turn or request triggers revalidation. Use `/free-refresh` to request an immediate refresh; there is no background polling timer.
|
|
73
|
+
|
|
74
|
+
A failed refresh does not erase the last known catalog. Cached entries stay visible, are marked as unverified, and cannot be called without the required fresh evidence.
|
|
75
|
+
|
|
76
|
+
Changes are handled according to what the sources actually say:
|
|
77
|
+
|
|
78
|
+
| Change | What happens |
|
|
79
|
+
|---|---|
|
|
80
|
+
| Explicit retirement | The model is removed from callable models and kept in history. |
|
|
81
|
+
| Missing from a successful catalog response | It is marked `not-listed`, not assumed retired. It can return if listed again. |
|
|
82
|
+
| Deprecated, but still listed and free | It remains available with a deprecation label. |
|
|
83
|
+
| Free model becomes paid | Free access is blocked; the model remains in the report. |
|
|
84
|
+
| Timeout, 404, 429, or capacity error | The error is recorded, not treated as proof of retirement. |
|
|
85
|
+
|
|
86
|
+
The cache is stored in `free-catalog/catalog.json` inside Pi's agent directory, normally `~/.pi/agent/free-catalog/`. Despite the filename, the cache is compact, gzip-compressed JSON. Older plain-JSON caches still load. It retains raw source responses, timestamps, classifications, and lifecycle history; the recent change log is limited to 2,000 events, but the model inventory is not.
|
|
87
|
+
|
|
88
|
+
`/free-export` writes readable, uncompressed JSON for inspection. Refreshes are bounded by a 12-second timeout and a 16 MiB response limit per source. A successful fetch reflects the upstream data at that moment, not a guarantee that every source agrees instantly.
|
|
54
89
|
|
|
55
90
|
## Commands
|
|
56
91
|
|
|
57
|
-
| Command |
|
|
92
|
+
| Command | What it does |
|
|
58
93
|
|---|---|
|
|
59
|
-
| `/free-catalog` | Show
|
|
60
|
-
| `/free-refresh` |
|
|
61
|
-
| `/free-thinking` |
|
|
62
|
-
| `/free-thinking MODE` |
|
|
63
|
-
| `/free-
|
|
94
|
+
| `/free-catalog` | Show free, uncertain, conflicting, missing, and historical models, plus source health and recent HTTP results. |
|
|
95
|
+
| `/free-refresh` | Fetch the sources again and reconcile the model lists. |
|
|
96
|
+
| `/free-thinking` | Show the current model's native thinking options and selection. |
|
|
97
|
+
| `/free-thinking MODE` | Choose `auto`, `default`, or an advertised native mode. |
|
|
98
|
+
| `/free-footer compact` | Hide the free-provider label beside the model in the footer. |
|
|
99
|
+
| `/free-footer default` | Restore Pi's built-in footer. |
|
|
100
|
+
| `/free-export [PATH]` | Export the complete catalog, including source data and all live IDs, even paid ones. |
|
|
101
|
+
|
|
102
|
+
## Thinking that matches the model
|
|
103
|
+
|
|
104
|
+
Use Pi's normal thinking selector or shortcut as usual. In `auto` mode, the extension translates the selected level into a setting the provider actually advertises. It does not invent effort levels for models that only have an on/off switch.
|
|
105
|
+
|
|
106
|
+
You can also choose a native mode directly. For example, after selecting an appropriate model:
|
|
107
|
+
|
|
108
|
+
```text
|
|
109
|
+
/free-thinking low
|
|
110
|
+
/free-thinking instant
|
|
111
|
+
/free-thinking thinking
|
|
112
|
+
/free-thinking auto
|
|
113
|
+
/free-thinking default
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
These are examples for different models, not options supported by every model. Kilo Beam advertises effort levels such as `low`, `medium`, `high`, `xhigh`, and `max`. Toggle models use modes such as `instant` and `thinking`. Zen effort settings are sent as `reasoning_effort`; Kilo settings use the provider's native parameters. The available choices come from live metadata, so check `/free-thinking` rather than relying on a fixed list.
|
|
64
117
|
|
|
65
|
-
|
|
118
|
+
### Pi and native choices stay in sync
|
|
66
119
|
|
|
67
|
-
`
|
|
120
|
+
When a native mode has an exact Pi equivalent, `/free-thinking` updates Pi's actual level too. For example, an effort model's `low` maps to Pi `low`. On a supported toggle model, `instant` maps to `off`, and `thinking` maps to `medium` as the on/off equivalent—not as an extra provider effort level.
|
|
68
121
|
|
|
69
|
-
|
|
122
|
+
Changing the level in Pi then releases the previous native override for that model and returns it to `auto`. This prevents an old native setting from silently overriding what the Pi footer shows. Pi's omitted `reasoning` option for `off` is handled explicitly, so an advertised disabled mode is sent when available.
|
|
70
123
|
|
|
71
|
-
|
|
72
|
-
- Kilo Beam: `/free-thinking low`, `medium`, `high`, `xhigh`, `max`.
|
|
73
|
-
- Zen Beam: corresponding advertised effort values, sent as `reasoning_effort`.
|
|
74
|
-
- Reset: `/free-thinking auto` or `/free-thinking default`.
|
|
124
|
+
There are a few limits worth knowing:
|
|
75
125
|
|
|
76
|
-
|
|
126
|
+
- `default` leaves thinking to the provider; it is not another spelling of `off`.
|
|
127
|
+
- A native mode without an exact Pi equivalent remains selectable through `/free-thinking`. The status bar labels that override rather than pretending Pi's fixed level names cover it.
|
|
128
|
+
- Choosing the same Pi level again does not emit a change event in Pi 1.3. Use `/free-thinking auto`, or change the level, to release an override.
|
|
129
|
+
- Saved native preferences return when you start a session or switch back to a model. They can take precedence over Pi's initial level. Choose `auto` if you want Pi to control it instead.
|
|
130
|
+
- If a provider advertises no adjustable controls, its default is preserved. Unsupported effort or disable settings are never guessed.
|
|
77
131
|
|
|
78
|
-
|
|
132
|
+
Preferences are saved per provider and model in `thinking.json` beside the cache.
|
|
79
133
|
|
|
80
|
-
##
|
|
134
|
+
## A quieter footer
|
|
81
135
|
|
|
82
|
-
|
|
136
|
+
By default, the extension hides only the `(free-kilo)` or `(free-zen)` prefix beside the model name. Provider IDs and model-picker entries are unchanged. The footer keeps the working directory, branch, token and cache statistics, cost, context usage, thinking level, and extension statuses. Catalog counts and freshness warnings remain visible.
|
|
83
137
|
|
|
84
|
-
|
|
138
|
+
Pi has one custom-footer slot. If another extension controls yours, run `/free-footer default` and let that extension handle the display. This restores the built-in footer; it cannot recover another extension's previous custom footer automatically. `/free-footer compact` enables this package's footer again. The preference is saved in `ui.json`.
|
|
85
139
|
|
|
86
|
-
|
|
140
|
+
The custom footer is used only in the terminal UI and only for the two free providers. Other models use Pi's built-in footer; print, JSON, and RPC modes are unaffected. The implementation uses Pi's exported renderer and was tested with Pi 1.3.0.
|
|
87
141
|
|
|
88
|
-
##
|
|
142
|
+
## Requests, tools, and safety
|
|
143
|
+
|
|
144
|
+
Requests use Pi's own OpenAI Completions, Responses, Anthropic Messages, and native classifier implementations. Pi's SDK handles streaming, tool calls and results, Unicode, cancellation, usage, and finish reasons. The extension does not replace the global HTTP client or add its own SSE parser.
|
|
145
|
+
|
|
146
|
+
Before each HTTP request, a scoped guard checks the model ID, approved endpoint, free classification, and freshness. It blocks redirects and paid fallback, and strips personal authorization and API-key headers. Kilo requests carry no Authorization header. Zen uses the literal public placeholder `Bearer public`, compatible CLI headers, and a stable per-session ID. The User-Agent identifies this integration; no OpenCode binary is launched and no quota identities are rotated.
|
|
147
|
+
|
|
148
|
+
Anonymous access can break if a service changes its policy or protocol requirements. This package does not promise permanent access, unlimited quota, universal tool support, or a particular response speed. An inference error does not blacklist a model or turn off tools to make a test pass.
|
|
149
|
+
|
|
150
|
+
Full capability metadata is retained in exports. This chat integration supports text and image inputs; other advertised modalities are not presented as implemented. When required limits are unknown, the model is labelled accordingly and uses local safety defaults of 8,192 context tokens and 2,048 output tokens. Those are not claimed provider limits.
|
|
151
|
+
|
|
152
|
+
## Development and testing
|
|
153
|
+
|
|
154
|
+
From the release source ZIP or a complete source checkout:
|
|
89
155
|
|
|
90
156
|
```bash
|
|
91
|
-
npm
|
|
157
|
+
npm ci
|
|
92
158
|
npm run verify
|
|
93
159
|
```
|
|
94
160
|
|
|
95
|
-
Verification
|
|
161
|
+
Verification checks syntax, runs the offline tests, and inspects the npm package contents. The tests cover catalog classification, lifecycle changes, free-only guards, compressed caches, thinking synchronization, the actual Pi session and SDK, and Pi's footer renderer. Network responses in the offline tests are mocked. Passing them is not a claim that every upstream model works.
|
|
96
162
|
|
|
97
|
-
|
|
163
|
+
For an optional live catalog check without inference:
|
|
98
164
|
|
|
99
165
|
```bash
|
|
100
|
-
node scripts/catalog.mjs
|
|
166
|
+
node scripts/catalog.mjs ./catalog-inspection.json
|
|
101
167
|
```
|
|
102
168
|
|
|
103
|
-
|
|
169
|
+
For live inference checks, use `scripts/verify-ui-thinking-live.mjs` in an isolated HOME with `PI_CODING_AGENT_DIR` set to a fresh directory. It runs Pi's actual agent session with the default read, write, edit, and bash tools, checks thinking parameters, and verifies real file changes. It makes about 14 inference requests across the two Beam models. `--toggle-only` runs the separate toggle checks. Model errors stop the run; inspect the result JSON and wire log rather than relying on a confident final answer.
|
|
104
170
|
|
|
105
|
-
|
|
171
|
+
The broader opt-in CLI harnesses, `scripts/live_pi.py` and `scripts/observe_live.py`, are also included. They require Python 3 and the `PI_NODE`, `PI_CLI`, `PI_TEST_DISPATCHER`, and `FREE_TEST_DIR` environment variables. Their test-only wire recorder is never loaded by the extension.
|
|
106
172
|
|
|
107
|
-
|
|
173
|
+
Do not commit or publish credentials, `.npmrc`, caches, or live test outputs. Review the source and the upstream services' terms before use; the code's MIT license does not grant service access.
|
|
174
|
+
|
|
175
|
+
## Catalog sources
|
|
108
176
|
|
|
109
177
|
- https://api.kilo.ai/api/gateway/models
|
|
110
178
|
- https://opencode.ai/zen/v1/models
|
|
111
179
|
- https://models.opencode.ai/api.json
|
|
112
180
|
- https://models.dev/api.json
|
|
113
181
|
- https://pi.dev/api/models/providers/opencode?types=chat,image,classifier
|
|
114
|
-
|
|
115
|
-
Independent MIT-licensed implementation. No upstream plugin source is vendored. Fixture catalogs are factual snapshots used for regression testing, not a model-weight license or service-access grant.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-free-catalog",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Live, auditable anonymous Kilo and OpenCode free model catalogs for Pi. No name-based free filtering.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,8 +37,6 @@
|
|
|
37
37
|
"scripts/*.js",
|
|
38
38
|
"scripts/*.py",
|
|
39
39
|
"README.md",
|
|
40
|
-
"README-fa.md",
|
|
41
|
-
"PUBLISHING-fa.md",
|
|
42
40
|
"CHANGELOG.md",
|
|
43
41
|
"LICENSE"
|
|
44
42
|
],
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Opt-in live proof: fresh catalogs, actual Pi AgentSession, no mocks or personal auth.
|
|
2
|
+
* Run in a clean HOME with PI_CODING_AGENT_DIR isolated, after npm install.
|
|
3
|
+
* node scripts/verify-ui-thinking-live.mjs /absolute/evidence-directory
|
|
4
|
+
* Makes up to ~14 short free-model requests. This is not run by npm test.
|
|
5
|
+
*/
|
|
6
|
+
import {mkdir,writeFile,readFile,unlink} from 'node:fs/promises';
|
|
7
|
+
import {resolve,join} from 'node:path';import {randomBytes,createHash} from 'node:crypto';import dc from 'node:diagnostics_channel';import fs from 'node:fs';
|
|
8
|
+
import {createAgentSession,DefaultResourceLoader,SessionManager,SettingsManager} from '@earendil-works/pi-coding-agent';
|
|
9
|
+
import extension from '../src/index.js';
|
|
10
|
+
const root=resolve(process.argv[2]||'live-proof');await mkdir(root,{recursive:true});
|
|
11
|
+
const wireFile=join(root,'wire.jsonl'),requests=new WeakMap();let requestCount=0;
|
|
12
|
+
const log=x=>fs.appendFileSync(wireFile,JSON.stringify({at:new Date().toISOString(),...x})+'\n');
|
|
13
|
+
dc.subscribe('undici:request:create',({request})=>{if(request.method==='POST'&&/api\.kilo\.ai|opencode\.ai/.test(String(request.origin)))requests.set(request,{id:++requestCount,parts:[]});});
|
|
14
|
+
dc.subscribe('undici:request:bodyChunkSent',({request,chunk})=>{const x=requests.get(request);if(x)x.parts.push(Buffer.from(chunk));});
|
|
15
|
+
dc.subscribe('undici:request:bodySent',({request})=>{const x=requests.get(request);if(x)log({kind:'request',id:x.id,url:String(request.origin)+request.path,headers:request.headers,body:JSON.parse(Buffer.concat(x.parts).toString())});});
|
|
16
|
+
dc.subscribe('undici:request:headers',({request,response})=>{const x=requests.get(request);if(x)log({kind:'response',id:x.id,http:response.statusCode});});
|
|
17
|
+
const statuses=new Map(),notes=[],errors=[],result={at:new Date().toISOString(),cases:[],errors};let session;
|
|
18
|
+
const save=()=>writeFile(join(root,'results.json'),JSON.stringify(result,null,2));
|
|
19
|
+
async function prompt(text,timeout=120000){let timer;try{await Promise.race([session.prompt(text),new Promise((_,reject)=>{timer=setTimeout(async()=>{await session.abort();reject(new Error('Live prompt deadline'));},timeout);})]);}finally{clearTimeout(timer);}}
|
|
20
|
+
async function chat(label,provider,id,setup){await session.setModel(session.modelRuntime.getModel(provider,id));await setup();const before=requestCount,start=performance.now();await prompt('What is 17 multiplied by 19? Answer only the number.');const last=session.messages.filter(m=>m.role==='assistant').at(-1);const text=(last?.content||[]).filter(x=>x.type==='text').map(x=>x.text).join('');const c={label,provider,id,piThinking:session.thinkingLevel,seconds:(performance.now()-start)/1000,answer:text,stopReason:last?.stopReason,errorMessage:last?.errorMessage,pass:/\b323\b/.test(text)&&last?.stopReason!=='error',apiRequests:requestCount-before};result.cases.push(c);await save();console.log(label,c.pass,c.seconds.toFixed(2),session.thinkingLevel);if(last?.stopReason==='error'||last?.stopReason==='aborted')throw new Error('Live model error; stopped without retries/fallback: '+(last.errorMessage||last.stopReason));}
|
|
21
|
+
async function tools(provider,id){
|
|
22
|
+
await session.setModel(session.modelRuntime.getModel(provider,id));session.setThinkingLevel('low');
|
|
23
|
+
const dir=join(root,provider+'-tools');await mkdir(dir,{recursive:true});const token=randomBytes(12).toString('hex'),fixture='TOKEN='+token+'\n',expected='stage=done\nTOKEN='+token+'\n';
|
|
24
|
+
const verifier=`import pathlib,hashlib,json\np=pathlib.Path(__file__).parent/'result.txt'\nb=p.read_bytes()\nassert b.decode()==${JSON.stringify(expected)}\nprint(json.dumps({'verified':True,'sha256':hashlib.sha256(b).hexdigest()}))\n`;
|
|
25
|
+
await writeFile(join(dir,'fixture.txt'),fixture);await writeFile(join(dir,'verify.py'),verifier);await unlink(join(dir,'result.txt')).catch(()=>{});
|
|
26
|
+
const events=[],start=performance.now(),before=requestCount;const unsub=session.subscribe(e=>{if(e.type.startsWith('tool_execution_'))events.push(e);});
|
|
27
|
+
try{await prompt(`Use the actual read, write, edit, bash tools, in that order. Work only inside ${dir}. Do not access network, credentials or environment variables. Read ${dir}/fixture.txt with read. Write ${dir}/result.txt with write, exactly two lines: stage=raw and TOKEN=<the token from fixture.txt>, ending with a newline. Use edit to replace stage=raw with stage=done. Use bash to run python3 ${dir}/verify.py. Do not alter fixture.txt or verify.py. Do not substitute bash for read/write/edit. After verification answer exactly DONE <TOKEN> <sha256 printed by verifier>.`,180000);}finally{unsub();}
|
|
28
|
+
const data=await readFile(join(dir,'result.txt'),'utf8').catch(()=>''),sha=createHash('sha256').update(data).digest('hex');
|
|
29
|
+
const ends=events.filter(e=>e.type==='tool_execution_end'),starts=events.filter(e=>e.type==='tool_execution_start');
|
|
30
|
+
const checks=Object.fromEntries(['read','write','edit','bash'].map(name=>[name,starts.some(s=>s.toolName===name&&ends.some(e=>e.toolCallId===s.toolCallId&&!e.isError)&&(name==='bash'?s.args.command.includes('verify.py'):resolve(s.args.path)===join(dir,name==='read'?'fixture.txt':'result.txt')))]));
|
|
31
|
+
const last=session.messages.filter(m=>m.role==='assistant').at(-1),answer=(last?.content||[]).filter(x=>x.type==='text').map(x=>x.text).join('');
|
|
32
|
+
const c={label:provider+'-four-tools',provider,id,piThinking:session.thinkingLevel,seconds:(performance.now()-start)/1000,checks,sha,token,answer,apiRequests:requestCount-before,events,pass:Object.values(checks).every(Boolean)&&data===expected&&answer.includes(token)&&answer.includes(sha)&&await readFile(join(dir,'fixture.txt'),'utf8')===fixture&&await readFile(join(dir,'verify.py'),'utf8')===verifier&&ends.some(e=>JSON.stringify(e.result).includes(sha))};result.cases.push(c);await save();console.log(c.label,c.pass,c.seconds.toFixed(2));
|
|
33
|
+
}
|
|
34
|
+
try{
|
|
35
|
+
const settingsManager=SettingsManager.inMemory({retry:{enabled:false,maxRetries:0},compaction:{enabled:false}}),agentDir=process.env.PI_CODING_AGENT_DIR;if(!agentDir)throw new Error('Set isolated PI_CODING_AGENT_DIR');
|
|
36
|
+
const loader=new DefaultResourceLoader({cwd:root,agentDir,settingsManager,noExtensions:true,noSkills:true,noPromptTemplates:true,noThemes:true,extensionFactories:[extension]});await loader.reload();
|
|
37
|
+
({session}=await createAgentSession({cwd:root,agentDir,resourceLoader:loader,settingsManager,sessionManager:SessionManager.inMemory(root)}));
|
|
38
|
+
await session.bindExtensions({mode:'print',uiContext:{setStatus:(k,v)=>v===undefined?statuses.delete(k):statuses.set(k,v),notify:(...x)=>notes.push(x)},onError:e=>errors.push(e)});
|
|
39
|
+
result.defaultTools=session.getActiveToolNames();if(!['read','write','edit','bash'].every(x=>result.defaultTools.includes(x)))throw new Error('Default tools missing');
|
|
40
|
+
result.models=session.modelRuntime.getAllModels().filter(m=>m.provider==='free-kilo'||m.provider==='free-zen').map(m=>({id:m.id,provider:m.provider,thinkingLevelMap:m.thinkingLevelMap}));await save();
|
|
41
|
+
if(!process.argv.includes('--toggle-only'))for(const [provider,id] of [['free-kilo','reflection/beam-free'],['free-zen','beam-free']]){
|
|
42
|
+
await chat(provider+'-slash-low',provider,id,()=>session.prompt('/free-thinking low'));
|
|
43
|
+
await session.prompt('/free-thinking high');if(session.thinkingLevel!=='high')throw new Error('Slash did not change Pi');
|
|
44
|
+
await chat(provider+'-pi-low-after-high',provider,id,async()=>session.setThinkingLevel('low'));
|
|
45
|
+
await tools(provider,id);
|
|
46
|
+
}
|
|
47
|
+
if(process.argv.includes('--toggle-only')) {
|
|
48
|
+
const provider='free-kilo',id='poolside/laguna-s-2.1:free';
|
|
49
|
+
await chat('toggle-native-thinking',provider,id,()=>session.prompt('/free-thinking thinking'));
|
|
50
|
+
await chat('toggle-native-instant',provider,id,()=>session.prompt('/free-thinking instant'));
|
|
51
|
+
await chat('toggle-Pi-off',provider,id,async()=>{session.setThinkingLevel('medium');session.setThinkingLevel('off');});
|
|
52
|
+
}
|
|
53
|
+
result.pass=result.cases.length===(process.argv.includes('--toggle-only')?3:6)&&result.cases.every(x=>x.pass)&&errors.length===0;result.notes=notes;result.done=true;
|
|
54
|
+
}catch(error){result.fatal=String(error.stack||error);result.pass=false;console.error(result.fatal);}finally{if(session){await session.extensionRunner.emit({type:'session_shutdown'});session.dispose();}await save();}
|
|
55
|
+
if(!result.pass)process.exitCode=1;
|
package/src/catalog.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile, mkdir, writeFile, rename } from 'node:fs/promises';
|
|
2
|
+
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
2
3
|
import { dirname } from 'node:path';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
4
5
|
|
|
@@ -145,11 +146,14 @@ export function summary(state) {
|
|
|
145
146
|
}
|
|
146
147
|
return result;
|
|
147
148
|
}
|
|
148
|
-
export async function atomicJSON(file, data) {
|
|
149
|
+
export async function atomicJSON(file, data, {gzip=false}={}) {
|
|
149
150
|
await mkdir(dirname(file),{recursive:true,mode:0o700});
|
|
150
151
|
const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
151
|
-
|
|
152
|
+
// Compact when gzipping: indentation is pure waste for machine-read state.
|
|
153
|
+
const body = gzip ? gzipSync(JSON.stringify(data)) : JSON.stringify(data,null,2);
|
|
154
|
+
await writeFile(temp,body,{mode:0o600}); await rename(temp,file);
|
|
152
155
|
}
|
|
156
|
+
export const isGzip = (buf) => buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b;
|
|
153
157
|
export class Catalog {
|
|
154
158
|
constructor({file,fetch:fetcher=globalThis.fetch,timeout=12000,now=()=>Date.now(),maxAge=300000}={}) {
|
|
155
159
|
this.file=file;this.fetcher=fetcher;this.timeout=timeout;this.now=now;this.maxAge=maxAge;this.state={schemaVersion:1,sources:{},entries:{},history:[]};this.lastAttempt=0;
|
|
@@ -157,7 +161,10 @@ export class Catalog {
|
|
|
157
161
|
async load() {
|
|
158
162
|
if (!this.file) return;
|
|
159
163
|
try {
|
|
160
|
-
|
|
164
|
+
// Accepts gzipped state (current) and plain JSON (pre-0.1.2 catalogs).
|
|
165
|
+
const buf=await readFile(this.file);
|
|
166
|
+
const text=isGzip(buf)?gunzipSync(buf).toString('utf8'):buf.toString('utf8');
|
|
167
|
+
const state=JSON.parse(text);
|
|
161
168
|
if (state.schemaVersion===1 && state.entries && state.sources) this.state=state;
|
|
162
169
|
} catch(error) { if(error.code!=='ENOENT') this.cacheWarning=String(error); }
|
|
163
170
|
}
|
|
@@ -184,7 +191,7 @@ export class Catalog {
|
|
|
184
191
|
// Never let Pi's startup refresh cancellation invalidate a successful startup snapshot.
|
|
185
192
|
if(signal?.aborted){this.lastAttempt=previousAttempt;throw signal.reason || new Error('Catalog refresh cancelled');}
|
|
186
193
|
this.state=reconcile(sources,old,new Date(this.now()).toISOString());
|
|
187
|
-
if(this.file) try{await atomicJSON(this.file,this.state);}catch(e){this.cacheWarning=String(e);}
|
|
194
|
+
if(this.file) try{await atomicJSON(this.file,this.state,{gzip:true});}catch(e){this.cacheWarning=String(e);}
|
|
188
195
|
return this.state;
|
|
189
196
|
}
|
|
190
197
|
async ensureFresh() { if(this.now()-this.lastAttempt>=this.maxAge) await this.refresh(); }
|
package/src/footer.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { FooterComponent } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { freeModelKey } from './thinking-sync.js';
|
|
3
|
+
|
|
4
|
+
/** Reuse Pi 1.3's exported renderer, with a local read-only view (never mutate core/provider IDs). */
|
|
5
|
+
export function freeFooter(pi,ctx,tui,footerData) {
|
|
6
|
+
const session={
|
|
7
|
+
get state(){return {model:ctx.model,thinkingLevel:pi.getThinkingLevel()};},
|
|
8
|
+
get model(){return ctx.model;},
|
|
9
|
+
get sessionManager(){return ctx.sessionManager;},
|
|
10
|
+
getContextUsage:()=>ctx.getContextUsage(),
|
|
11
|
+
// This footer is installed only for our two anonymous, non-subscription providers.
|
|
12
|
+
modelRuntime:{isUsingSubscription:()=>false},
|
|
13
|
+
};
|
|
14
|
+
const data={
|
|
15
|
+
getGitBranch:()=>footerData.getGitBranch(),
|
|
16
|
+
getExtensionStatuses:()=>footerData.getExtensionStatuses(),
|
|
17
|
+
getAvailableProviderCount:()=>1,
|
|
18
|
+
onBranchChange:fn=>footerData.onBranchChange(fn),
|
|
19
|
+
};
|
|
20
|
+
const component=new FooterComponent(session,data);
|
|
21
|
+
const unsubscribe=footerData.onBranchChange(()=>tui.requestRender());
|
|
22
|
+
return {
|
|
23
|
+
invalidate:()=>component.invalidate(),
|
|
24
|
+
dispose:()=>{unsubscribe();component.dispose();},
|
|
25
|
+
render(width){
|
|
26
|
+
component.setAutoCompactEnabled(pi.getSettings().compaction?.enabled!==false);
|
|
27
|
+
return component.render(width);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Pi exposes one footer slot. Other footer extensions can opt out with /free-footer default. */
|
|
32
|
+
export function footerController({pi,getMode}) {
|
|
33
|
+
let installed=false;
|
|
34
|
+
return ctx=>{
|
|
35
|
+
if(ctx.mode!=='tui')return;
|
|
36
|
+
if(getMode()==='compact' && freeModelKey(ctx.model)) {
|
|
37
|
+
if(!installed) {
|
|
38
|
+
ctx.ui.setFooter((tui,_theme,data)=>freeFooter(pi,ctx,tui,data));installed=true;
|
|
39
|
+
}
|
|
40
|
+
} else if(installed) {ctx.ui.setFooter(undefined);installed=false;}
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/index.js
CHANGED
|
@@ -4,6 +4,8 @@ import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
|
4
4
|
import { completions, responses, anthropic, classify } from './sdk.js';
|
|
5
5
|
import { Catalog, PROVIDERS, recordKey, summary, atomicJSON } from './catalog.js';
|
|
6
6
|
import { registrations, report, thinkingProfile, selectThinking } from './models.js';
|
|
7
|
+
import { thinkingSync } from './thinking-sync.js';
|
|
8
|
+
import { footerController } from './footer.js';
|
|
7
9
|
import { scopedFetch, requestPayload, sessionId } from './transport.js';
|
|
8
10
|
|
|
9
11
|
const STREAMS={'openai-completions':completions,'openai-responses':responses,'anthropic-messages':anthropic};
|
|
@@ -13,6 +15,10 @@ export default async function freeCatalog(pi) {
|
|
|
13
15
|
const catalog=new Catalog({file:join(directory,'catalog.json')});
|
|
14
16
|
const selectionFile=join(directory,'thinking.json');let selections={};
|
|
15
17
|
try { const d=JSON.parse(await readFile(selectionFile,'utf8'));if(d&&typeof d==='object'&&!Array.isArray(d))selections=d; } catch {}
|
|
18
|
+
const uiFile=join(directory,'ui.json');let uiPreferences={footer:'compact'};
|
|
19
|
+
try {const d=JSON.parse(await readFile(uiFile,'utf8'));if(['compact','default'].includes(d.footer))uiPreferences=d;}catch {}
|
|
20
|
+
let saveQueue=Promise.resolve();
|
|
21
|
+
const saveSelections=()=>{const snapshot=structuredClone(selections);saveQueue=saveQueue.catch(()=>{}).then(()=>atomicJSON(selectionFile,snapshot));return saveQueue;};
|
|
16
22
|
let session=sessionId();
|
|
17
23
|
const getSelection=(service,id)=>selections[recordKey(service,id)] || 'auto';
|
|
18
24
|
const health={};
|
|
@@ -30,7 +36,7 @@ export default async function freeCatalog(pi) {
|
|
|
30
36
|
const fetch=scopedFetch({catalog,service,model,session,onResponse:r=>noteResponse(service,model.id,r)});
|
|
31
37
|
return stream(model,context,{
|
|
32
38
|
...options,apiKey:'public',fetch,
|
|
33
|
-
onPayload:requestPayload({catalog,service,id:model.id,getSelection,piLevel:options.reasoning,original:options.onPayload}),
|
|
39
|
+
onPayload:requestPayload({catalog,service,id:model.id,getSelection,piLevel:options.reasoning??'off',original:options.onPayload}),
|
|
34
40
|
});
|
|
35
41
|
},
|
|
36
42
|
classifiers:{'typesafe-system-one':{classify:async(model,context,options={})=>{
|
|
@@ -46,21 +52,31 @@ export default async function freeCatalog(pi) {
|
|
|
46
52
|
const s=summary(catalog.state),bad=Object.entries(catalog.state.sources).filter(([,x])=>!x.fresh).map(([k])=>k);
|
|
47
53
|
ctx.ui.setStatus('free-catalog',`Free: Kilo ${s.kilo.free} · Zen ${s.zen.free}${bad.length?' · STALE '+bad.join(','):''}`);
|
|
48
54
|
};
|
|
55
|
+
const sync=thinkingSync({pi,catalog,getSelection,setSelection:(key,mode)=>{selections[key]=mode;},save:saveSelections,output});
|
|
56
|
+
const updateFooter=footerController({pi,getMode:()=>uiPreferences.footer});
|
|
57
|
+
pi.on('session_shutdown',async()=>{await saveQueue;});
|
|
58
|
+
pi.on('thinking_level_select',(event,ctx)=>sync.selected(event,ctx));
|
|
59
|
+
pi.on('model_select',(_event,ctx)=>{sync.restore(ctx);updateFooter(ctx);});
|
|
60
|
+
pi.on('session_before_switch',()=>{sync.unbind();});
|
|
49
61
|
pi.on('session_start',async(_event,ctx)=>{
|
|
50
|
-
status(ctx);
|
|
62
|
+
status(ctx);sync.restore(ctx);updateFooter(ctx);
|
|
51
63
|
const bad=Object.entries(catalog.state.sources).filter(([,s])=>!s.fresh);
|
|
52
64
|
if(bad.length)output('Free catalog refresh incomplete: '+bad.map(([k,s])=>`${k}: ${s.error}`).join('; ')+'. Cached entries remain visible; unverified requests are blocked.',ctx);
|
|
53
65
|
if(catalog.cacheWarning)output('Free catalog cache warning: '+catalog.cacheWarning,ctx);
|
|
54
66
|
});
|
|
55
|
-
pi.on('session_switch',()=>{session=sessionId();});
|
|
67
|
+
pi.on('session_switch',(_event,ctx)=>{session=sessionId();sync.restore(ctx);updateFooter(ctx);});
|
|
56
68
|
pi.on('before_agent_start',async(_event,ctx)=>{
|
|
57
|
-
if(Date.now()-catalog.lastAttempt>=catalog.maxAge){await catalog.refresh();install();status(ctx);}
|
|
69
|
+
if(Date.now()-catalog.lastAttempt>=catalog.maxAge){await catalog.refresh();install();status(ctx);sync.restore(ctx);}
|
|
58
70
|
});
|
|
59
71
|
pi.registerCommand('free-catalog',{description:'Show ALL free, uncertain, deprecated, absent and retired models; no hidden name filters',handler:async(_args,ctx)=>{output(report(catalog.state)+'\n\nLast HTTP results (not retirement proof):\n'+JSON.stringify(health,null,2),ctx);}});
|
|
60
72
|
pi.registerCommand('free-refresh',{description:'Fetch all catalogs, prices and capabilities again; reconcile lifecycle and replace model lists',handler:async(_args,ctx)=>{
|
|
61
|
-
await catalog.refresh();install();status(ctx);output(JSON.stringify(summary(catalog.state),null,2)+'\n'+catalog.state.changes.map(c=>`${c.service}/${c.id}: ${JSON.stringify(c.from)} → ${JSON.stringify(c.to)}`).join('\n'),ctx);
|
|
73
|
+
await catalog.refresh();install();status(ctx);sync.restore(ctx);output(JSON.stringify(summary(catalog.state),null,2)+'\n'+catalog.state.changes.map(c=>`${c.service}/${c.id}: ${JSON.stringify(c.from)} → ${JSON.stringify(c.to)}`).join('\n'),ctx);
|
|
62
74
|
}});
|
|
63
|
-
pi.registerCommand('free-
|
|
75
|
+
pi.registerCommand('free-footer',{description:'compact hides only the free provider label in the footer; default restores Pi footer',handler:async(args,ctx)=>{
|
|
76
|
+
const mode=args.trim();if(!['compact','default'].includes(mode)){output(`Footer: ${uiPreferences.footer}. Use /free-footer compact or /free-footer default.`,ctx);return;}
|
|
77
|
+
uiPreferences.footer=mode;await atomicJSON(uiFile,uiPreferences);updateFooter(ctx);output(`Free model footer: ${mode}. Provider IDs and model picker are unchanged.`,ctx);
|
|
78
|
+
}});
|
|
79
|
+
pi.registerCommand('free-thinking',{description:'Inspect native thinking or select a mode; exact Pi equivalents sync both ways',handler:async(args,ctx)=>{
|
|
64
80
|
const service=Object.entries(PROVIDERS).find(([,p])=>p===ctx.model?.provider)?.[0];
|
|
65
81
|
if(!service){output('Select a free-kilo or free-zen chat model first.',ctx);return;}
|
|
66
82
|
await catalog.ensureFresh();const entry=catalog.state.entries[recordKey(service,ctx.model.id)];
|
|
@@ -68,8 +84,8 @@ export default async function freeCatalog(pi) {
|
|
|
68
84
|
if(!mode){output(JSON.stringify({model:entry.id,selected:getSelection(service,entry.id),piThinking:pi.getThinkingLevel(),source:profile.source,nativeChoices:profile.choices,note:profile.note},null,2),ctx);return;}
|
|
69
85
|
try {
|
|
70
86
|
if(!['auto','default'].includes(mode))selectThinking(entry,catalog.state,mode);
|
|
71
|
-
selections[recordKey(service,entry.id)]=mode;await
|
|
72
|
-
output(`Thinking for ${service}/${entry.id}: ${mode}.
|
|
87
|
+
selections[recordKey(service,entry.id)]=mode;sync.applyNative(mode,ctx);await saveSelections();
|
|
88
|
+
output(`Thinking for ${service}/${entry.id}: ${mode}; Pi: ${pi.getThinkingLevel()}. Exact equivalents are synchronized; a subsequent Pi thinking change returns this model to auto. Default/unmapped modes are explicitly labelled in the status bar.`,ctx);
|
|
73
89
|
}catch(error){output(String(error),ctx);}
|
|
74
90
|
}});
|
|
75
91
|
pi.registerCommand('free-export',{description:'Export complete live evidence, every classified ID and lifecycle history to a JSON file',handler:async(args,ctx)=>{
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { PROVIDERS, recordKey } from './catalog.js';
|
|
3
|
+
import { LEVELS, selectThinking } from './models.js';
|
|
4
|
+
|
|
5
|
+
export function freeModelKey(model) {
|
|
6
|
+
const service=Object.entries(PROVIDERS).find(([,provider])=>provider===model?.provider)?.[0];
|
|
7
|
+
return service ? {service,id:model.id,key:recordKey(service,model.id)} : null;
|
|
8
|
+
}
|
|
9
|
+
/** Only claim a Pi equivalent when it produces exactly the same native parameters. */
|
|
10
|
+
export function piLevelForNative(entry,state,mode) {
|
|
11
|
+
if(['auto','default'].includes(mode))return null;
|
|
12
|
+
const target=selectThinking(entry,state,mode);
|
|
13
|
+
const levels=LEVELS.includes(mode)?[mode,...LEVELS.filter(x=>x!==mode)]:LEVELS;
|
|
14
|
+
for(const level of levels) {
|
|
15
|
+
try { if(isDeepStrictEqual(selectThinking(entry,state,'auto',level),target))return level; } catch {}
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
/** No core patches: Pi's public setter and thinking_level_select event are the bridge. */
|
|
20
|
+
export function thinkingSync({pi,catalog,getSelection,setSelection,save,output}) {
|
|
21
|
+
let boundKey=null;
|
|
22
|
+
const pending=[];
|
|
23
|
+
function mirror(level,key) {
|
|
24
|
+
const previous=pi.getThinkingLevel();
|
|
25
|
+
if(previous===level)return;
|
|
26
|
+
const token={key,level,previous};pending.push(token);
|
|
27
|
+
try { pi.setThinkingLevel(level); }
|
|
28
|
+
catch(error) { const i=pending.indexOf(token);if(i>=0)pending.splice(i,1);throw error; }
|
|
29
|
+
// A clamped/no-change setter may not emit an event. Never suppress a later user action.
|
|
30
|
+
if(pi.getThinkingLevel()!==level) { const i=pending.indexOf(token);if(i>=0)pending.splice(i,1); }
|
|
31
|
+
}
|
|
32
|
+
function status(ctx) {
|
|
33
|
+
const model=freeModelKey(ctx.model),mode=model?getSelection(model.service,model.id):'auto';
|
|
34
|
+
let text;
|
|
35
|
+
if(mode==='default')text='Thinking: provider default (Pi level not applied)';
|
|
36
|
+
else if(mode!=='auto') {
|
|
37
|
+
const entry=catalog.state.entries[model.key];
|
|
38
|
+
try { if(!entry || piLevelForNative(entry,catalog.state,mode)===null)text=`Native thinking: ${mode} (no exact Pi level)`; }
|
|
39
|
+
catch {text=`Native thinking: ${mode} unavailable — select a current mode`;}
|
|
40
|
+
} else if(model) {
|
|
41
|
+
const entry=catalog.state.entries[model.key];
|
|
42
|
+
if(entry && !Object.keys(entry.metadata.opencode?.variants||{}).length) {
|
|
43
|
+
// selectThinking preserves the provider default for models without advertised choices.
|
|
44
|
+
try { if(Object.keys(selectThinking(entry,catalog.state,'auto',pi.getThinkingLevel())).length===0)text='Thinking: provider default (no advertised controls)'; } catch {}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
ctx.ui.setStatus('free-thinking',text);
|
|
48
|
+
}
|
|
49
|
+
function restore(ctx) {
|
|
50
|
+
const model=freeModelKey(ctx.model);boundKey=model?.key??null;
|
|
51
|
+
if(model) {
|
|
52
|
+
const entry=catalog.state.entries[model.key],mode=getSelection(model.service,model.id);
|
|
53
|
+
if(entry && !['auto','default'].includes(mode)) {
|
|
54
|
+
try { const level=piLevelForNative(entry,catalog.state,mode);if(level!==null)mirror(level,model.key); }
|
|
55
|
+
catch(error) { output(`Saved thinking is no longer supported: ${error}`,ctx); }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
status(ctx);
|
|
59
|
+
}
|
|
60
|
+
async function selected(event,ctx) {
|
|
61
|
+
const model=freeModelKey(ctx.model);
|
|
62
|
+
const i=pending.findIndex(x=>x.key===model?.key&&x.level===event.level&&x.previous===event.previousLevel);
|
|
63
|
+
if(i>=0){pending.splice(i,1);return;}
|
|
64
|
+
// Pi applies a model's default before emitting model_select. Do not erase the target's preference.
|
|
65
|
+
if(!model || model.key!==boundKey)return;
|
|
66
|
+
setSelection(model.key,'auto');
|
|
67
|
+
status(ctx);
|
|
68
|
+
await save();
|
|
69
|
+
}
|
|
70
|
+
function applyNative(mode,ctx) {
|
|
71
|
+
const model=freeModelKey(ctx.model),entry=model&&catalog.state.entries[model.key];
|
|
72
|
+
if(!entry)return;
|
|
73
|
+
if(!['auto','default'].includes(mode)) {
|
|
74
|
+
const level=piLevelForNative(entry,catalog.state,mode);
|
|
75
|
+
if(level!==null)mirror(level,model.key);
|
|
76
|
+
}
|
|
77
|
+
status(ctx);
|
|
78
|
+
}
|
|
79
|
+
return {restore,selected,applyNative,status,unbind:()=>{boundKey=null;pending.length=0;}};
|
|
80
|
+
}
|
package/test/catalog.test.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import test from 'node:test';
|
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
|
+
import { gunzipSync } from 'node:zlib';
|
|
5
6
|
import { join } from 'node:path';
|
|
6
7
|
import { Catalog, reconcile, recordKey, summary, priceClass, validateSource } from '../src/catalog.js';
|
|
7
8
|
import { registrations, report } from '../src/models.js';
|
|
@@ -93,7 +94,23 @@ test('startup load is followed by new network calls, updates saved cache, concur
|
|
|
93
94
|
const fetch=async(...a)=>{calls++;return fetcher()(...a);};
|
|
94
95
|
const a=new Catalog({file,fetch});await a.refresh();assert.equal(calls,5);
|
|
95
96
|
const b=new Catalog({file,fetch});await b.load();await Promise.all([b.refresh(),b.refresh()]);assert.equal(calls,10);
|
|
96
|
-
assert.equal(JSON.parse(await readFile(file
|
|
97
|
+
assert.equal(JSON.parse(gunzipSync(await readFile(file)).toString('utf8')).schemaVersion,1);assert.equal(summary(b.state).kilo.free,17);
|
|
98
|
+
}finally{await rm(dir,{recursive:true,force:true});}
|
|
99
|
+
});
|
|
100
|
+
test('saved state is gzipped and compact, round-trips, and legacy plain caches still load',async()=>{
|
|
101
|
+
const dir=await mkdtemp(join(tmpdir(),'pi-free-gz-'));try{
|
|
102
|
+
const file=join(dir,'catalog.json');
|
|
103
|
+
const a=new Catalog({file,fetch:fetcher()});await a.refresh();
|
|
104
|
+
const raw=await readFile(file);
|
|
105
|
+
assert.equal(raw[0],0x1f);assert.equal(raw[1],0x8b);
|
|
106
|
+
const parsed=JSON.parse(gunzipSync(raw).toString('utf8'));assert.equal(parsed.schemaVersion,1);
|
|
107
|
+
const pretty=Buffer.byteLength(JSON.stringify(parsed,null,2));
|
|
108
|
+
assert.ok(raw.length*3<pretty,`gzipped ${raw.length} should be far below pretty ${pretty}`);
|
|
109
|
+
const b=new Catalog({file,fetch:fetcher()});await b.load();
|
|
110
|
+
assert.equal(summary(b.state).kilo.free,17);
|
|
111
|
+
assert.equal(Object.keys(b.state.entries).length,Object.keys(parsed.entries).length);
|
|
112
|
+
const legacy=join(dir,'legacy.json');await writeFile(legacy,JSON.stringify(parsed,null,2));
|
|
113
|
+
const c=new Catalog({file:legacy,fetch:fetcher()});await c.load();assert.equal(summary(c.state).kilo.free,17);
|
|
97
114
|
}finally{await rm(dir,{recursive:true,force:true});}
|
|
98
115
|
});
|
|
99
116
|
test('fresh paid price beats stale free source and blocks dispatch',async()=>{
|
|
@@ -119,7 +136,7 @@ test('failed empty snapshot preserves listed free IDs instead of retiring them',
|
|
|
119
136
|
const c=new Catalog({fetch:fetcher()});await c.refresh();const d=structuredClone(fixtures);d.kilo={data:[]};c.fetcher=fetcher(d);await c.refresh();assert.equal(summary(c.state).kilo.free,17);assert.equal(c.state.sources.kilo.fresh,false);assert.equal(c.state.entries[key('kilo','reflection/beam-free')].lifecycle,'active');
|
|
120
137
|
});
|
|
121
138
|
test('bulk future catalog growth without free naming never loses a free LLM',()=>{
|
|
122
|
-
const d=structuredClone(fixtures);const ids=Array.from({length:100},(_,i)
|
|
139
|
+
const d=structuredClone(fixtures);const ids=Array.from({length:100},(_,i)=>`model-🧪-${i}/new.route`);
|
|
123
140
|
for(const id of ids){d.kilo.data.push({id,isFree:true,context_length:4096,max_completion_tokens:512});d.zen.data.push({id});d.official.opencode.models[id]={id,cost:{input:0,output:0},limit:{context:4096,output:512}};}
|
|
124
141
|
const s=reconcile(sources(d));for(const service of ['kilo','zen']){const found=new Set(registrations(s,service).map(m=>m.id));for(const id of ids)assert.ok(found.has(id),`${service}/${id}`);}
|
|
125
142
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {readdir,readFile} from 'node:fs/promises';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
import {gunzipSync} from 'node:zlib';
|
|
6
|
+
import {fileURLToPath} from 'node:url';
|
|
7
|
+
|
|
8
|
+
// Explicit Unicode ranges keep the rule readable without embedding the script it rejects.
|
|
9
|
+
const prohibited=/[\u0600-\u06ff\u0750-\u077f\u08a0-\u08ff\ufb50-\ufdff\ufe70-\ufeff]/u;
|
|
10
|
+
function checkText(text,label){
|
|
11
|
+
assert.ok(!prohibited.test(text),`Non-English release text in ${label}`);
|
|
12
|
+
// Catch escaped JSON/code strings as well as literal characters.
|
|
13
|
+
const unescaped=text.replace(/\\u([0-9a-f]{4})/gi,(_,hex)=>String.fromCharCode(parseInt(hex,16)));
|
|
14
|
+
// The test's regex declaration is policy, not a string containing release text.
|
|
15
|
+
if(!label.endsWith('english-only.test.mjs'))assert.ok(!prohibited.test(unescaped),`Escaped non-English text in ${label}`);
|
|
16
|
+
}
|
|
17
|
+
test('release source, documentation and decompressed catalog fixtures contain no Persian/Arabic-script text',async()=>{
|
|
18
|
+
const root=fileURLToPath(new URL('../',import.meta.url));let checked=0;
|
|
19
|
+
async function walk(dir){
|
|
20
|
+
for(const item of await readdir(dir,{withFileTypes:true})){
|
|
21
|
+
if(['node_modules','.git','artifacts','verification'].includes(item.name))continue;
|
|
22
|
+
const p=join(dir,item.name);
|
|
23
|
+
if(item.isDirectory()){await walk(p);continue;}
|
|
24
|
+
if(!item.isFile())continue;
|
|
25
|
+
let data=await readFile(p);if(item.name.endsWith('.gz'))data=gunzipSync(data);
|
|
26
|
+
checkText(data.toString('utf8'),p);checked++;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
await walk(root);assert.ok(checked>=30);
|
|
30
|
+
});
|
|
31
|
+
test('English-only guard rejects literal and escaped script characters without dropping Unicode coverage',()=>{
|
|
32
|
+
assert.throws(()=>checkText(String.fromCharCode(0x0633),'sample.txt'));
|
|
33
|
+
assert.throws(()=>checkText('\\u0633','sample.json'));
|
|
34
|
+
assert.doesNotThrow(()=>checkText('Hello 🧠 — model-🧪-1','sample.txt'));
|
|
35
|
+
});
|
package/test/extension.test.mjs
CHANGED
|
@@ -6,7 +6,8 @@ async function setup(fn){
|
|
|
6
6
|
const dir=await mkdtemp(join(tmpdir(),'pi-free-extension-')),prior=process.env.PI_CODING_AGENT_DIR,original=globalThis.fetch;
|
|
7
7
|
const configs=new Map(),commands=new Map(),events=new Map(),output=[];
|
|
8
8
|
process.env.PI_CODING_AGENT_DIR=dir;let delegate=fetcher();globalThis.fetch=(...args)=>delegate(...args);const setFetch=fn=>{delegate=fn;};
|
|
9
|
-
|
|
9
|
+
let thinking='medium';
|
|
10
|
+
const pi={registerProvider:(id,c)=>configs.set(id,c),registerCommand:(id,c)=>commands.set(id,c),on:(id,fn)=>events.set(id,fn),sendMessage:m=>output.push(m.content),getThinkingLevel:()=>thinking,setThinkingLevel:level=>{const previousLevel=thinking;thinking=level;if(level!==previousLevel)void events.get('thinking_level_select')?.({level,previousLevel},ctx);}};
|
|
10
11
|
const ctx={hasUI:false,model:{provider:'free-kilo',id:'poolside/laguna-s-2.1:free'},ui:{setStatus:()=>{}}};
|
|
11
12
|
try{await extension(pi);await fn({dir,configs,commands,events,ctx,output,setFetch});}
|
|
12
13
|
finally{globalThis.fetch=original;if(prior===undefined)delete process.env.PI_CODING_AGENT_DIR;else process.env.PI_CODING_AGENT_DIR=prior;await rm(dir,{recursive:true,force:true});}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import test from 'node:test';import assert from 'node:assert/strict';
|
|
2
|
+
import {FooterComponent,initTheme} from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import {freeFooter,footerController} from '../src/footer.js';
|
|
4
|
+
initTheme('dark');
|
|
5
|
+
function setup(){
|
|
6
|
+
let renders=0,disposed=0,compact=false;
|
|
7
|
+
const model={id:'beam-free',provider:'free-zen',reasoning:true,contextWindow:1048576};
|
|
8
|
+
const entries=[{type:'message',message:{role:'assistant',usage:{input:1234,output:543,cacheRead:1000,cacheWrite:200,cost:{total:0.12}}}}];
|
|
9
|
+
const manager={getCwd:()=>'/home/dev/demo',getSessionName:()=> 'review',getEntries:()=>entries,getEntryCount:()=>entries.length,getSessionId:()=> 'id',getLeafId:()=> 'leaf'};
|
|
10
|
+
const ctx={mode:'tui',model,sessionManager:manager,getContextUsage:()=>({contextWindow:1048576,percent:42,tokens:440401}),ui:{}};
|
|
11
|
+
const data={getGitBranch:()=> 'main',getExtensionStatuses:()=>new Map([['free-catalog','Free: Kilo 17 · Zen 9'],['warning','STALE source warning']]),getAvailableProviderCount:()=>3,onBranchChange:fn=>{return()=>disposed++;}};
|
|
12
|
+
const pi={getThinkingLevel:()=> 'low',getSettings:()=>({compaction:{enabled:compact}})};
|
|
13
|
+
const original=new FooterComponent({state:{model,thinkingLevel:'low'},sessionManager:manager,getContextUsage:ctx.getContextUsage,modelRuntime:{isUsingSubscription:()=>false}},data);original.setAutoCompactEnabled(false);
|
|
14
|
+
const footer=freeFooter(pi,ctx,{requestRender:()=>renders++},data);
|
|
15
|
+
return {ctx,pi,data,footer,original,get disposed(){return disposed;},set compact(x){compact=x;}};
|
|
16
|
+
}
|
|
17
|
+
test('actual Pi footer renderer preserves every statistic/status but omits free provider prefix',()=>{
|
|
18
|
+
const x=setup(),clean=s=>s.replace(/\x1b\[[0-9;]*m/g,'');const before=x.original.render(200).map(clean),after=x.footer.render(200).map(clean);
|
|
19
|
+
assert.match(before[1],/\(free-zen\)/);assert.doesNotMatch(after[1],/free-zen/);assert.equal(after[0],before[0]);assert.equal(after[2],before[2]);
|
|
20
|
+
for(const text of ['↑1.2k','↓543','R1.0k','W200','CH','42.0%','beam-free','low','$0.120'])assert.ok(after[1].includes(text),text);
|
|
21
|
+
assert.equal(after[1].replaceAll(' ',''),before[1].replace('(free-zen)','').replaceAll(' ',''));
|
|
22
|
+
});
|
|
23
|
+
test('both free labels hidden, identity untouched, narrow width and compaction setting honored',()=>{
|
|
24
|
+
const x=setup();x.ctx.model.provider='free-kilo';assert.doesNotMatch(x.footer.render(200).join('\n'),/free-kilo/);assert.equal(x.ctx.model.provider,'free-kilo');
|
|
25
|
+
for(const width of [1,8,24,60,120])assert.ok(x.footer.render(width).every(line=>typeof line==='string'));
|
|
26
|
+
x.compact=true;assert.match(x.footer.render(200).join('\n'),/auto/);x.footer.dispose();assert.equal(x.disposed,1);
|
|
27
|
+
});
|
|
28
|
+
test('controller installs only in TUI on free models, restores default away, and supports opt-out',()=>{
|
|
29
|
+
const calls=[],x=setup();let mode='compact';x.ctx.ui.setFooter=f=>calls.push(f);const update=footerController({pi:x.pi,getMode:()=>mode});
|
|
30
|
+
x.ctx.mode='rpc';update(x.ctx);assert.equal(calls.length,0);x.ctx.mode='tui';update(x.ctx);assert.equal(typeof calls[0],'function');update(x.ctx);assert.equal(calls.length,1);
|
|
31
|
+
x.ctx.model={provider:'openai',id:'test'};update(x.ctx);assert.equal(calls[1],undefined);update(x.ctx);assert.equal(calls.length,2);
|
|
32
|
+
x.ctx.model={provider:'free-zen',id:'beam-free'};update(x.ctx);mode='default';update(x.ctx);assert.equal(calls.at(-1),undefined);
|
|
33
|
+
});
|
package/test/package.test.mjs
CHANGED
|
@@ -3,5 +3,13 @@ test('actual npm pack excludes Python bytecode, caches, credentials and node_mod
|
|
|
3
3
|
const result=spawnSync('npm',['pack','--dry-run','--ignore-scripts','--json'],{cwd:fileURLToPath(new URL('../',import.meta.url)),encoding:'utf8'});assert.equal(result.status,0,result.stderr);
|
|
4
4
|
const parsed=JSON.parse(result.stdout);const packed=Array.isArray(parsed)?parsed[0]:Object.values(parsed)[0];assert.ok(packed&&Array.isArray(packed.files),'npm pack --json shape not recognised');const files=packed.files.map(f=>f.path);assert.ok(files.length>20);
|
|
5
5
|
for(const path of files)assert.ok(!/(?:^|\/)(?:node_modules|__pycache__|\.npmrc|\.env)(?:\/|$)|\.pyc$/.test(path),path);
|
|
6
|
-
for(const path of ['src/index.js','src/sdk.js','src/catalog.js','src/models.js','src/transport.js','
|
|
6
|
+
for(const path of ['src/index.js','src/sdk.js','src/catalog.js','src/models.js','src/transport.js','test/fixtures/kilo.json.gz'])assert.ok(files.includes(path),path);
|
|
7
|
+
});
|
|
8
|
+
test('Persian README is removed from source, English README and the published tarball',async()=>{
|
|
9
|
+
const {readFile,access}=await import('node:fs/promises');
|
|
10
|
+
await assert.rejects(access(new URL('../README-fa.md',import.meta.url)));
|
|
11
|
+
const pkg=JSON.parse(await readFile(new URL('../package.json',import.meta.url),'utf8'));assert.ok(!pkg.files.includes('README-fa.md'));
|
|
12
|
+
const readme=await readFile(new URL('../README.md',import.meta.url),'utf8');assert.ok(!readme.includes('README-fa.md'));
|
|
13
|
+
const r=spawnSync('npm',['pack','--dry-run','--ignore-scripts','--json'],{cwd:fileURLToPath(new URL('../',import.meta.url)),encoding:'utf8'});assert.equal(r.status,0);
|
|
14
|
+
const d=JSON.parse(r.stdout),pack=Array.isArray(d)?d[0]:Object.values(d)[0];assert.ok(!pack.files.some(f=>f.path==='README-fa.md'));assert.ok(pack.files.some(f=>f.path==='src/footer.js'));assert.ok(pack.files.some(f=>f.path==='src/thinking-sync.js'));
|
|
7
15
|
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import test from 'node:test';import assert from 'node:assert/strict';
|
|
2
|
+
import {mkdtemp,readFile,rm} from 'node:fs/promises';import {tmpdir} from 'node:os';import {join} from 'node:path';
|
|
3
|
+
import {createAgentSession,DefaultResourceLoader,SessionManager,SettingsManager,initTheme} from '@earendil-works/pi-coding-agent';
|
|
4
|
+
import extension from '../src/index.js';import {fetcher} from './helpers.mjs';
|
|
5
|
+
|
|
6
|
+
// Actual Pi AgentSession, event runner, thinking clamp and SDK serialization.
|
|
7
|
+
// Catalog and inference HTTP are mocked deliberately: separate live runs verify the network.
|
|
8
|
+
test('real Pi session: slash <-> native selector, switches, footer and off wire payload',async()=>{
|
|
9
|
+
const dir=await mkdtemp(join(tmpdir(),'free-pi-session-'));const beforeFetch=globalThis.fetch,beforeDir=process.env.PI_CODING_AGENT_DIR;
|
|
10
|
+
process.env.PI_CODING_AGENT_DIR=dir;const catalogFetch=fetcher(),wires=[],errors=[],statuses=new Map();let session,footerFactory;
|
|
11
|
+
globalThis.fetch=async(input,init)=>{
|
|
12
|
+
if(String(input).endsWith('/chat/completions')){
|
|
13
|
+
wires.push(JSON.parse(init.body));return new Response('data: {"id":"x","choices":[{"index":0,"delta":{"content":"323"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',{headers:{'content-type':'text/event-stream'}});
|
|
14
|
+
}
|
|
15
|
+
return catalogFetch(input,init);
|
|
16
|
+
};
|
|
17
|
+
try{
|
|
18
|
+
initTheme('dark');const settingsManager=SettingsManager.inMemory({retry:{enabled:false},compaction:{enabled:false}});
|
|
19
|
+
const loader=new DefaultResourceLoader({cwd:dir,agentDir:dir,settingsManager,noExtensions:true,noSkills:true,noPromptTemplates:true,noThemes:true,extensionFactories:[extension]});await loader.reload();
|
|
20
|
+
({session}=await createAgentSession({cwd:dir,agentDir:dir,resourceLoader:loader,settingsManager,sessionManager:SessionManager.inMemory(dir)}));
|
|
21
|
+
await session.bindExtensions({mode:'tui',uiContext:{setStatus:(k,v)=>v===undefined?statuses.delete(k):statuses.set(k,v),setFooter:factory=>{footerFactory=factory;},notify:()=>{}},onError:e=>errors.push(e)});
|
|
22
|
+
const model=(s,id)=>session.modelRuntime.getModel(s,id);
|
|
23
|
+
const beam=model('free-zen','beam-free'),laguna=model('free-kilo','poolside/laguna-s-2.1:free');assert.ok(beam&&laguna);
|
|
24
|
+
await session.setModel(beam);await session.prompt('/free-thinking max');assert.equal(session.thinkingLevel,'max');
|
|
25
|
+
await session.prompt('17*19?');assert.equal(wires.at(-1).reasoning_effort,'max');
|
|
26
|
+
session.setThinkingLevel('low');await session.prompt('17*19?');assert.equal(wires.at(-1).reasoning_effort,'low');
|
|
27
|
+
await session.prompt('/free-thinking default');await session.prompt('17*19?');assert.equal(wires.at(-1).reasoning_effort,undefined);assert.match(statuses.get('free-thinking'),/provider default/);
|
|
28
|
+
session.setThinkingLevel('high');await session.prompt('17*19?');assert.equal(wires.at(-1).reasoning_effort,'high');assert.equal(statuses.get('free-thinking'),undefined);
|
|
29
|
+
await session.prompt('/free-thinking max');await session.setModel(laguna);await session.prompt('/free-thinking instant');assert.equal(session.thinkingLevel,'off');await session.prompt('17*19?');assert.deepEqual(wires.at(-1).reasoning,{enabled:false,effort:'none'});
|
|
30
|
+
session.setThinkingLevel('medium');await session.prompt('17*19?');assert.deepEqual(wires.at(-1).reasoning,{enabled:true,effort:'high'});
|
|
31
|
+
session.setThinkingLevel('off');await session.prompt('17*19?');assert.deepEqual(wires.at(-1).reasoning,{enabled:false,effort:'none'});
|
|
32
|
+
await session.setModel(beam);assert.equal(session.thinkingLevel,'max'); // restored, not erased by Pi's preliminary level event
|
|
33
|
+
await session.prompt('17*19?');assert.equal(wires.at(-1).reasoning_effort,'max');
|
|
34
|
+
assert.equal(typeof footerFactory,'function');
|
|
35
|
+
const data={getGitBranch:()=>null,getExtensionStatuses:()=>statuses,getAvailableProviderCount:()=>4,onBranchChange:()=>()=>{}};
|
|
36
|
+
const component=footerFactory({requestRender:()=>{}},null,data);assert.doesNotMatch(component.render(180)[1],/free-zen/);assert.match(component.render(180)[1],/beam-free/);component.dispose();
|
|
37
|
+
await session.prompt('/free-footer default');assert.equal(footerFactory,undefined);await session.prompt('/free-footer compact');assert.equal(typeof footerFactory,'function');
|
|
38
|
+
const saved=JSON.parse(await readFile(join(dir,'free-catalog/thinking.json'),'utf8'));assert.equal(saved[JSON.stringify(['zen','beam-free'])],'max');assert.equal(saved[JSON.stringify(['kilo',laguna.id])],'auto');
|
|
39
|
+
assert.deepEqual(errors,[]);assert.equal(wires.length,8);
|
|
40
|
+
}finally{if(session){await session.extensionRunner.emit({type:'session_shutdown'});session.dispose();}globalThis.fetch=beforeFetch;if(beforeDir===undefined)delete process.env.PI_CODING_AGENT_DIR;else process.env.PI_CODING_AGENT_DIR=beforeDir;await rm(dir,{recursive:true,force:true,maxRetries:5,retryDelay:50});}
|
|
41
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {readFile} from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
function linkTargets(markdown) {
|
|
6
|
+
const inline=[...markdown.matchAll(/!?\[[^\]]*\]\(\s*<?([^\s)>]+)>?(?:\s+[^)]*)?\)/g)].map(m=>m[1]);
|
|
7
|
+
const references=[...markdown.matchAll(/^\s*\[[^\]]+\]:\s*<?([^\s>]+)>?/gm)].map(m=>m[1]);
|
|
8
|
+
const html=[...markdown.matchAll(/\b(?:href|src)\s*=\s*["']([^"']+)["']/gi)].map(m=>m[1]);
|
|
9
|
+
return [...inline,...references,...html];
|
|
10
|
+
}
|
|
11
|
+
function isPortablePublicLink(target) {
|
|
12
|
+
if(!/^https?:\/\//i.test(target))return false;
|
|
13
|
+
const url=new URL(target);
|
|
14
|
+
// Do not reintroduce a README download/self-link, even through a CDN.
|
|
15
|
+
return !/(?:^|\/)readme(?:[-_.][^/]*)?$/i.test(decodeURIComponent(url.pathname));
|
|
16
|
+
}
|
|
17
|
+
test('public README has no relative documentation links, README-file links or local HTML resources',async()=>{
|
|
18
|
+
const readme=await readFile(new URL('../README.md',import.meta.url),'utf8');
|
|
19
|
+
for(const target of linkTargets(readme))assert.ok(isPortablePublicLink(target),`Non-portable public README link: ${target}`);
|
|
20
|
+
assert.ok(!readme.includes('README-fa.md'));
|
|
21
|
+
});
|
|
22
|
+
test('public README guard detects inline, reference and HTML link regressions',()=>{
|
|
23
|
+
const sample='[Guide](PUBLISHING-fa.md)\n[English](./README.md)\n[doc]: ../README.md\n<a href="README-fa.md">FA</a>\n<img src="./preview.png">\n[CDN](https://cdn.example.com/README.md)';
|
|
24
|
+
const targets=linkTargets(sample);assert.equal(targets.length,6);
|
|
25
|
+
assert.ok(targets.every(t=>!isPortablePublicLink(t)));
|
|
26
|
+
assert.equal(isPortablePublicLink('https://pi.dev/docs/latest/packages'),true);
|
|
27
|
+
});
|
|
28
|
+
test('versioned README installation example matches the release manifest and has no stale candidate banner',async()=>{
|
|
29
|
+
const pkg=JSON.parse(await readFile(new URL('../package.json',import.meta.url),'utf8'));
|
|
30
|
+
const readme=await readFile(new URL('../README.md',import.meta.url),'utf8');
|
|
31
|
+
assert.ok(readme.includes(`pi install npm:${pkg.name}@${pkg.version}`));
|
|
32
|
+
assert.doesNotMatch(readme,/unreleased candidate|0\.1\.3 candidate|pi install npm:pi-free-catalog@0\.1\.2/i);
|
|
33
|
+
});
|
package/test/sdk.test.mjs
CHANGED
|
@@ -14,8 +14,8 @@ async function run(response,extra={}){
|
|
|
14
14
|
const events=[];for await(const e of stream)events.push(e);const result=await stream.result();return {calls,events,result};
|
|
15
15
|
}
|
|
16
16
|
test('real Pi SDK parses Unicode stream, terminal event, tools declaration and zero-cost usage',async()=>{
|
|
17
|
-
const response=sse([chunk({role:'assistant',content:'
|
|
18
|
-
const {events,result,calls}=await run(response);assert.equal(result.stopReason,'stop');assert.equal(result.content[0].text,'
|
|
17
|
+
const response=sse([chunk({role:'assistant',content:'Hello 🧠'}),{...chunk({},'stop'),usage:{prompt_tokens:7,completion_tokens:3,total_tokens:10}}]);
|
|
18
|
+
const {events,result,calls}=await run(response);assert.equal(result.stopReason,'stop');assert.equal(result.content[0].text,'Hello 🧠');assert.equal(result.usage.totalTokens,10);assert.equal(result.usage.cost.total,0);assert.equal(events.filter(e=>e.type==='done'||e.type==='error').length,1);
|
|
19
19
|
const body=JSON.parse(calls[0][1].body);assert.equal(body.reasoning.effort,'low');assert.equal(body.tools[0].function.name,'read');
|
|
20
20
|
});
|
|
21
21
|
test('real Pi SDK assembles split tool JSON into valid arguments',async()=>{
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import test from 'node:test';import assert from 'node:assert/strict';
|
|
2
|
+
import {thinkingSync,piLevelForNative} from '../src/thinking-sync.js';
|
|
3
|
+
import {recordKey} from '../src/catalog.js';import {state} from './helpers.mjs';
|
|
4
|
+
const st=state(),get=(s,id)=>st.entries[recordKey(s,id)];
|
|
5
|
+
test('exact reverse mapping covers Kilo and Zen effort including max',()=>{
|
|
6
|
+
for(const [s,id] of [['kilo','reflection/beam-free'],['zen','beam-free']])for(const level of ['low','medium','high','xhigh','max'])assert.equal(piLevelForNative(get(s,id),st,level),level);
|
|
7
|
+
});
|
|
8
|
+
test('toggle reverse mapping is instant -> off and thinking -> medium, never fictional low/high',()=>{
|
|
9
|
+
const e=get('kilo','poolside/laguna-s-2.1:free');assert.equal(piLevelForNative(e,st,'instant'),'off');assert.equal(piLevelForNative(e,st,'thinking'),'medium');
|
|
10
|
+
});
|
|
11
|
+
test('provider default and unknown future variants never claim an exact Pi level',()=>{
|
|
12
|
+
assert.equal(piLevelForNative(get('zen','beam-free'),st,'default'),null);
|
|
13
|
+
const s=structuredClone(st),e=s.entries[recordKey('kilo','reflection/beam-free')];e.metadata.opencode.variants['new-ultra']={reasoning:{enabled:true,effort:'future-effort'}};
|
|
14
|
+
assert.equal(piLevelForNative(e,s,'new-ultra'),null);
|
|
15
|
+
});
|
|
16
|
+
function setup(){
|
|
17
|
+
let level='medium',saved=0;const prefs={},statuses=new Map();const ctx={model:{provider:'free-zen',id:'beam-free'},ui:{setStatus:(k,v)=>statuses.set(k,v)}};
|
|
18
|
+
const key=()=>recordKey(ctx.model.provider==='free-zen'?'zen':'kilo',ctx.model.id);
|
|
19
|
+
let sync;const pi={getThinkingLevel:()=>level,setThinkingLevel:n=>{const previousLevel=level;level=n;if(n!==previousLevel)void sync.selected({level:n,previousLevel},ctx);}};
|
|
20
|
+
sync=thinkingSync({pi,catalog:{state:st},getSelection:(s,id)=>prefs[recordKey(s,id)]||'auto',setSelection:(k,m)=>prefs[k]=m,save:async()=>{saved++;},output:()=>{}});
|
|
21
|
+
return {pi,sync,ctx,prefs,statuses,key,get saves(){return saved;}};
|
|
22
|
+
}
|
|
23
|
+
test('native -> Pi updates actual level, internal event does not erase preference or recurse',()=>{
|
|
24
|
+
const x=setup();x.sync.restore(x.ctx);x.prefs[x.key()]='max';x.sync.applyNative('max',x.ctx);assert.equal(x.pi.getThinkingLevel(),'max');assert.equal(x.prefs[x.key()],'max');assert.equal(x.saves,0);
|
|
25
|
+
});
|
|
26
|
+
test('Pi -> native removes stale override immediately and affects only current free model',async()=>{
|
|
27
|
+
const x=setup();x.prefs[x.key()]='max';const other=recordKey('kilo','reflection/beam-free');x.prefs[other]='high';x.sync.restore(x.ctx);x.pi.setThinkingLevel('low');assert.equal(x.prefs[x.key()],'auto');assert.equal(x.prefs[other],'high');assert.equal(x.saves,1);
|
|
28
|
+
});
|
|
29
|
+
test('model-switch preliminary thinking event cannot erase target preference',async()=>{
|
|
30
|
+
const x=setup();x.sync.restore(x.ctx);x.ctx.model={provider:'free-kilo',id:'reflection/beam-free'};x.prefs[x.key()]='max';await x.sync.selected({level:'low',previousLevel:'medium'},x.ctx);assert.equal(x.prefs[x.key()],'max');x.sync.restore(x.ctx);assert.equal(x.pi.getThinkingLevel(),'max');
|
|
31
|
+
});
|
|
32
|
+
test('non-free models are unaffected; default override and default-only controls are visibly labelled',async()=>{
|
|
33
|
+
const x=setup();x.prefs[x.key()]='default';x.sync.restore(x.ctx);assert.match(x.statuses.get('free-thinking'),/provider default/);x.pi.setThinkingLevel('low');assert.equal(x.prefs[x.key()],'auto');assert.equal(x.statuses.get('free-thinking'),undefined);
|
|
34
|
+
x.ctx.model={provider:'anthropic',id:'anything'};await x.sync.selected({level:'high',previousLevel:'low'},x.ctx);x.sync.restore(x.ctx);assert.equal(x.statuses.get('free-thinking'),undefined);assert.equal(x.saves,1);
|
|
35
|
+
});
|
|
36
|
+
test('unmapped future native mode remains usable and explicitly labelled',()=>{
|
|
37
|
+
const x=setup();const e=st.entries[x.key()];e.metadata.reasoning_options.push({type:'effort',values:['future-effort']});
|
|
38
|
+
try{x.prefs[x.key()]='future-effort';x.sync.restore(x.ctx);assert.equal(x.pi.getThinkingLevel(),'medium');assert.match(x.statuses.get('free-thinking'),/no exact Pi level/);}finally{e.metadata.reasoning_options.pop();}
|
|
39
|
+
});
|
package/test/transport.test.mjs
CHANGED
|
@@ -24,7 +24,7 @@ test('HTTP 404/429 are passed through and NOT reclassified as retirement',async(
|
|
|
24
24
|
for(const status of [404,429]){const response=new Response('upstream error',{status});const {f}=setup('kilo',input.model,response);assert.equal((await f(url,{method:'POST',body:JSON.stringify(input)})).status,status);assert.equal(catalog.state.entries[recordKey('kilo',input.model)].lifecycle,'active');}
|
|
25
25
|
});
|
|
26
26
|
test('Unicode SSE, empty text, tool chunks and partial bytes pass through exactly, no lossy SSE fixer',async()=>{
|
|
27
|
-
const bytes=new TextEncoder().encode('data: {"choices":[{"delta":{"content":"
|
|
27
|
+
const bytes=new TextEncoder().encode('data: {"choices":[{"delta":{"content":"Hello 🧠"}}]}\n\ndata: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":"{\\"path\\":\\"a\\"}"}}]}}]}\n\ndata: [DONE]\n\n');
|
|
28
28
|
const response=new Response(new ReadableStream({start(c){for(let i=0;i<bytes.length;i+=3)c.enqueue(bytes.slice(i,i+3));c.close();}}));
|
|
29
29
|
const {f}=setup('kilo',input.model,response);const actual=await f(url,{method:'POST',body:JSON.stringify(input)});assert.equal(actual,response);assert.deepEqual(new Uint8Array(await actual.arrayBuffer()),bytes);
|
|
30
30
|
});
|
package/PUBLISHING-fa.md
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
# انتشار در npm و نصب تکدستوری در Pi
|
|
2
|
-
|
|
3
|
-
این بسته روی npm منتشر شده است: `pi-free-catalog` (بدون scope، نسخهٔ 0.1.1، نگهدارنده: `aminmza`). این راهنما برای انتشار نسخههای بعدی و بازتولید همین فرایند است. یک بسته هر دو provider را منتشر میکند؛ نیازی به انتشار دو بسته نیست.
|
|
4
|
-
|
|
5
|
-
## ۱. پیشنیاز
|
|
6
|
-
|
|
7
|
-
- حساب npm متعلق به خودتان؛ دسترسی به نام یا scope مورد نظر.
|
|
8
|
-
- Node >=22.19 و نسخهٔ سازگار Pi. این نسخه روی Pi 1.3.0 آزمایش شده است.
|
|
9
|
-
- فعالکردن احراز هویت دومرحلهای حساب npm برای انتشار تعاملی.
|
|
10
|
-
- بررسی کد و مجوز MIT. برای استفاده از سرویسهای مدل، شرایط خود سرویسها هم مستقل از مجوز کد است.
|
|
11
|
-
|
|
12
|
-
طبق مستندات فعلی npm، انتشار بسته به 2FA یا روش مجاز token نیاز دارد. پیشنهاد این راهنما **login و انتشار تعاملی با 2FA** است. رمز، OTP، توکن یا فایل `.npmrc` را در چت/سورس نگذارید. برای CI در آینده trusted publishing را بررسی کنید، نه قراردادن توکن در کد.
|
|
13
|
-
|
|
14
|
-
## ۲. نام و scope بسته
|
|
15
|
-
|
|
16
|
-
داخل پوشهٔ استخراجشده:
|
|
17
|
-
|
|
18
|
-
```bash
|
|
19
|
-
cd pi-free-catalog
|
|
20
|
-
node --version
|
|
21
|
-
pi --version
|
|
22
|
-
npm login
|
|
23
|
-
npm whoami
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
این بسته بدون scope منتشر شده است و نیازی به تغییر نام ندارد. فقط اگر میخواهید نسخهٔ خودتان را با scope منتشر کنید، نام را عوض کنید:
|
|
27
|
-
|
|
28
|
-
```bash
|
|
29
|
-
NPM_USER="$(npm whoami)"
|
|
30
|
-
npm pkg set "name=@${NPM_USER}/pi-free-catalog"
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
این متغیر صرفاً نام حساب است، نه token. روی Windows PowerShell معادل آن:
|
|
34
|
-
|
|
35
|
-
```powershell
|
|
36
|
-
$NpmUser = npm whoami
|
|
37
|
-
npm pkg set "name=@$NpmUser/pi-free-catalog"
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
اگر قبلاً همین نام/نسخه را منتشر کردهاید، نسخه را افزایش دهید؛ یک ترکیب name/version را نمیتوان دوباره استفاده کرد. نام صاحب مجوز/اطلاعات نویسنده را فقط مطابق مالکیت واقعی خودتان اضافه کنید. repository را فقط وقتی آدرس واقعی دارید وارد کنید؛ در این بسته آدرس جعلی وجود ندارد.
|
|
41
|
-
|
|
42
|
-
## ۳. بررسی و ساخت بسته
|
|
43
|
-
|
|
44
|
-
```bash
|
|
45
|
-
npm install
|
|
46
|
-
npm run verify
|
|
47
|
-
npm pack --dry-run
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
`verify` شامل بررسی syntax، تمام تستهای خودکار و dry-run بسته است. بهطور پیشفرض inference واقعی اجرا نمیکند. قبل از انتشار نهایی، تستهای زندهٔ اختیاری را هم متناسب با بودجه/سهمیهٔ عمومی بررسی کنید.
|
|
51
|
-
|
|
52
|
-
لیست فایلهای pack را بخوانید. سورس، تستها، fixtureها، مستندات و LICENSE باید باشند؛ credential، `.npmrc`، کش، HOME آزمایشی، node_modules و شواهد inference نباید باشند. بسته `files` محدود دارد و symlink توسعهٔ محلی node_modules وارد tarball نمیشود.
|
|
53
|
-
|
|
54
|
-
## ۴. انتشار واقعی — این فرمان را خودتان اجرا میکنید
|
|
55
|
-
|
|
56
|
-
```bash
|
|
57
|
-
npm publish --access public
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
درخواست احراز هویت/2FA را در ابزار رسمی npm کامل کنید. `prepublishOnly` دوباره verify را اجرا میکند. اگر تست شکست بخورد، انتشار نباید ادامه یابد.
|
|
61
|
-
|
|
62
|
-
برای بررسی پس از انتشار:
|
|
63
|
-
|
|
64
|
-
```bash
|
|
65
|
-
npm view "pi-free-catalog@0.1.1" version dist.integrity dist.shasum
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
## ۵. نصب در Pi با یک دستور
|
|
69
|
-
|
|
70
|
-
برای همان حساب ناشر و در همان shell:
|
|
71
|
-
|
|
72
|
-
```bash
|
|
73
|
-
pi install "npm:pi-free-catalog@0.1.1"
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
برای دیگران، همان نام منتشرشده را بدهید:
|
|
77
|
-
|
|
78
|
-
```bash
|
|
79
|
-
pi install npm:pi-free-catalog@0.1.1
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
این نام واقعاً روی npm منتشر شده است. پس از نصب، Pi را دوباره اجرا کنید و providerهای `free-kilo` و `free-zen` را انتخاب کنید. npm بسته را نصب میکند؛ کاتالوگ مدلها در هر اجرای خود افزونه از سرویسها بهروز میشود، نه از fixtureهای npm.
|
|
83
|
-
|
|
84
|
-
## ۶. نسخهٔ بعدی
|
|
85
|
-
|
|
86
|
-
کاتالوگ معمولاً بدون انتشار نسخهٔ جدید تازه میشود. اما اصلاح کد، تغییر schema/API، تغییر سیاست ناشناس یا پروتکل تازه، انتشار نسخهٔ جدید میخواهد.
|
|
87
|
-
|
|
88
|
-
پس از تغییر و تست:
|
|
89
|
-
|
|
90
|
-
```bash
|
|
91
|
-
npm version patch --no-git-tag-version
|
|
92
|
-
npm publish --access public
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
تغییرات و سازگاری را در CHANGELOG ثبت کنید. اگر نسخهٔ جدید User-Agent/README نسخهٔ محصول را تغییر میدهد، آن برچسبها را هم هماهنگ کنید. از افزایش جعلی نسخهٔ پروتکل سازگاری برای پنهانکردن خطای API استفاده نکنید؛ سازگاری را واقعاً تست کنید.
|
|
96
|
-
|
|
97
|
-
## ۷. نکتهٔ عملی: 2FA و انتشار مرحلهای در npm 12
|
|
98
|
-
|
|
99
|
-
npm 12 انتشار را با «staged publishing» انجام میدهد و اثبات حضور (2FA) را به مرحلهای بعد موکول میکند؛ خانوادهٔ دستورهای `npm stage publish|list|view|approve|reject` برای همین کار است.
|
|
100
|
-
|
|
101
|
-
نکتهٔ مهم در محیط بدون TTY (Termux یا CI): کتابخانهٔ npm فقط زمانی جریان احراز هویت مرورگری را اجرا میکند که هر دو ورودی و خروجی TTY باشند؛ در غیر این صورت بیدرنگ خطای `EOTP` میدهد. روی Termux از pty استفاده کنید:
|
|
102
|
-
|
|
103
|
-
```bash
|
|
104
|
-
printf '\n' | script -qec 'npm publish ./pi-free-catalog-0.1.1.tgz --tag latest --access public' /dev/null
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
سپس URL چاپشده را در مرورگر تأیید کنید. ممکن است npm یک نسخهٔ جایگزین `0.0.0-stage` (stub) هم در registry بسازد؛ این نسخهٔ خالی است و برچسب `latest` را تغییر نمیدهد.
|
|
108
|
-
|
|
109
|
-
## مستندات رسمی بررسیشده
|
|
110
|
-
|
|
111
|
-
- https://docs.npmjs.com/cli/v11/commands/npm-publish
|
|
112
|
-
- https://docs.npmjs.com/requiring-2fa-for-package-publishing-and-settings-modification
|
|
113
|
-
- https://docs.npmjs.com/trusted-publishers
|
|
114
|
-
|
|
115
|
-
نسخهٔ فعلی منتشرشده `pi-free-catalog@0.1.1` است؛ نسخههای بعدی با همان حساب npm و 2FA منتشر میشوند. بررسی کد و مجوز MIT پیش از انتشار همچنان لازم است.
|
package/README-fa.md
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
# افزونهٔ زندهٔ مدلهای رایگان Pi — نسخهٔ 0.1.1
|
|
2
|
-
|
|
3
|
-
یک بسته با دو provider مستقل: **`free-kilo`** و **`free-zen`**. پیادهسازی مستقل است؛ افزونهٔ رسمی Kilo/OpenCode نیست. روی **Pi 1.3.0 و Node 22.23.3** آزمایش شده؛ حداقل Node اعلامشده **22.19** است. نسخههای قدیمی Pi تضمین نمیشوند.
|
|
4
|
-
|
|
5
|
-
**روی npm منتشر شده است:** بستهٔ بدون scope با نام `pi-free-catalog` (نسخهٔ 0.1.1، مجوز MIT، نگهدارنده: `aminmza`). برای نصب از registry: `pi install npm:pi-free-catalog@0.1.1`
|
|
6
|
-
|
|
7
|
-
## نصب محلی
|
|
8
|
-
|
|
9
|
-
ZIP را استخراج کنید و پوشه را در جای دائمی نگه دارید. از پوشهٔ والد:
|
|
10
|
-
|
|
11
|
-
```bash
|
|
12
|
-
pi install ./pi-free-catalog
|
|
13
|
-
pi
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
سپس در انتخابگر مدل، `free-kilo` یا `free-zen` را انتخاب کنید. کلید شخصی، حساب سرویس یا اجرای جداگانهٔ OpenCode CLI لازم نیست. برای عیبیابی، افزونههای دیگری را که درخواستهای همین سرویسها را تغییر میدهند موقتاً غیرفعال کنید. افزونه namespace مستقل دارد و مدلهای provider اصلی شما را جایگزین نمیکند.
|
|
17
|
-
|
|
18
|
-
## قانون اصلی: هیچ مدل رایگانی بیصدا ناپدید نشود
|
|
19
|
-
|
|
20
|
-
- **در هر بار بارگذاری افزونه**، پیش از انتخاب مدل در شروع Pi، پنج منبع زنده دریافت میشوند؛ حتی در اجرای غیرتعاملی. شروع برنامه از کش بهجای تلاش تازهٔ شبکه استفاده نمیکند.
|
|
21
|
-
- کل شناسههای کاتالوگ طبقهبندی میشوند؛ نه یک allowlist ثابت و نه صرفاً نامهایی که `free` دارند. مدل تازهٔ بدون چنین اسمی هم کشف میشود.
|
|
22
|
-
- Kilo با پرچم صریح `isFree:true` شناسایی میشود؛ وجود مبلغ مثبت در قیمتها با این پرچم «تعارض» است. قیمت صفر با `isFree:false` هم خودکار رایگان فرض نمیشود. اگر پرچم صریح true باشد ولی قیمت عددی ناقص/پویا باشد، خود پرچم بهعنوان مدرک حفظ میشود.
|
|
23
|
-
- فهرست Zen قیمت ندارد؛ شناسهٔ دقیق آن با registry رسمی OpenCode، models.dev و کاتالوگ زندهٔ Pi تطبیق داده میشود. قیمت ورودی، خروجی، cache و tierهای اعلامشده بررسی میشود. قیمت ناموجود هرگز صفر نیست.
|
|
24
|
-
- مدلهای مشکوک، متعارض، قبلاً رایگان و مدلهای رایگانِ متادیتا که در فهرست زنده نیستند، در گزارش و خروجی JSON باقی میمانند. تناقض قیمت رایگان/پولی در منابع تازه بیصدا نادیده گرفته نمیشود.
|
|
25
|
-
- همهٔ مدلهای چتِ رایگانِ تأییدشده و فهرستشده ثبت میشوند؛ حتی بدون ابزار، دارای سقف نامعلوم، یا با سابقهٔ timeout/خطای سرویس. شکست پاسخ باعث مخفیشدن مدل نمیشود.
|
|
26
|
-
- classifier بومی مثل `jev-1.13-free` نیز ثبت میشود، ولی **بهعنوان classifier**؛ نه مدل چت تقلبی. مصرفش از API classifier در Pi است و در انتخابگر چت قرار نمیگیرد.
|
|
27
|
-
- نوع عملیات یا پروتکل آیندهای که adapter آن هنوز پیاده نشده هم در کاتالوگ با توضیح روشن باقی میماند، نه اینکه حذف شود.
|
|
28
|
-
|
|
29
|
-
**محدودیت صادقانه:** هیچ برنامهای نمیتواند مدل پنهانشده در خود سرویس یا مدل بدون هیچ مدرک قیمت را قطعاً رایگان تشخیص بدهد. اینها در بخش نامشخص دیده میشوند؛ اما برای رعایت «فقط رایگان»، خودکار قابل فراخوانی نمیشوند. پشتیبانی خودکار از هر schema یا پروتکل ناشناختهٔ آینده هم قابل تضمین نیست.
|
|
30
|
-
|
|
31
|
-
## همهٔ اطلاعات چه زمانی تازه میشود؟
|
|
32
|
-
|
|
33
|
-
در شروع، درخواستها موازی و با timeout دوازدهثانیهای برای هر منبع ارسال میشوند. بدنهها حداکثر ۱۶ MiB هستند. بدنهٔ خام منابع، مشخصات، قیمت، قابلیتها، وضعیت و زمان دریافت در فایل زیر ذخیره میشود:
|
|
34
|
-
|
|
35
|
-
```text
|
|
36
|
-
~/.pi/agent/free-catalog/catalog.json
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
اگر محل agent directory را تغییر دادهاید، فایل در همان محل قرار میگیرد. بعد از پنج دقیقه، در نوبت بعدی عامل/درخواست دوباره اعتبارسنجی میشود. برای دریافت فوری از `/free-refresh` استفاده کنید. timer دائمی پسزمینه ندارد.
|
|
40
|
-
|
|
41
|
-
اگر اینترنت یا یکی از منابع قطع باشد، **اطلاعات تازه جعل نمیشود**: وضعیت منبع خطادار روشن میشود، دادهٔ قبلی قابل مشاهده میماند و درخواستِ فاقد تأیید تازه مسدود میشود. اگر منبع جایگزینِ معتبر و تازه برای همان شناسه وجود داشته باشد، میتواند مدرک قیمت فراهم کند. لغو refresh پسزمینه توسط خود Pi، snapshot سالمِ تازه را خراب نمیکند.
|
|
42
|
-
|
|
43
|
-
سقف context و خروجی از متادیتای همان مدل میآید، نه مقدار عمومی حدسی. اگر فیلد ضروری Pi واقعاً موجود نباشد، مدل حذف نمیشود و نامش برچسب «limits unverified» میگیرد؛ مقدار ایمنی محلی 8192/2048 استفاده میشود، **نه ادعای سقف واقعی provider**. تمام modalityها و سایر فیلدهای خام در export حفظ میشوند؛ مسیر چت این افزونه فعلاً فقط ورودی text/image قابلنمایش در Pi را پیاده میکند.
|
|
44
|
-
|
|
45
|
-
## تشخیص بازنشستگی و تغییر قیمت
|
|
46
|
-
|
|
47
|
-
| رویداد | رفتار |
|
|
48
|
-
|---|---|
|
|
49
|
-
| اعلام صریح `retired` یا رسیدن تاریخ صریح retirement | از مدلهای قابل فراخوانی خارج میشود؛ در تاریخچه باقی میماند |
|
|
50
|
-
| ناپدیدشدن از پاسخ سالم کاتالوگ | `not-listed`؛ نه ادعای بازنشستگی قطعی |
|
|
51
|
-
| برگشتن به کاتالوگ | خودکار دوباره فعال میشود، در صورت رایگانبودن |
|
|
52
|
-
| `deprecated` ولی هنوز موجود و رایگان | قابل استفاده با برچسب deprecated |
|
|
53
|
-
| تغییر رایگان به پولی | از فهرست قابل فراخوانی رایگان خارج میشود؛ سابقه پنهان نمیشود |
|
|
54
|
-
| timeout، ظرفیت، 404 یا 429 هنگام inference | دلیل بازنشستگی نیست |
|
|
55
|
-
| خطای شبکه، JSON خراب یا فهرست خالی مشکوک | دادهٔ قبلی حفظ میشود؛ بازنشستگی دستهجمعی اعلام نمیشود |
|
|
56
|
-
|
|
57
|
-
موجودی شناسههای دیدهشده حذف/کوتاه نمیشود. فقط فهرست رویدادهای اخیر به ۲۰۰۰ تغییر محدود است؛ وضعیت فعلی و سابقهٔ حضور هر شناسه حفظ میشود.
|
|
58
|
-
|
|
59
|
-
## فرمانها
|
|
60
|
-
|
|
61
|
-
```text
|
|
62
|
-
/free-catalog
|
|
63
|
-
/free-refresh
|
|
64
|
-
/free-thinking
|
|
65
|
-
/free-thinking MODE
|
|
66
|
-
/free-export
|
|
67
|
-
/free-export /path/to/catalog.json
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
`/free-catalog` همهٔ مدلهای رایگان، نامشخص، متعارض و تاریخی را نشان میدهد. `/free-export` جزئیات کامل تمام شناسههای زنده، حتی پولیها، و شواهد خام را ذخیره میکند.
|
|
71
|
-
|
|
72
|
-
## تنظیم thinking بر اساس خود provider
|
|
73
|
-
|
|
74
|
-
ابتدا مدل مورد نظر را انتخاب کنید و بزنید:
|
|
75
|
-
|
|
76
|
-
```text
|
|
77
|
-
/free-thinking
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
نام حالتهای بومی و پارامترهایشان را میبینید. نمونهها:
|
|
81
|
-
|
|
82
|
-
- Kilo Laguna یا Super VL: `instant` و `thinking`.
|
|
83
|
-
- Kilo Beam: `low`، `medium`، `high`، `xhigh`، `max`.
|
|
84
|
-
- Zen Beam: effortهای اعلامشدهٔ خودش؛ در درخواست به شکل `reasoning_effort`.
|
|
85
|
-
|
|
86
|
-
```text
|
|
87
|
-
/free-thinking instant
|
|
88
|
-
/free-thinking low
|
|
89
|
-
/free-thinking max
|
|
90
|
-
/free-thinking auto
|
|
91
|
-
/free-thinking default
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
هر نمونه فقط وقتی معتبر است که مدل انتخابشده همان حالت را اعلام کرده باشد. انتخاب نامعتبر خطای روشن میدهد. `auto` سطح thinking خود Pi را به مقدار بومی مدل نگاشت میکند. گزینهٔ استاندارد `--thinking low` روی Beam هر دو سرویس در درخواست واقعی بررسی شده است. `default` تنظیم thinking ساختهشده توسط adapter را حذف میکند تا پیشفرض provider اعمال شود.
|
|
95
|
-
|
|
96
|
-
حالت انتخابشده برای **همان provider/مدل** در `thinking.json` ذخیره میشود. Kilo از `opencode.variants` و Zen از `reasoning_options` و قابلیتهای exact-ID کاتالوگ زندهٔ Pi استفاده میکند. اگر provider هیچ سطح قابل تنظیمی منتشر نکند، پیشفرض خودش عرضه میشود؛ ادعا نمیکنیم که انتخاب یک سطح خیالی واقعاً تغییری ایجاد کرده است.
|
|
97
|
-
|
|
98
|
-
## امنیت و ابزارها
|
|
99
|
-
|
|
100
|
-
- برای streaming و tool calling از پیادهسازی خود Pi استفاده میشود؛ parser دستساز SSE نداریم.
|
|
101
|
-
- fetch یا HTTP کل برنامه patch نمیشود؛ فقط درخواست providerهای خودمان کنترل میشود.
|
|
102
|
-
- شناسهٔ مدل، endpoint، رایگانبودن و تازگی مدرک پیش از درخواست HTTP بررسی میشود. redirect و fallback پولی مجاز نیست.
|
|
103
|
-
- کلید شخصی ارسال نمیشود: Kilo بدون Authorization؛ Zen با مقدار عمومی `Bearer public`.
|
|
104
|
-
- Zen به هدرهای پروتکل سازگار OpenCode نیاز دارد. User-Agent نام این افزونه و نسخهٔ سازگاری آزمایششدهٔ 1.19.3 را آشکار میکند؛ افزونه خودِ باینری OpenCode نیست. شناسهٔ نشست ثابت است و برای دورزدن سهمیه چرخانده نمیشود.
|
|
105
|
-
- کلید شخصی از محیط/فایل auth توسط خود افزونه خوانده نمیشود و هدرهای کلید شخصی در مرز ارسال حذف میشوند.
|
|
106
|
-
- هیچ مدلی بعد از خطا blacklist نمیشود و برای سبزکردن تست، ابزارها خودکار خاموش نمیشوند.
|
|
107
|
-
|
|
108
|
-
سهمیه، ظرفیت، کیفیت ابزارزنی و تأخیر تحت اختیار سرویس است. تغییر سیاست دسترسی یا پروتکل upstream ممکن است نیازمند نسخهٔ جدید افزونه باشد. رایگانبودن همیشگی یا موفقیت همهٔ مدلها تضمین نمیشود.
|
|
109
|
-
|
|
110
|
-
## اجرای تستها
|
|
111
|
-
|
|
112
|
-
```bash
|
|
113
|
-
npm install
|
|
114
|
-
npm run verify
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
این فرمان syntax، تستهای آفلاین/شبیهسازی، parser و classifier واقعی SDK Pi و محتوای بستهٔ npm را بررسی میکند. تستهای آفلاین را نباید inference واقعی نامید. fixtureهای فشرده، snapshot واقعی ۲۰۲۶-۱۰-۱۷ هستند و فهرست production را ثابت نمیکنند.
|
|
118
|
-
|
|
119
|
-
بررسی زندهٔ کاتالوگ بدون inference:
|
|
120
|
-
|
|
121
|
-
```bash
|
|
122
|
-
node scripts/catalog.mjs /tmp/free-catalog-live.json
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
اسکریپتهای `scripts/live_pi.py`، `scripts/observe_live.py` و recorder محافظ `scripts/wire.mjs` نیز همراه سورس هستند. اجرای آنها اختیاری و نیازمند Python 3، Pi نصبشده و مسیرهای `PI_NODE`، `PI_CLI`، `PI_TEST_DISPATCHER` و `FREE_TEST_DIR` است. ابتدا snapshot را با `catalog.mjs` در `FREE_TEST_DIR/live-catalog.json` بنویسید. توضیح دقیقتر در README انگلیسی آمده است. دادههای این آزمونها فقط fixture محلیاند؛ آنها را روی پوشهٔ پروژه/اطلاعات محرمانهٔ خود اجرا نکنید. نتیجهٔ هر مدل را از JSON بخوانید؛ خروج موفق اسکریپت به معنی موفقیت همهٔ مدلهای upstream نیست.
|
|
126
|
-
|
|
127
|
-
گزارش اجرای واقعی این نسخه و فایل شواهد، جدا از بستهٔ قابل انتشار تحویل شدهاند. روند انتشار نسخههای بعدی در **PUBLISHING-fa.md** آمده است؛ انتشار از حساب npm نگهدارنده با 2FA انجام میشود.
|