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 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 README.md, README-fa.md and PUBLISHING-fa.md; install instructions now reference the real published name and version.
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
- Live, auditable **anonymous Kilo + OpenCode Zen** model discovery for **Pi 1.3.x / Node >=22.19**. One Pi package, two separate providers: `free-kilo` and `free-zen`. This package is an independent integration, not an official service client. It is published on npm as [`pi-free-catalog`](https://www.npmjs.com/package/pi-free-catalog) under the MIT license, maintained by `aminmza`.
3
+ Find free models from **Kilo** and **OpenCode Zen** in Pi, without keeping a model list up to date yourself.
4
4
 
5
- [فارسی](README-fa.md) · [Publishing instructions](PUBLISHING-fa.md)
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
- ## Install locally
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
- Extract the source ZIP, keep its folder permanently, then:
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 ./pi-free-catalog
14
+ pi install npm:pi-free-catalog
13
15
  pi
14
16
  ```
15
17
 
16
- There is no build step and no third-party runtime dependency beyond Pi's peer packages. Do not `npm install -g` this as a standalone agent. Choose `free-kilo` or `free-zen` in Pi's model picker. You do not need a personal API key, login or OpenCode CLI process. Disable other extensions modifying these requests when diagnosing conflicts.
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
- ## Install from npm
26
+ To try the published package for one run without saving it to your settings:
19
27
 
20
28
  ```bash
21
- pi install npm:pi-free-catalog@0.1.1
29
+ pi -e npm:pi-free-catalog
22
30
  ```
23
31
 
24
- Pin the exact version for reproducibility. `pi -e npm:pi-free-catalog` loads the package for a single invocation without changing your settings.
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
- ## Non-negotiable: no silent omissions
53
+ The goal is to keep the full picture visible, not just the models that happened to work in a test.
27
54
 
28
- - Fetches **all five sources on EVERY extension load**, before Pi startup model selection, even for noninteractive startup. No warm-up session or TTL shortcut skips startup retrieval.
29
- - Classifies **every live ID**, not just a static allowlist and never by a `free` name/suffix.
30
- - Kilo: explicit `isFree:true` is authoritative. Explicit positive monetary charges conflict with that flag and are reported/blocked. Zero cost with `isFree:false` is reported as conflict, not assumed free. Missing/dynamic costs with an explicit true flag are preserved as explicit-free evidence.
31
- - Zen: exact-ID join of the live catalog with live official OpenCode, models.dev and Pi model/price metadata. Checks input/output, cache and tiered charges. Missing prices are UNKNOWN, never silently zero. Conflicting fresh free/paid prices are visibly quarantined.
32
- - Metadata-free models absent from the live service, formerly-free models, uncertain IDs, deprecated IDs and retired IDs stay visible in `/free-catalog` and `/free-export`.
33
- - All currently live confirmed-free **chat** models are registered, including ones that lack tools, have unknown limits or previously failed inference. Failures are not reasons to hide them.
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
- **No honest client can prove an unpriced model is free or discover a model the upstream never exposes.** Such cases remain visible as UNKNOWN, not automatically callable as free. An anonymous request cannot spend a personal key this extension never sends.
62
+ ### How free pricing is checked
38
63
 
39
- ## Refresh, history and retirement
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
- Startup awaits bounded parallel source requests (12-second timeout per source, 16 MiB per body). Full raw JSON, exact IDs, metadata, source timestamps, classification and lifecycle history are saved under `<Pi agent directory>/free-catalog/catalog.json`. Normal location: `~/.pi/agent/free-catalog/`.
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
- After five minutes, the next agent turn/request revalidates. `/free-refresh` explicitly fetches everything immediately. No permanent background timer. History retains every seen ID; the recent transition event log is bounded to 2,000 events, not the ID inventory.
68
+ The extension cannot verify information that an upstream service does not publish. It keeps that uncertainty visible instead of guessing.
44
69
 
45
- - Explicit upstream retirement / elapsed explicit retirement date: retired, removed from callable models, retained in history.
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
- A refresh is an attempt to retrieve current upstream information, not a guarantee that the upstream/CDN has perfect, instantly synchronized metadata. During network failure, fresh information cannot be fabricated.
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 | Function |
92
+ | Command | What it does |
58
93
  |---|---|
59
- | `/free-catalog` | Show all free + uncertain/conflicting + former/absent/retired entries, source health and last HTTP statuses |
60
- | `/free-refresh` | Re-fetch all catalogs/prices/capabilities; replace lists and report transitions |
61
- | `/free-thinking` | Inspect native thinking choices and the selected mode for current free model |
62
- | `/free-thinking MODE` | Select a provider-advertised native mode, saved per provider/model |
63
- | `/free-export [PATH]` | Export complete evidence, all live IDs including paid ones, source JSON and history |
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
- ## Thinking — actual provider semantics
118
+ ### Pi and native choices stay in sync
66
119
 
67
- `auto` maps Pi's current thinking level to advertised native values. Standard Pi `--thinking low` was wire-tested on Kilo and Zen Beam. `default` removes adapter-generated thinking fields and leaves the provider default. Neither mode invents unsupported effort values.
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
- Examples (first select the indicated model):
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
- - Kilo Laguna / Super VL toggle: `/free-thinking instant` or `/free-thinking thinking`.
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
- Kilo variant names/parameters come from live `opencode.variants`; Zen uses live `reasoning_options` and exact-ID Pi capability hints. New native names are not limited to Pi's fixed level vocabulary. Unsupported modes fail explicitly. Where the provider publishes no adjustable values, only its default is advertised; we do not claim that choosing a Pi level secretly changed it. A native mode overrides Pi's level only for that individual model. Thinking preferences live in `thinking.json` beside the catalog.
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
- Full capabilities remain in the exported raw metadata. Pi currently accepts only text/image inputs through this chat integration; other advertised modalities are not falsely presented as implemented. Required missing limits use labelled local safety defaults (8192 context / 2048 output), NOT claimed provider limits. Models are not hidden for that reason.
132
+ Preferences are saved per provider and model in `thinking.json` beside the cache.
79
133
 
80
- ## Transport and security
134
+ ## A quieter footer
81
135
 
82
- Uses Pi's own OpenAI Completions, Responses, Anthropic Messages and native classifier implementations; no home-grown SSE parser. Tool schemas/results, Unicode, cancellation, usage and finish reasons remain SDK-owned. No global fetch/http patches. Scoped fetch validates the exact model, URL, current free classification and freshness before each HTTP call; disallows redirects and paid fallback; strips personal Authorization/API-key headers.
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
- Kilo sends no Authorization. Zen uses literal `Bearer public` and OpenCode-compatible CLI protocol headers with a stable per-session ID. Its User-Agent explicitly includes `pi-free-catalog` and the tested compatibility version 1.19.3. This protocol adapter is not an installed OpenCode binary. Future access policy/client-version requirements can break anonymous access; this extension does not create accounts, rotate quota identities, bypass paid authentication or promise permanent free access.
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
- No automatic model blacklist, no hiding models after errors, no automatic disabling of tools to produce a green test. SDK retry/cancellation behavior is retained. Actual capacity, quotas, latency, tool support and reasoning quality belong to the upstream.
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
- ## Tests
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 install
157
+ npm ci
92
158
  npm run verify
93
159
  ```
94
160
 
95
- Verification runs syntax checks, offline automated tests against compressed real catalog fixtures (2026-10-17), mocked lifecycle/transport/security tests, real Pi SDK stream/classifier normalization tests, and npm package-content inspection. These are **not live inference claims**. Peer SDK 1.3.0 was used; fixture expectations do not pin the production catalog. The packaging test accepts both `npm pack --json` output shapes (an array on npm 11, a name-keyed object on npm 12 and later).
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
- Opt-in live catalog inspection (network, no inference):
163
+ For an optional live catalog check without inference:
98
164
 
99
165
  ```bash
100
- node scripts/catalog.mjs /tmp/free-catalog-live.json
166
+ node scripts/catalog.mjs ./catalog-inspection.json
101
167
  ```
102
168
 
103
- Opt-in real Pi CLI tests are in `scripts/live_pi.py` and `scripts/observe_live.py` (Python 3 + Node + installed Pi peer packages). Set absolute `PI_NODE`, `PI_CLI`, `PI_TEST_DISPATCHER`, and `FREE_TEST_DIR`; generate `FREE_TEST_DIR/live-catalog.json` with `catalog.mjs` first. `PI_TEST_DISPATCHER` is Pi's installed `dist/core/http-dispatcher.js`. `PI_CLI` is its `dist/cli.js`. The separate test-only `wire.mjs` enforces a confirmed-free allowlist and 120 POST attempts/service, records request payloads/HTTP statuses, and is never loaded in production. The suite uses isolated HOME/settings, random fixtures, default tools ON and strict named read/write/edit/bash verification. It exits processes on deadlines. Inspect `inference-results.json`; process exit 0 alone does not mean all upstream models passed. Re-running reuses completed case IDs; use a new evidence directory for a fresh complete run.
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
- Releases are published from the `aminmza` npm account with 2FA enabled. See [PUBLISHING-fa.md](PUBLISHING-fa.md). Never share npm tokens or commit `.npmrc`, credentials, caches or live test files.
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
- ## Sources
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.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
- await writeFile(temp,JSON.stringify(data,null,2),{mode:0o600}); await rename(temp,file);
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
- const state=JSON.parse(await readFile(this.file,'utf8'));
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-thinking',{description:'Show native thinking modes, or choose auto/default/a native mode for the selected free model',handler:async(args,ctx)=>{
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 atomicJSON(selectionFile,selections);
72
- output(`Thinking for ${service}/${entry.id}: ${mode}. Native settings override Pi level only for this model.`,ctx);
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
+ }
@@ -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,'utf8')).schemaVersion,1);assert.equal(summary(b.state).kilo.free,17);
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)=>`مدل-${i}/new.route`);
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
+ });
@@ -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
- 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:()=> 'medium'};
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
+ });
@@ -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','PUBLISHING-fa.md','test/fixtures/kilo.json.gz'])assert.ok(files.includes(path),path);
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:'سلام 🧠'}),{...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,'سلام 🧠');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);
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
+ });
@@ -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":"سلام 🧠"}}]}\n\ndata: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":"{\\"path\\":\\"a\\"}"}}]}}]}\n\ndata: [DONE]\n\n');
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 انجام می‌شود.