@bitefoundry/retrieviewer 0.0.12 → 0.0.14

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/ATTENTION.md ADDED
@@ -0,0 +1,92 @@
1
+ # Attention preview
2
+
3
+ Opt-in, metadata-only StackBite notifications. This integration does **not** see
4
+ screens, click controls, answer questions, or approve anything. LLM use is OFF by
5
+ default; see the separate [opt-in paid DeepSeek backend](BRAIN.md).
6
+ The existing puppy art, rendering and physics stay in place.
7
+
8
+ For a local browser motion review with explicitly simulated events:
9
+
10
+ ```sh
11
+ node tools/preview-attention.mjs
12
+ ```
13
+
14
+ Open its loopback URL. This page does not connect to StackBite or inspect any
15
+ screen; its pending items are fixtures. The preview server exits after ten minutes;
16
+ the loaded page can keep animating locally. Relaunch the command to reload later.
17
+
18
+ ## Run the installed package
19
+
20
+ After `npm install -g @bitefoundry/retrieviewer@latest`, run from a live StackBite
21
+ Seat on the same computer as the desktop overlay, from any directory:
22
+
23
+ ```sh
24
+ retrieviewer attention --json
25
+ retrieviewer desktop --watch stackbite
26
+ ```
27
+
28
+ Choose the StackBite window. If a companion is already running, stop it explicitly
29
+ with `retrieviewer desktop-stop`, then start the watcher. An existing
30
+ companion is never silently replaced. Check `desktop-status --json` for
31
+ `watch.source`, `watch.state` and `watch.pendingCount`.
32
+
33
+ Source development commands such as `node retrieviewer-cli.mjs` require the
34
+ repository root as the current directory; installed commands do not.
35
+ Ordinary `desktop` leaves watching OFF. Stop with
36
+ `desktop-stop`; restart without `--watch` to remove observation. No persistent
37
+ watch setting, host patch, hook, auto-start, account, model or new dependency is added.
38
+
39
+ ## What it reads
40
+
41
+ Only the fixed read-only command `stackbite agent seats --output json`, at most
42
+ once every five seconds after the previous read finishes. StackBite revalidates
43
+ the inherited caller Seat/generation. The source requires a live Room binding;
44
+ launching from an unrelated terminal is refused rather than guessing credentials.
45
+ No raw database, credential file, terminal transcript, screenshot, microphone,
46
+ clipboard, provider hook or browser session is read by Retrieviewer.
47
+
48
+ The current `seat_status` v1 projection supplies `needs_input`, generation,
49
+ turn/request identity and typed approval/question facts. It outranks legacy
50
+ fields, including when unknown/resolved. Only live, generation-matched Seats
51
+ with a positive turn ID or stable input request ID qualify. Older explicit
52
+ `awaiting_user`/`blocked` lifecycle facts require a positive turn ID. Generic input
53
+ is **not proof of a particular question**; unknown states are never inferred from
54
+ prose. Onboarding and questions attached to stopped Seats are excluded. Other
55
+ Rooms, exact sidebar item positions and pixel detection are not supported yet.
56
+ SSH: install/run the overlay where the visible desktop lives. A Room binding on
57
+ a remote server does not automatically give a local Windows pet remote access.
58
+
59
+ ## Behavior and limits
60
+
61
+ - A new exact Room/Seat/generation/turn/request cues one connected sit-and-look-left.
62
+ Left means the sidebar direction, not a detected button coordinate.
63
+ - Explicit ball/treat/tug interactions win. A rejected cue retries only while
64
+ its source fact is fresh. Distinct cues are at least 20 seconds apart.
65
+ - Resolved/disconnected/stale facts clear the cue. Source errors are unavailable,
66
+ not “zero pending.” A hidden/paused pet aborts observation and refreshes on resume.
67
+ - At most 32 pending items and 128 dedupe identities are retained, in memory only.
68
+ Excess/malformed snapshots fail closed. No unlimited history or recording.
69
+ - Each read has a 4-second timeout and 256 KiB output cap. No overlapping reads,
70
+ separate resident watcher, additional timer or shell command interpreter.
71
+ The CLI briefly runs a child process; it is not free in CPU/peak RAM.
72
+
73
+ The first Windows 1280x800 off-screen startup probes measured Node RSS of about
74
+ 106 MiB with watch off and 107 MiB after one live metadata read with watch on.
75
+ These are separate short runs, not a steady-state/peak comparison or a guarantee
76
+ of 1 MiB overhead. Native-helper and transient StackBite CLI memory are additional.
77
+ No new image payload, model or runtime package dependency was added.
78
+
79
+ Windows additionally displays a small count/offline label in the existing control
80
+ bar. Non-Windows helpers do not yet display that label; their live StackBite
81
+ integration remains unverified. Capture/overlay support is a separate platform
82
+ question. No new Wayland or AlmaLinux compatibility claim is made here.
83
+
84
+ ## Reuse decision
85
+
86
+ The first source already exists in StackBite, so a fixed-argv adapter is smaller
87
+ than importing an agent desktop app. No external source was copied in this patch.
88
+ Future capture permissions can follow
89
+ [AI-Desktop-Pet](https://github.com/AkshitIreddy/AI-Desktop-Pet); pointing/coordinate
90
+ mapping can draw from [Blepper](https://github.com/Ruthheasman/Blepper). Neither
91
+ capture nor a permissive model action parser belongs in this metadata-only slice.
92
+ Observer is AGPL and current Screenpipe is source-available, not an MIT dependency.
package/BRAIN.md ADDED
@@ -0,0 +1,115 @@
1
+ # Optional DeepSeek brain
2
+
3
+ The selected backend is OpenRouter's paid `deepseek/deepseek-v4.1-flash-1017`.
4
+ No local model, SDK, renderer, dependency, image asset, or permanent watcher is added.
5
+ The existing offline pet and rules remain the default.
6
+
7
+ ## Install and run
8
+
9
+ Update once: `npm install -g @bitefoundry/retrieviewer@latest`.
10
+ Then use the installed `retrieviewer` command from any directory; no checkout or
11
+ `cd` is required. Node's `node retrieviewer-cli.mjs` instead looks in the current
12
+ directory and fails with `MODULE_NOT_FOUND` outside the source repository.
13
+
14
+ Set `OPENROUTER_API_KEY` in the launching terminal's environment using your secret
15
+ manager or a hidden terminal prompt. Do not paste it in chat, command arguments,
16
+ source files, screenshots, `.env` files or the repository. Nothing saves the key.
17
+
18
+ ```sh
19
+ retrieviewer brain-check --json
20
+ retrieviewer desktop --watch stackbite --brain openrouter
21
+ ```
22
+
23
+ The first command sends one **synthetic** approval event, never a real Room,
24
+ and incurs API usage charges.
25
+ The second must run inside a live StackBite Seat on the display computer. Stop an
26
+ existing companion explicitly with `desktop-stop` first; it is never replaced
27
+ automatically. `desktop-status --json` exposes sanitized `brain` diagnostics and
28
+ `watch.lastJudgment`. Restart without `--brain` to disable the backend. No key is
29
+ required for help/status, offline mode, or metadata-only watching.
30
+
31
+ For source development only, first change to your Retrieviewer checkout directory
32
+ and substitute `node retrieviewer-cli.mjs` for `retrieviewer`. Do not copy that
33
+ relative source command into an unrelated directory. The installed command does
34
+ not require this machine-specific path.
35
+
36
+ ## What leaves the PC
37
+
38
+ Only fixed system instructions and this allowlisted anonymous state:
39
+
40
+ ```json
41
+ {"event":"pending_input","kind":"approval","pending_count":1}
42
+ ```
43
+
44
+ No Room/Seat/request identifiers, labels, task text, terminal output, screenshots,
45
+ clipboard or credentials appear in prompts. The key goes only in the Authorization
46
+ header to the fixed HTTPS OpenRouter URL, never to helper or StackBite child
47
+ processes. Redirects are refused. Provider data collection is denied; if that
48
+ policy leaves no eligible route, it falls back rather than weakening the policy.
49
+
50
+ ## Judgment and fallback
51
+
52
+ The model may return only `{"decision":"notify"}`, `ignore`, or `uncertain`.
53
+ JSON mode is not a correctness guarantee: code rejects extra keys, malformed/
54
+ truncated output, tool calls and responses over 32 KiB. No returned text is run,
55
+ typed, used as a coordinate, or displayed as a factual summary.
56
+
57
+ This first connection is **advisory on known pending-input facts**, not screen
58
+ understanding. A confirmed approval/question cannot be suppressed by an LLM:
59
+ `notify` follows the model; `ignore` is overridden by the rule; uncertain/errors
60
+ fall back. Diagnostics distinguish `model_notify`, `rule_override`, and
61
+ `rule_fallback`. With only kind/count facts, the model adds little semantic value;
62
+ this slice establishes a bounded backend before optional richer perception.
63
+
64
+ Each exact pending item is judged once while fresh. Resolution, generation
65
+ replacement, stale source, pause, hide and shutdown cancel its request; late
66
+ answers cannot resurrect a notice. Direct ball/treat/tug still wins. Animation
67
+ ticks never await HTTP. Network timeout is 3 seconds; a host-tick guard falls back
68
+ within 3.5 seconds of active time even if a custom judge hangs. `brain-check`
69
+ allows 8 seconds for diagnosis, not for normal pet reactions.
70
+
71
+ ## Resource and request limits
72
+
73
+ - One inference at a time. No automatic network retries or alternate-model fallback.
74
+ - Fixed paid model with provider price ceilings of $0.30 per million input tokens
75
+ and $1.20 per million output tokens, with per-request fees disallowed. These are
76
+ unit-price limits, not an account spending cap. Thinking disabled,
77
+ temperature 0, maximum 96 output tokens; no tools or plugins.
78
+ - Up to 16 anonymous successful judgments cached for 60 seconds. Same state can
79
+ reuse an answer without another request; source identity/freshness stays local.
80
+ - Default 40 HTTP attempts per UTC day **per process lifetime**. Restarts reset
81
+ this guard, and other apps share account limits; it is not a global quota meter.
82
+ - A 429 backs off for 60 seconds. Other request failures back off for 10 seconds.
83
+ No new calls while the pet is hidden/paused or the source has no fresh events.
84
+ - Requests use bounded transient buffers; the API adds network/HTTP runtime costs.
85
+ No claim of zero RAM overhead or guaranteed endpoint latency is made.
86
+
87
+ The catalog price on 2026-10-24 was $0.15 input / $0.60 output per million tokens,
88
+ with scheduled peak pricing at $0.30 / $1.20. Provider prices vary; the request
89
+ ceilings fail closed if no compatible route remains. Check the
90
+ [model and provider prices](https://openrouter.ai/deepseek/deepseek-v4.1-flash-1017/providers)
91
+ before budgeting. Even an aborted/timed-out request can incur provider usage.
92
+
93
+ Reference contracts: [OpenRouter parameters](https://openrouter.ai/docs/api-reference/parameters),
94
+ [provider routing](https://openrouter.ai/docs/guides/routing/provider-selection),
95
+ [reasoning](https://openrouter.ai/docs/guides/best-practices/reasoning-tokens).
96
+ The state/closed-choice design follows the TypeSafe skill's guidance, but no
97
+ TypeSafe service or SDK is installed; the user-selected OpenRouter backend is retained.
98
+
99
+ ## Verification — 2026-10-24
100
+
101
+ The user-selected paid DeepSeek endpoint returned a valid `notify` in 1,608 ms
102
+ through `brain-check`. A second real request through the owned Windows worker
103
+ returned `notify` in 1,113 ms, within the normal 3-second deadline. Both requests
104
+ used synthetic anonymous approval facts; no real approval was created or answered.
105
+
106
+ The native test used a disposable Room fixture and reached `model_notify`.
107
+ Ball interaction kept priority, the pet kept rendering, resolution cleared the
108
+ pending event, and worker/painter both exited. Node RSS was about 128 MiB in that
109
+ short run, excluding helper/CLI memory. This is not an incremental or steady-state
110
+ RAM measurement. Existing live companion state was not changed.
111
+
112
+ All 217 applicable tests passed (two macOS-only checks skipped), including mocked
113
+ JSON validation, price ceilings, timeout, cache, budget, cancellation and stale
114
+ results. The earlier free Gemma attempt returned 429 and never produced a verified
115
+ live decision; it is no longer the selected backend. Privacy restrictions remain.
package/INSTALLING.md CHANGED
@@ -99,6 +99,31 @@ Inside OpenCode, `/retrieviewer enable`, `/retrieviewer disable`,
99
99
  `/retrieviewer ball`, `/retrieviewer treat`, `/retrieviewer status` control the native
100
100
  pet. The adapter owns no raw keyboard or stdout; the host owns drawing and layout.
101
101
 
102
+ ## Recovering an interrupted settings change
103
+
104
+ Settings writes use an exclusive `settings.lock`. New locks record the owning
105
+ PID, hostname, start time and a random token. A busy error prints the exact lock
106
+ path and PID when known. An old or empty lock may have no owner information.
107
+ Locks are deliberately **not** deleted just because they are old or a PID probe
108
+ fails: a long operation, PID reuse or a shared directory can make that unsafe.
109
+
110
+ If an operation crashed:
111
+
112
+ 1. Stop all Retrieviewer enable/disable/disconnect commands and close all host
113
+ agents that can change its settings. If the directory is shared, stop writers
114
+ on every machine. Inspect the exact lock named in the error; an active owner
115
+ must finish first. Do not kill an unrelated process just because its PID matches.
116
+ 2. With **all writers stopped**, move only that `settings.lock` aside in the same
117
+ directory (for example to `settings.lock.recovered`). Keep it as a backup;
118
+ do not overwrite an existing backup, touch `settings.json`, remove the whole
119
+ directory, or follow a symlink. Then retry the original command. A fresh lock
120
+ is acquired normally and the existing settings are preserved.
121
+
122
+ Default directory: `%USERPROFILE%\.config\retrieviewer` on Windows,
123
+ `~/.config/retrieviewer` on macOS/Linux. `RETRIEVIEWER_HOME` overrides it, so use
124
+ the path in the error rather than assuming the default. This is an **offline
125
+ manual recovery**, not a command to remove a live lock or automatic stale reaping.
126
+
102
127
  ## Evidence, not a misleading green check
103
128
 
104
129
  Registration means the config contains our plugin URL. A runtime receipt means
package/MOTION-TABLES.md CHANGED
@@ -19,7 +19,9 @@ reject stale output after source metadata or witnessed art changes.
19
19
  gait-phase/velocity-match requirements. These cover walk entry/exit, rear run
20
20
  into the authored impact, impact into rear pickup, carry-gather into arrival
21
21
  seat, tug into the canonical seat, and four watch endpoints into that seat.
22
- - SHA256 of every input metadata/art file used by the bake. No source images,
22
+ - SHA256 of every input metadata/art file used by the bake. JSON text is hashed
23
+ with LF line endings; binary artwork is hashed byte-for-byte. Generated text
24
+ checks also accept CRLF checkouts. No source images,
23
25
  source hashes or graph search are computed by the running pet.
24
26
 
25
27
  The generated routes preserve the existing approved preparation order. This is
package/README.ko.md CHANGED
@@ -106,6 +106,22 @@ npm uninstall -g @bitefoundry/retrieviewer
106
106
  별도로 [OpenCode 네이티브 등록](INSTALLING.md)을 했다면 해당 등록을 disconnect한
107
107
  뒤 제거하세요. Codex/Claude/Cursor 등에 MCP만 연결해 내부 그림을 띄우는 기능은 없어요.
108
108
 
109
+ ## StackBite 알림
110
+
111
+ 0.0.14부터 [승인·입력 대기 알림](ATTENTION.md)을 선택해서 켤 수 있어요.
112
+ 화면 캡처나 자동 승인 없이 현재 Room의 메타데이터만 읽어요.
113
+ 업데이트 후, 화면이 보이는 PC의 실행 중인 StackBite Seat에서 실행하세요.
114
+
115
+ ```sh
116
+ retrieviewer desktop --watch stackbite --brain openrouter
117
+ ```
118
+
119
+ [유료 DeepSeek 연결](BRAIN.md)에는 같은 터미널의 `OPENROUTER_API_KEY`가 필요해요.
120
+ `--brain openrouter`를 빼면 API 없이 규칙 알림만 써요. 이미 실행 중이면 먼저
121
+ `retrieviewer desktop-stop`으로 종료하세요. 설치된 `retrieviewer` 명령은 어느
122
+ 폴더에서든 쓸 수 있어요. `node retrieviewer-cli.mjs`는 소스 폴더 안에서만 쓰는
123
+ 개발용 명령이에요.
124
+
109
125
  ## 직접 만든 TUI에 붙이기
110
126
 
111
127
  JS 플러그인과 JSONL 브리지를 제공해요. 화면·입력·시간 제어는 호스트가 소유합니다.
package/README.md CHANGED
@@ -130,6 +130,24 @@ The native OpenCode pane has Windows/OpenCode 2.1.2 host evidence; Codex, Claude
130
130
  Cursor, Grok and Antigravity **in-terminal** renderers are not implemented.
131
131
  An MCP connection alone cannot insert a persistent picture into those clients.
132
132
 
133
+ ## Optional StackBite alerts
134
+
135
+ Version 0.0.14 adds [metadata-only StackBite alerts](./ATTENTION.md).
136
+ It is opt-in and does not capture screens or operate other apps.
137
+ An [optional paid DeepSeek/OpenRouter backend](./BRAIN.md) can judge anonymous event metadata;
138
+ offline rules remain the default and preserve confirmed pending alerts.
139
+
140
+ After updating, run inside a live StackBite Seat on the display computer:
141
+
142
+ ```sh
143
+ retrieviewer desktop --watch stackbite --brain openrouter
144
+ ```
145
+
146
+ The paid backend requires `OPENROUTER_API_KEY` in that terminal's environment.
147
+ Omit `--brain openrouter` for rule-only alerts. Quit an existing companion first
148
+ with `retrieviewer desktop-stop`. These installed commands work from any folder;
149
+ `node retrieviewer-cli.mjs` is for developers running in the source checkout.
150
+
133
151
  ## Build, contribute, be kind
134
152
 
135
153
  ```sh
package/RELEASING.md CHANGED
@@ -1,4 +1,50 @@
1
- # Retrieviewer 0.0.12 release process
1
+ # Retrieviewer 0.0.14 release process
2
+
3
+ Current target: `@bitefoundry/retrieviewer@0.0.14`, public `latest`.
4
+ On 2026-10-24 the owner authorized npm publication of the metadata-only StackBite
5
+ watcher and optional paid DeepSeek V4.1 Flash 1017 backend. Existing artwork and
6
+ physics are unchanged. Watching and remote inference both remain opt-in.
7
+
8
+ The installed `retrieviewer` bin works from any directory; relative source
9
+ commands require the checkout root. Include this unrelated-working-directory
10
+ regression in the fresh installed-consumer check. Keep model identity, provider
11
+ price ceilings, privacy restrictions, daily per-process attempt guard, deadlines,
12
+ cache bounds, cancellation and rule fallback. No API keys ship or are saved.
13
+
14
+ Build/install a fresh exact artifact, compare every shipped byte, run tests and
15
+ an owned native worker check, then publish the same tarball. Verify registry
16
+ integrity and a fresh registry consumer. Earlier versions stay immutable; do not
17
+ restart the user's pet, patch the host, or certify untested macOS/Linux behavior.
18
+ No new runtime dependency or local model is added. Website deployment is outside
19
+ this release request.
20
+
21
+ ## Previous 0.0.13 scope (historical)
22
+
23
+ Current target: `@bitefoundry/retrieviewer@0.0.13`, public `latest`.
24
+ The owner authorized npm publication and website deployment on2026-10-24.
25
+ This release contains the reviewed reliability fixes, with unchanged art/motion:
26
+
27
+ - Canonical LF text/hash checks and CRLF regression coverage; binary art stays
28
+ byte-for-byte unchanged. Git attributes and existing JSON text use LF.
29
+ - Exclusive settings locks record the owner PID/host/time/token. Busy errors
30
+ identify the lock and link to the offline manual recovery in INSTALLING.md.
31
+ There is no automatic removal of possibly live locks.
32
+ - Workers require ready plus an acknowledged detach to survive launcher loss.
33
+ Startup disconnect/launcher-death tests cover worker, painter and session cleanup.
34
+ - Oversized Windows grids hide until they fit again; plugin timer delays cannot
35
+ overflow Node's signed32-bit timeout bound into a1ms loop.
36
+ - Windows20 / Linux22 Node CI on push and PR, including installed-consumer checks
37
+ and an owned Windows native diagnostic. Website copy discloses page visit and
38
+ performance analytics separately from the desktop pet.
39
+
40
+ Build and install a fresh exact tarball outside the source tree, compare installed
41
+ bytes and test its native/browser runtimes. Publish that same artifact after the
42
+ new remote CI succeeds; verify registry integrity and matching Pages runtime.
43
+ No new runtime dependency, process, background timer or art cache. No claim of
44
+ new macOS/Linux GUI certification or a material RAM reduction. Preserve all
45
+ earlier npm artifacts, MIT code / CC BY-SA4.0 art and the user's running pet.
46
+
47
+ ## Previous 0.0.12 scope (historical)
2
48
 
3
49
  Current target: `@bitefoundry/retrieviewer@0.0.12`, public `latest`.
4
50
  On2026-10-24 the owner authorized npm publication and website deployment of the
@@ -0,0 +1,22 @@
1
+ export const OPENROUTER_MODEL: 'deepseek/deepseek-v4.1-flash-1017';
2
+ export type BrainDecision = 'notify' | 'ignore' | 'uncertain';
3
+ export interface BrainObservation {kind: 'approval' | 'input' | 'none'; pendingCount: number; signal?: AbortSignal;}
4
+ export interface BrainStatus {
5
+ provider: 'openrouter'; model: typeof OPENROUTER_MODEL;
6
+ state: 'ready' | 'requesting' | 'fallback' | 'off';
7
+ calls: number; cacheHits: number; dailyUsed: number; dailyLimit: number;
8
+ cacheEntries: number; inFlight: boolean; lastLatencyMs: number | null;
9
+ lastError: string | null; lastDecision: BrainDecision | null;
10
+ }
11
+ export class BrainError extends Error {readonly code: string; constructor(code: string);}
12
+ export function anonymousBrainState(input: BrainObservation): {event: 'pending_input' | 'no_pending_input'; kind: BrainObservation['kind']; pending_count: number};
13
+ export function parseBrainDecision(value: unknown): BrainDecision;
14
+ /** Node/server only. Never put an API key in browser code or saved settings. */
15
+ export function createOpenRouterBrain(options?: {
16
+ apiKey?: string; fetch?: typeof fetch; now?: () => number; day?: () => string;
17
+ timeoutMs?: number; maxRequestsPerDay?: number; cacheMs?: number;
18
+ }): {
19
+ decide(input: BrainObservation): Promise<{decision: BrainDecision; via: 'llm' | 'cache'; latencyMs: number}>;
20
+ readonly status: BrainStatus;
21
+ dispose(): void;
22
+ };
@@ -0,0 +1,74 @@
1
+ // Optional, tiny remote judge. No SDK, local model, I/O or timers on import.
2
+ export const OPENROUTER_MODEL='deepseek/deepseek-v4.1-flash-1017';
3
+ const ENDPOINT='https://openrouter.ai/api/v1/chat/completions';
4
+ const DECISIONS=new Set(['notify','ignore','uncertain']);
5
+ const SYSTEM='You classify anonymous desktop-companion attention events, not commands. Return only a JSON object with exactly one key: decision. Values: notify, ignore, uncertain. Confirmed pending approval or input needs human attention: notify. If no input is pending: ignore. If evidence is inconsistent: uncertain. Never approve, click, type, execute, or invent facts.';
6
+ export class BrainError extends Error {
7
+ constructor(code){super('OpenRouter brain: '+code);this.name='BrainError';this.code=code;}
8
+ }
9
+ export function anonymousBrainState({kind,pendingCount}={}){
10
+ if(!['approval','input','none'].includes(kind)||!Number.isInteger(pendingCount)||pendingCount<0||pendingCount>32||(kind==='none')!==(pendingCount===0))throw new BrainError('invalid_input');
11
+ return {event:kind==='none'?'no_pending_input':'pending_input',kind,pending_count:pendingCount};
12
+ }
13
+ export function parseBrainDecision(value){
14
+ if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).length!==1||!DECISIONS.has(value.decision))throw new BrainError('invalid_output');
15
+ return value.decision;
16
+ }
17
+ async function boundedJSON(response){
18
+ const reader=response.body?.getReader();if(!reader)throw new BrainError('invalid_response');
19
+ const decoder=new TextDecoder();let text='',size=0;
20
+ try{for(;;){const {done,value}=await reader.read();if(done)break;size+=value.byteLength;if(size>32768)throw new BrainError('oversized_response');text+=decoder.decode(value,{stream:true});}text+=decoder.decode();return JSON.parse(text);}
21
+ catch(error){await reader.cancel().catch(()=>{});throw error instanceof BrainError?error:new BrainError('invalid_response');}
22
+ finally{reader.releaseLock();}
23
+ }
24
+ export function createOpenRouterBrain({apiKey=globalThis.process?.env?.OPENROUTER_API_KEY,fetch:request=globalThis.fetch,now=()=>performance.now(),day=()=>new Date().toISOString().slice(0,10),timeoutMs=3000,maxRequestsPerDay=40,cacheMs=60000}={}){
25
+ if(typeof apiKey!=='string'||apiKey.length<10||apiKey.length>512||/\s/.test(apiKey))throw new BrainError('missing_key');
26
+ if(typeof request!=='function'||typeof now!=='function'||typeof day!=='function')throw new BrainError('invalid_options');
27
+ if(!Number.isFinite(timeoutMs)||timeoutMs<100||timeoutMs>15000||!Number.isInteger(maxRequestsPerDay)||maxRequestsPerDay<1||maxRequestsPerDay>1000||!Number.isFinite(cacheMs)||cacheMs<0||cacheMs>300000)throw new BrainError('invalid_options');
28
+ const cache=new Map();let disposed=false,flight=null,dayKey=day(),used=0,calls=0,cacheHits=0,nextAllowed=0,lastLatencyMs=null,lastError=null,lastDecision=null,state='ready';
29
+ return {
30
+ async decide(input){
31
+ if(disposed)throw new BrainError('disposed');
32
+ const facts=anonymousBrainState(input),signal=input?.signal,key=JSON.stringify(facts),start=now();
33
+ if(signal?.aborted)throw new BrainError('aborted');
34
+ const stored=cache.get(key);if(stored&&start<stored.expires){cacheHits++;lastDecision=stored.decision;lastError=null;state='ready';return {decision:stored.decision,via:'cache',latencyMs:0};}
35
+ if(flight)throw new BrainError('busy');
36
+ if(start<nextAllowed)throw new BrainError('backoff');
37
+ const today=day();if(today!==dayKey){dayKey=today;used=0;}
38
+ if(used>=maxRequestsPerDay){state='fallback';lastError='daily_budget';throw new BrainError('daily_budget');}
39
+ const operation={controller:new AbortController()};flight=operation;used++;calls++;state='requesting';
40
+ let timer,rejectAbort;
41
+ const interrupted=new Promise((_,reject)=>{rejectAbort=reject;});
42
+ const abort=()=>{rejectAbort(new BrainError('aborted'));operation.controller.abort();};
43
+ signal?.addEventListener('abort',abort,{once:true});
44
+ operation.abort=abort;
45
+ timer=setTimeout(()=>{rejectAbort(new BrainError('timeout'));operation.controller.abort();},timeoutMs);
46
+ try{
47
+ const result=await Promise.race([(async()=>{
48
+ const response=await request(ENDPOINT,{
49
+ method:'POST',redirect:'error',signal:operation.controller.signal,
50
+ headers:{'Authorization':'Bearer '+apiKey,'Content-Type':'application/json'},
51
+ // USD per million tokens; no per-request fee or alternate-model fallback.
52
+ body:JSON.stringify({model:OPENROUTER_MODEL,messages:[{role:'system',content:SYSTEM},{role:'user',content:key}],temperature:0,max_tokens:96,reasoning:{enabled:false},response_format:{type:'json_object'},provider:{require_parameters:true,data_collection:'deny',max_price:{prompt:0.3,completion:1.2,request:0}}}),
53
+ });
54
+ if(!response.ok){await response.body?.cancel().catch(()=>{});throw new BrainError(response.status===429?'rate_limited':[401,403].includes(response.status)?'authorization_failed':response.status===404?'route_unavailable':'service_unavailable');}
55
+ const body=await boundedJSON(response),choice=body.choices?.[0];
56
+ if(choice?.finish_reason!=='stop'||typeof choice.message?.content!=='string'||choice.message.tool_calls?.length)throw new BrainError('invalid_output');
57
+ let value;try{value=JSON.parse(choice.message.content);}catch{throw new BrainError('invalid_output');}
58
+ return {decision:parseBrainDecision(value),via:'llm',latencyMs:Math.max(0,Math.round(now()-start))};
59
+ })(),interrupted]);
60
+ if(disposed||signal?.aborted||flight!==operation)throw new BrainError('aborted');
61
+ lastLatencyMs=result.latencyMs;lastError=null;lastDecision=result.decision;state='ready';
62
+ cache.delete(key);cache.set(key,{decision:result.decision,expires:now()+cacheMs});
63
+ while(cache.size>16)cache.delete(cache.keys().next().value);
64
+ return result;
65
+ }catch(error){
66
+ const safe=error instanceof BrainError?error:new BrainError('network_error');
67
+ if(!disposed){lastError=safe.code;lastLatencyMs=Math.max(0,Math.round(now()-start));state='fallback';if(safe.code!=='aborted')nextAllowed=now()+(safe.code==='rate_limited'?60000:10000);}
68
+ throw safe;
69
+ }finally{clearTimeout(timer);signal?.removeEventListener('abort',abort);if(flight===operation)flight=null;}
70
+ },
71
+ get status(){return {provider:'openrouter',model:OPENROUTER_MODEL,state,calls,cacheHits,dailyUsed:day()===dayKey?used:0,dailyLimit:maxRequestsPerDay,cacheEntries:cache.size,inFlight:flight!==null,lastLatencyMs,lastError,lastDecision};},
72
+ dispose(){if(disposed)return;disposed=true;flight?.abort();flight=null;cache.clear();apiKey='';state='off';},
73
+ };
74
+ }
@@ -0,0 +1,47 @@
1
+ // Narrow projection of `stackbite agent seats --output json`, not terminal text.
2
+ // Uses StackBite's own binding validation; never reads its credential files.
3
+ import {execFile} from 'node:child_process';
4
+ import {promisify} from 'node:util';
5
+ import {validateAttentionSnapshot} from '../../retrieviewer-attention.mjs';
6
+ const execute=promisify(execFile);
7
+ const identifier=value=>typeof value==='string'&&/^[a-zA-Z0-9_.:-]{1,128}$/.test(value);
8
+
9
+ export function stackBiteBinding(env=process.env){
10
+ if(!identifier(env.STACKBITE_ROOM_ID)||!identifier(env.STACKBITE_SEAT_KEY)||!identifier(env.STACKBITE_SESSION_GENERATION)||!env.STACKBITE_STATE_DIR||!env.STACKBITE_ROOM_BRIDGE_URL)throw Error('Start desktop --watch stackbite inside a live StackBite Seat. No Room binding was found.');
11
+ return {roomId:env.STACKBITE_ROOM_ID};
12
+ }
13
+ export function projectStackBiteSeats(value,{roomId}={}){
14
+ if(!identifier(roomId)||value?.room_id!==roomId||!Array.isArray(value.seats)||value.seats.length>256)throw Error('Invalid or mismatched StackBite Room snapshot');
15
+ const items=[];
16
+ for(const seat of value.seats){
17
+ if(!seat||typeof seat!=='object')throw Error('Invalid StackBite Seat');
18
+ if(seat.is_alive!==true||!identifier(seat.seat_key)||!identifier(seat.generation))continue;
19
+ const status=seat.seat_status;
20
+ if(status!==undefined){
21
+ // The current public projection outranks legacy fields. Never revive an
22
+ // unknown/resolved/foreign projection from a stale legacy flag.
23
+ if(status?.schema_version!==1||status.generation!==seat.generation||status.phase!=='needs_input'||status.input_kind==='onboarding')continue;
24
+ const turnId=Number.isSafeInteger(status.turn_id)&&status.turn_id>0?status.turn_id:null;
25
+ const requestId=identifier(status.input_request_id)?status.input_request_id:undefined;
26
+ if(turnId===null&&requestId===undefined)continue;
27
+ items.push({id:seat.seat_key,generation:seat.generation,turnId,...requestId===undefined?{}:{requestId},kind:status.input_kind==='approval'?'approval':'input'});
28
+ }else{
29
+ // Older explicit lifecycle facts are accepted only with an exact turn.
30
+ if(!['awaiting_user','blocked'].includes(seat.lifecycle_state)||!Number.isSafeInteger(seat.turn_id)||seat.turn_id<1)continue;
31
+ items.push({id:seat.seat_key,generation:seat.generation,turnId:seat.turn_id,kind:seat.provider_permission_pending===true||seat.input_kind==='approval'?'approval':'input'});
32
+ }
33
+ }
34
+ return validateAttentionSnapshot({source:'stackbite',scope:roomId,items});
35
+ }
36
+ export function createStackBiteSource({env=process.env,exec=execute}={}){
37
+ const binding=stackBiteBinding(env);
38
+ const childEnv={...env};delete childEnv.OPENROUTER_API_KEY;
39
+ return {
40
+ async readSnapshot({signal}={}){
41
+ // Fixed argv, no shell, no input/click/report/approval command. The CLI checks
42
+ // that the inherited caller Seat/generation is still live on every read.
43
+ const {stdout}=await exec('stackbite',['agent','seats','--output','json'],{env:childEnv,signal,encoding:'utf8',windowsHide:true,timeout:4000,maxBuffer:262144});
44
+ return projectStackBiteSeats(JSON.parse(stdout),binding);
45
+ },
46
+ };
47
+ }
@@ -110,7 +110,7 @@ sealed class Companion : ApplicationContext {
110
110
  readonly JavaScriptSerializer json=new JavaScriptSerializer();readonly object inputLock=new object();
111
111
  readonly Queue<Dictionary<string,object>> messages=new Queue<Dictionary<string,object>>();
112
112
  TargetItem target;Form pickerWindow;QuietWindow testTarget;readonly bool testMode;Rectangle canvas;int revision=0,cell=8;bool visible=false,paused=false,loaded=false,quitting=false,picking=false,announced=false;
113
- Label status=new Label();Mutex mutex;
113
+ Label status=new Label();Mutex mutex;string attentionText=null;
114
114
  internal Companion(Mutex single,bool diagnostic){
115
115
  mutex=single;testMode=diagnostic;bar.Text="Retrieviewer controls";bar.Size=new Size(538,40);bar.BackColor=Color.FromArgb(28,30,34);bar.ForeColor=Color.White;
116
116
  bar.Font=new Font("Segoe UI",9);bar.Controls.Add(MakeButton("Ball",8,()=>Interact("ball")));bar.Controls.Add(MakeButton("Treat",76,()=>Interact("treat")));
@@ -151,7 +151,13 @@ sealed class Companion : ApplicationContext {
151
151
  string type=Program.Str(m,"type");
152
152
  if(type=="quit"){Quit();return;}
153
153
  if(type=="loaded"){string ink=m.ContainsKey("ink")?Program.Str(m,"ink"):"cream";if(ink!="cream"&&ink!="gold"&&ink!="dark")throw new Exception("Invalid ink");pet.Ink=ink=="gold"?Color.FromArgb(245,231,184,103):ink=="dark"?Color.FromArgb(245,67,48,24):Color.FromArgb(245,248,242,232);loaded=true;status.Text="Playing";if(testMode)((Button)bar.Controls[0]).PerformClick();continue;}
154
- if(type=="interaction"){status.Text=Program.Bool(m,"accepted")?"Ready!":"Please wait";continue;}
154
+ if(type=="interaction"){status.Text=attentionText??(Program.Bool(m,"accepted")?"Ready!":"Please wait");continue;}
155
+ if(type=="attention"){
156
+ int count=Program.Int(m,"count");string state=Program.Str(m,"state");
157
+ if(count<0||count>32)throw new Exception("Invalid attention count");
158
+ attentionText=state=="connected"?(count>0?count+" need input":"Watching"):state=="paused"?"Watch paused":state=="connecting"?"Connecting...":"Watch offline";status.Text=attentionText;
159
+ continue;
160
+ }
155
161
  if(type=="error"){status.Text="Engine error";Quit();return;}
156
162
  if(type=="frame"){
157
163
  // Always acknowledge discarded frames too, otherwise resizing can deadlock the sender.
@@ -168,8 +174,10 @@ sealed class Companion : ApplicationContext {
168
174
  if(target!=null&&!Native.IsIconic(target.Window)&&Native.IsWindowVisible(target.Window)){
169
175
  next=Native.ClientBounds(target.Window);IntPtr foreground=Native.GetForegroundWindow();show=!picking&&(testMode||foreground==target.Window||foreground==bar.Handle)&&next.Width>=160&&next.Height>=120;
170
176
  }
171
- int nextCell=8;while(next.Width/nextCell*(next.Height/(nextCell*2))>125000&&nextCell<16)nextCell+=2;
172
- if(nextCell==14)nextCell=16;
177
+ int nextCell=Program.GridCell(next.Width,next.Height);
178
+ // A huge target may exceed the transport budget even at the largest cell.
179
+ // Hide until its size fits again; never send an invalid visible grid.
180
+ if(nextCell==0){show=false;nextCell=16;}
173
181
  int columns=next.Width/nextCell,rows=next.Height/(nextCell*2);
174
182
  if(force||show!=visible||next!=canvas||nextCell!=cell){
175
183
  visible=show;canvas=next;cell=nextCell;revision++;pet.Hide();
@@ -237,6 +245,13 @@ static class Program {
237
245
  using(Companion context=new Companion(single,ipcTest))Application.Run(context);return 0;
238
246
  }catch(Exception error){Emit(new {type="error",message=error.Message});return 1;}
239
247
  }
248
+ internal static int GridCell(int width,int height){
249
+ foreach(int size in new int[]{8,10,12,16}){
250
+ long columns=(long)width/size,rows=(long)height/(size*2);
251
+ if(columns>0&&rows>0&&columns*rows<=125000)return size;
252
+ }
253
+ return 0;
254
+ }
240
255
  static int SelfTest(string proof){
241
256
  // Uses only our own off-screen windows. Does NOT click/type in or capture a user application.
242
257
  IntPtr foreground=Native.GetForegroundWindow();
@@ -253,8 +268,9 @@ static class Program {
253
268
  Native.ShowWindow(target.Handle,7);Application.DoEvents();bool minimized=Native.IsIconic(target.Handle);pet.Hide();bool hidden=!pet.Visible;
254
269
  Native.ShowWindow(target.Handle,4);Application.DoEvents();bool restored=!Native.IsIconic(target.Handle);
255
270
  bool focus=Native.GetForegroundWindow()==foreground;
256
- bool passed=clickThrough&&noActivate&&layered&&follows&&minimized&&hidden&&restored&&focus&&pet.PaintedDots>0;
257
- Emit(new {type="self-test",passed=passed,clickThroughStyle=clickThrough,noActivateStyle=noActivate,layered=layered,followGeometry=follows,expectedX=expectedX,expectedY=expectedY,actualX=actual.Left,actualY=actual.Top,minimize=minimized,hide=hidden,restore=restored,foregroundPreserved=focus,dots=pet.PaintedDots,proof=proof});return passed?0:1;
271
+ bool gridBounds=GridCell(3840,2160)==8&&GridCell(7680,4320)==12&&GridCell(32000,18000)==0&&GridCell(int.MaxValue,int.MaxValue)==0&&GridCell(0,0)==0;
272
+ bool passed=clickThrough&&noActivate&&layered&&follows&&minimized&&hidden&&restored&&focus&&pet.PaintedDots>0&&gridBounds;
273
+ Emit(new {type="self-test",passed=passed,gridBounds=gridBounds,clickThroughStyle=clickThrough,noActivateStyle=noActivate,layered=layered,followGeometry=follows,expectedX=expectedX,expectedY=expectedY,actualX=actual.Left,actualY=actual.Top,minimize=minimized,hide=hidden,restore=restored,foregroundPreserved=focus,dots=pet.PaintedDots,proof=proof});return passed?0:1;
258
274
  }
259
275
  }
260
276
  }
@@ -93,18 +93,33 @@ export async function desktopHelper(options={}){
93
93
  throw Error('Linux desktop needs system Python3.6+ (AlmaLinux8 platform-python is supported). No packages or system settings were installed.');
94
94
  }
95
95
  export async function startDesktop(options={}){
96
+ const watch=options.watch??'off';if(!['off','stackbite'].includes(watch))throw Error('Invalid desktop watch source');
97
+ const brain=options.brain??'off';if(!['off','openrouter'].includes(brain))throw Error('Invalid desktop brain');
98
+ if(brain==='openrouter'){
99
+ if(watch!=='stackbite')throw Error('--brain openrouter requires --watch stackbite');
100
+ const {createOpenRouterBrain}=await import('../openrouter/brain.mjs');createOpenRouterBrain().dispose();
101
+ }
102
+ if(watch==='stackbite'){const {stackBiteBinding}=await import('../stackbite/attention.mjs');stackBiteBinding();}
96
103
  const ink=options.ink??'cream';if(!['cream','gold','dark'].includes(ink))throw Error('Invalid desktop ink');
97
104
  const returnStyle=options.returnStyle??'auto';if(!['auto','brake','trot','run-trot'].includes(returnStyle))throw Error('Invalid return style');
98
105
  // Fail before creating any cache/session on unsupported desktops (e.g. Wayland).
99
106
  desktopPlatform(options);
100
- const status=await desktopStatus(options);if(status.running)return {...status,alreadyRunning:true,...status.returnStyle!==returnStyle?{requestedReturnStyle:returnStyle,restartRequired:true}:{}};
107
+ const status=await desktopStatus(options);if(status.running)return {...status,alreadyRunning:true,...status.returnStyle!==returnStyle?{requestedReturnStyle:returnStyle,restartRequired:true}:{},...(status.watch?.source??'off')!==watch?{requestedWatch:watch,restartRequired:true}:{},...(status.brain?.provider??'off')!==brain?{requestedBrain:brain,restartRequired:true}:{}};
101
108
  if(status.stale)await stopDesktop(options);
102
109
  const helper=await desktopHelper(options);
110
+ const workerEnv={...process.env,RETRIEVIEWER_HOME:getConfigPaths(options).root,RETRIEVIEWER_INK:ink,RETRIEVIEWER_RETURN_STYLE:returnStyle,RETRIEVIEWER_WATCH:watch,RETRIEVIEWER_BRAIN:brain};
111
+ if(brain==='off')delete workerEnv.OPENROUTER_API_KEY;
103
112
  return new Promise((resolve,reject)=>{
104
- const child=spawn(process.execPath,[fileURLToPath(new URL('./worker.mjs',import.meta.url)),'--helper-spec',JSON.stringify(helper)],{detached:true,windowsHide:true,stdio:['ignore','ignore','ignore','ipc'],env:{...process.env,RETRIEVIEWER_HOME:getConfigPaths(options).root,RETRIEVIEWER_INK:ink,RETRIEVIEWER_RETURN_STYLE:returnStyle}});
105
- let settled=false;const finish=(error,message)=>{if(settled)return;settled=true;clearTimeout(timer);if(child.connected)child.disconnect();child.unref();error?reject(error):resolve(message);};
106
- const timer=setTimeout(()=>{if(child.connected)child.send({command:'abort'});finish(Error('Desktop startup timed out; no agent settings changed'));},20000);
107
- child.on('error',error=>finish(error));child.on('message',message=>message.error?finish(Error(message.error)):finish(null,message));child.on('exit',code=>finish(Error('Desktop exited during startup ('+code+')')));
113
+ const child=spawn(process.execPath,[fileURLToPath(new URL('./worker.mjs',import.meta.url)),'--helper-spec',JSON.stringify(helper)],{detached:true,windowsHide:true,stdio:['ignore','ignore','ignore','ipc'],env:workerEnv});
114
+ let settled=false,ready=null;const finish=(error,message)=>{if(settled)return;settled=true;clearTimeout(timer);if(child.connected)child.disconnect();child.unref();error?reject(error):resolve(message);};
115
+ // Disconnect on failure aborts startup. Only an acknowledged detach may live on.
116
+ const timer=setTimeout(()=>finish(Error('Desktop startup timed out; no agent settings changed')),20000);
117
+ child.on('error',error=>finish(error));child.on('message',message=>{
118
+ if(settled)return;
119
+ if(message?.error)return finish(Error(message.error));
120
+ if(message?.detached===true&&ready)return finish(null,ready);
121
+ if(message?.running===true&&!ready){ready=message;child.send({command:'detach'},error=>{if(error)finish(error);});}
122
+ });child.on('disconnect',()=>{if(!settled)finish(Error('Desktop disconnected during startup'));});child.on('exit',code=>finish(Error('Desktop exited during startup ('+code+')')));
108
123
  });
109
124
  }
110
125
  export async function cleanDesktop(options={}){
@@ -42,6 +42,8 @@ export function createFramePump({pet,send,now=()=>performance.now()}){
42
42
  try{send(frame);lastFrame=frame;}catch(error){inFlight=false;lastFrame=null;nextPaint=0;throw error;}return true;
43
43
  },
44
44
  interact(kind){alive();if(!['ball','treat','tug'].includes(kind))throw Error('Invalid overlay interaction');const accepted=pet.interact(kind);if(accepted){lastFrame=null;nextPaint=0;}return accepted;},
45
+ notify(notice){alive();const accepted=pet.notify(notice);if(accepted){lastFrame=null;nextPaint=0;}return accepted;},
46
+ clearNotice(){alive();pet.clearNotice();lastFrame=null;nextPaint=0;},
45
47
  get hidden(){return !viewport||paused;},
46
48
  dispose(){if(!disposed){disposed=true;viewport=lastFrame=null;pet.dispose();}},
47
49
  };