@bitefoundry/retrieviewer 0.0.13 → 0.0.15

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,122 @@
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 attention episode:
62
+ brake/recover and face the user, approach using the existing world path if more
63
+ than 0.8 m from the near check-in point, then sit and look left. Near pets stay
64
+ in place. Resolution during approach brakes the obsolete journey naturally.
65
+ Left means the sidebar direction, not a detected button coordinate.
66
+ - The rule cue is accepted before optional inference starts. LLM latency, failure
67
+ or disagreement never delays, repeats or retracts a confirmed pending alert.
68
+ This does not remove the existing five-second source polling interval.
69
+ - Explicit ball/treat/tug interactions win. A rejected cue retries only while
70
+ its source fact is fresh. Distinct cues are at least 20 seconds apart.
71
+ - Resolved/disconnected/stale facts clear the cue. Source errors are unavailable,
72
+ not “zero pending.” A hidden/paused pet aborts observation and refreshes on resume.
73
+ - At most 32 pending items and 128 dedupe identities are retained, in memory only.
74
+ Excess/malformed snapshots fail closed. No unlimited history or recording.
75
+ - Each read has a 4-second timeout and 256 KiB output cap. No overlapping reads,
76
+ separate resident watcher, additional timer or shell command interpreter.
77
+ The CLI briefly runs a child process; it is not free in CPU/peak RAM.
78
+
79
+ The first Windows 1280x800 off-screen startup probes measured Node RSS of about
80
+ 106 MiB with watch off and 107 MiB after one live metadata read with watch on.
81
+ These are separate short runs, not a steady-state/peak comparison or a guarantee
82
+ of 1 MiB overhead. Native-helper and transient StackBite CLI memory are additional.
83
+ No new image payload, model or runtime package dependency was added.
84
+
85
+ Windows displays the pending Seat's bounded `role_title` and approval/input kind
86
+ in the existing control bar, with ellipsis and a full-text hover tooltip. The
87
+ local `watch.target` and `attentionLabel` diagnostics expose that same target.
88
+ Names are at most 64 UTF-16 code units, control/directional characters are removed,
89
+ and no task text is retained. Labels never enter model input or saved settings.
90
+ Renaming/replacing a target refreshes the label even if the count is unchanged;
91
+ renaming alone is not a new alert. Missing names use the Seat ID as a fallback.
92
+ Non-Windows helpers do not yet display that label; their live StackBite
93
+ integration remains unverified. Capture/overlay support is a separate platform
94
+ question. No new Wayland or AlmaLinux compatibility claim is made here.
95
+
96
+ Version 0.0.15 includes these rule-first/target/approach improvements after the
97
+ 0.0.14 baseline. A simulated frozen
98
+ LLM and a time-skipped distant notification are available in the local preview.
99
+ They never write StackBite state or make model requests. Real provider events
100
+ still depend on StackBite publishing trustworthy `needs_input` metadata.
101
+
102
+ Working-copy verification on 2026-10-24: focused tests cover immediate delivery
103
+ with hung/disagreeing advice, local-only bounded labels, same-count renaming,
104
+ late-result cancellation, far-to-near motion, and resolution during approach.
105
+ An owned Windows worker/native-helper run displayed and renamed the actual label,
106
+ cleared it to `Watching`, preserved direct ball priority and exited both children.
107
+ The fixture run used about 112 MiB Node RSS (no LLM), excluding the helper/CLI.
108
+ Matched short watch-off native startup probes of published 0.0.14 and the working
109
+ copy both rounded to 106 MiB Node RSS. These are not steady-state or peak-memory
110
+ guarantees. All nine packaged art archives are byte-identical; no dependency,
111
+ additional resident process or observer timer was added. Labels are bounded
112
+ metadata, not a promise of literally zero additional bytes or allocations.
113
+
114
+ ## Reuse decision
115
+
116
+ The first source already exists in StackBite, so a fixed-argv adapter is smaller
117
+ than importing an agent desktop app. No external source was copied in this patch.
118
+ Future capture permissions can follow
119
+ [AI-Desktop-Pet](https://github.com/AkshitIreddy/AI-Desktop-Pet); pointing/coordinate
120
+ mapping can draw from [Blepper](https://github.com/Ruthheasman/Blepper). Neither
121
+ capture nor a permissive model action parser belongs in this metadata-only slice.
122
+ Observer is AGPL and current Screenpipe is source-available, not an MIT dependency.
package/BRAIN.md ADDED
@@ -0,0 +1,125 @@
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. Since 0.0.15, a confirmed approval/question is
59
+ delivered by the rule first; optional model work starts only after the pet accepts
60
+ the cue. It cannot delay, suppress, repeat or retract the cue. Diagnostics show
61
+ `rule_first` while advice is pending, then `model_notify` for agreement,
62
+ `rule_override` for disagreement, or `rule_fallback` for uncertain/errors.
63
+ The 0.0.14 baseline waited for advice. With only kind/count facts, the model adds little semantic value;
64
+ this slice establishes a bounded backend before optional richer perception.
65
+
66
+ Each accepted exact pending item is judged once while fresh; rejected cues during
67
+ direct play do not spend requests. Seat display names stay local. Resolution, generation
68
+ replacement, stale source, pause, hide and shutdown cancel its request; late
69
+ answers cannot resurrect a notice. Direct ball/treat/tug still wins. Animation
70
+ ticks never await HTTP. Network timeout is 3 seconds; a host-tick guard discards
71
+ hung advice within 3.5 seconds of active time, without delaying the already-issued
72
+ rule alert. `brain-check`
73
+ allows 8 seconds for diagnosis, not for normal pet reactions.
74
+
75
+ ## Resource and request limits
76
+
77
+ - One inference at a time. No automatic network retries or alternate-model fallback.
78
+ - Fixed paid model with provider price ceilings of $0.30 per million input tokens
79
+ and $1.20 per million output tokens, with per-request fees disallowed. These are
80
+ unit-price limits, not an account spending cap. Thinking disabled,
81
+ temperature 0, maximum 96 output tokens; no tools or plugins.
82
+ - Up to 16 anonymous successful judgments cached for 60 seconds. Same state can
83
+ reuse an answer without another request; source identity/freshness stays local.
84
+ - Default 40 HTTP attempts per UTC day **per process lifetime**. Restarts reset
85
+ this guard, and other apps share account limits; it is not a global quota meter.
86
+ - A 429 backs off for 60 seconds. Other request failures back off for 10 seconds.
87
+ No new calls while the pet is hidden/paused or the source has no fresh events.
88
+ - Requests use bounded transient buffers; the API adds network/HTTP runtime costs.
89
+ No claim of zero RAM overhead or guaranteed endpoint latency is made.
90
+
91
+ The catalog price on 2026-10-24 was $0.15 input / $0.60 output per million tokens,
92
+ with scheduled peak pricing at $0.30 / $1.20. Provider prices vary; the request
93
+ ceilings fail closed if no compatible route remains. Check the
94
+ [model and provider prices](https://openrouter.ai/deepseek/deepseek-v4.1-flash-1017/providers)
95
+ before budgeting. Even an aborted/timed-out request can incur provider usage.
96
+
97
+ Reference contracts: [OpenRouter parameters](https://openrouter.ai/docs/api-reference/parameters),
98
+ [provider routing](https://openrouter.ai/docs/guides/routing/provider-selection),
99
+ [reasoning](https://openrouter.ai/docs/guides/best-practices/reasoning-tokens).
100
+ The state/closed-choice design follows the TypeSafe skill's guidance, but no
101
+ TypeSafe service or SDK is installed; the user-selected OpenRouter backend is retained.
102
+
103
+ ## Verification — 2026-10-24
104
+
105
+ The user-selected paid DeepSeek endpoint returned a valid `notify` in 1,608 ms
106
+ through `brain-check`. A second real request through the owned Windows worker
107
+ returned `notify` in 1,113 ms, within the normal 3-second deadline. Both requests
108
+ used synthetic anonymous approval facts; no real approval was created or answered.
109
+
110
+ The native test used a disposable Room fixture and reached `model_notify`.
111
+ Ball interaction kept priority, the pet kept rendering, resolution cleared the
112
+ pending event, and worker/painter both exited. Node RSS was about 128 MiB in that
113
+ short run, excluding helper/CLI memory. This is not an incremental or steady-state
114
+ RAM measurement. Existing live companion state was not changed.
115
+
116
+ For the 0.0.14 baseline, all 217 applicable tests passed (two macOS-only checks skipped), including mocked
117
+ JSON validation, price ceilings, timeout, cache, budget, cancellation and stale
118
+ results. The earlier free Gemma attempt returned 429 and never produced a verified
119
+ live decision; it is no longer the selected backend. Privacy restrictions remain.
120
+
121
+ The 0.0.15 rule-first candidate passed 223 applicable tests (two macOS-only checks
122
+ skipped), fresh installed-consumer checks and an owned Windows fixture run with
123
+ actual native target-label renaming/clearing. Hung/disagreeing advice is tested
124
+ with mock judges: those tests do not make paid requests. The model request body
125
+ and privacy/price restrictions are unchanged.
package/README.ko.md CHANGED
@@ -106,6 +106,24 @@ 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
+ 0.0.15는 LLM을 기다리지 않고 알리고, Windows에 Seat 이름과 대기 종류를 표시해요.
113
+ 강아지도 기존 그림으로 가까이 와서 알림 방향을 봐요. 이름은 외부로 보내지 않아요.
114
+ 화면 캡처나 자동 승인 없이 현재 Room의 메타데이터만 읽어요.
115
+ 업데이트 후, 화면이 보이는 PC의 실행 중인 StackBite Seat에서 실행하세요.
116
+
117
+ ```sh
118
+ retrieviewer desktop --watch stackbite --brain openrouter
119
+ ```
120
+
121
+ [유료 DeepSeek 연결](BRAIN.md)에는 같은 터미널의 `OPENROUTER_API_KEY`가 필요해요.
122
+ `--brain openrouter`를 빼면 API 없이 규칙 알림만 써요. 이미 실행 중이면 먼저
123
+ `retrieviewer desktop-stop`으로 종료하세요. 설치된 `retrieviewer` 명령은 어느
124
+ 폴더에서든 쓸 수 있어요. `node retrieviewer-cli.mjs`는 소스 폴더 안에서만 쓰는
125
+ 개발용 명령이에요.
126
+
109
127
  ## 직접 만든 TUI에 붙이기
110
128
 
111
129
  JS 플러그인과 JSONL 브리지를 제공해요. 화면·입력·시간 제어는 호스트가 소유합니다.
package/README.md CHANGED
@@ -130,6 +130,28 @@ 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
+ Version 0.0.15 alerts without waiting for the model, shows the Seat name and
141
+ pending kind locally on Windows, and lets the puppy approach before looking
142
+ toward the sidebar. It reuses existing artwork; names never enter model input.
143
+
144
+ After updating, run inside a live StackBite Seat on the display computer:
145
+
146
+ ```sh
147
+ retrieviewer desktop --watch stackbite --brain openrouter
148
+ ```
149
+
150
+ The paid backend requires `OPENROUTER_API_KEY` in that terminal's environment.
151
+ Omit `--brain openrouter` for rule-only alerts. Quit an existing companion first
152
+ with `retrieviewer desktop-stop`. These installed commands work from any folder;
153
+ `node retrieviewer-cli.mjs` is for developers running in the source checkout.
154
+
133
155
  ## Build, contribute, be kind
134
156
 
135
157
  ```sh
package/RELEASING.md CHANGED
@@ -1,4 +1,42 @@
1
- # Retrieviewer 0.0.13 release process
1
+ # Retrieviewer 0.0.15 release process
2
+
3
+ Current target: `@bitefoundry/retrieviewer@0.0.15`, public `latest`.
4
+ On 2026-10-24 the owner authorized npm publication of the reviewed rule-first
5
+ attention update. The optional model now runs after a confirmed rule cue is
6
+ accepted; no model response can delay, retract or repeat that cue. Windows
7
+ displays a bounded local Seat name and approval/input kind with a hover tooltip.
8
+ The pet reuses existing front contact, travel and whole-body look material;
9
+ resolved approaches brake naturally and direct play keeps priority.
10
+
11
+ Keep all nine art archives, runtime dependencies, provider/privacy restrictions,
12
+ price ceilings and request bounds unchanged. Build/install a fresh exact tarball,
13
+ compare every shipped byte, run the full suite and owned native checks, then
14
+ publish that same artifact. Verify registry integrity and a fresh public-registry
15
+ consumer. No StackBite changes, real approval actions, screen capture, host patch,
16
+ live pet restart or website deployment are authorized by this release request.
17
+ macOS/Linux live notification behavior remains unverified.
18
+
19
+ ## Previous 0.0.14 scope (historical)
20
+
21
+ Current target: `@bitefoundry/retrieviewer@0.0.14`, public `latest`.
22
+ On 2026-10-24 the owner authorized npm publication of the metadata-only StackBite
23
+ watcher and optional paid DeepSeek V4.1 Flash 1017 backend. Existing artwork and
24
+ physics are unchanged. Watching and remote inference both remain opt-in.
25
+
26
+ The installed `retrieviewer` bin works from any directory; relative source
27
+ commands require the checkout root. Include this unrelated-working-directory
28
+ regression in the fresh installed-consumer check. Keep model identity, provider
29
+ price ceilings, privacy restrictions, daily per-process attempt guard, deadlines,
30
+ cache bounds, cancellation and rule fallback. No API keys ship or are saved.
31
+
32
+ Build/install a fresh exact artifact, compare every shipped byte, run tests and
33
+ an owned native worker check, then publish the same tarball. Verify registry
34
+ integrity and a fresh registry consumer. Earlier versions stay immutable; do not
35
+ restart the user's pet, patch the host, or certify untested macOS/Linux behavior.
36
+ No new runtime dependency or local model is added. Website deployment is outside
37
+ this release request.
38
+
39
+ ## Previous 0.0.13 scope (historical)
2
40
 
3
41
  Current target: `@bitefoundry/retrieviewer@0.0.13`, public `latest`.
4
42
  The owner authorized npm publication and website deployment on2026-10-24.
@@ -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',label:seat.role_title});
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',label:seat.role_title});
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,12 +110,12 @@ 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();readonly ToolTip attentionTip=new ToolTip();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")));
117
117
  bar.Controls.Add(MakeButton("Tug",144,()=>Interact("tug")));bar.Controls.Add(MakeButton("Window",212,ChooseTarget));bar.Controls.Add(MakeButton("Pause",280,TogglePause));bar.Controls.Add(MakeButton("Quit",348,Quit));
118
- status.AutoSize=false;status.Location=new Point(422,12);status.Size=new Size(112,22);status.Text="Loading...";bar.Controls.Add(status);
118
+ status.AutoSize=false;status.AutoEllipsis=true;status.UseMnemonic=false;status.Location=new Point(422,12);status.Size=new Size(112,22);status.Text="Loading...";bar.Controls.Add(status);
119
119
  ContextMenuStrip menu=new ContextMenuStrip();menu.Items.Add("Choose window",null,(s,e)=>ChooseTarget());menu.Items.Add("Hide / resume",null,(s,e)=>TogglePause());menu.Items.Add("Quit",null,(s,e)=>Quit());
120
120
  tray.Icon=SystemIcons.Application;tray.Text="Retrieviewer · Choose window";tray.ContextMenuStrip=menu;tray.Visible=true;tray.DoubleClick+=(s,e)=>ChooseTarget();
121
121
  // Handles are created on the UI thread before the reader can signal EOF.
@@ -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
+ attentionText=Program.AttentionText(state,count,m.ContainsKey("label")?Program.Str(m,"label"):"",m.ContainsKey("kind")?Program.Str(m,"kind"):"");status.Text=attentionText;attentionTip.SetToolTip(status,attentionText);
158
+ Program.Emit(new {type="attention-label",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.
@@ -210,7 +216,7 @@ sealed class Companion : ApplicationContext {
210
216
  }
211
217
  }
212
218
  void Quit(){if(quitting)return;quitting=true;clock.Stop();if(pickerWindow!=null)pickerWindow.Close();pet.Close();bar.Close();tray.Visible=false;tray.Dispose();Program.Emit(new {type="quit"});ExitThread();}
213
- protected override void Dispose(bool disposing){if(disposing){clock.Dispose();pet.Dispose();bar.Dispose();tray.Dispose();if(testTarget!=null)testTarget.Dispose();if(mutex!=null){mutex.ReleaseMutex();mutex.Dispose();mutex=null;}}base.Dispose(disposing);}
219
+ protected override void Dispose(bool disposing){if(disposing){clock.Dispose();attentionTip.Dispose();pet.Dispose();bar.Dispose();tray.Dispose();if(testTarget!=null)testTarget.Dispose();if(mutex!=null){mutex.ReleaseMutex();mutex.Dispose();mutex=null;}}base.Dispose(disposing);}
214
220
  }
215
221
 
216
222
  static class Program {
@@ -220,6 +226,17 @@ static class Program {
220
226
  internal static int Int(Dictionary<string,object> m,string key){return Convert.ToInt32(m[key]);}
221
227
  internal static string Str(Dictionary<string,object> m,string key){return Convert.ToString(m[key]);}
222
228
  internal static bool Bool(Dictionary<string,object> m,string key){return Convert.ToBoolean(m[key]);}
229
+ internal static string AttentionText(string state,int count,string label,string kind){
230
+ if(count<0||count>32||label.Length>64)throw new Exception("Invalid attention metadata");
231
+ foreach(char c in label)if(char.IsControl(c)||(c>=0x200b&&c<=0x200f)||(c>=0x202a&&c<=0x202e)||(c>=0x2060&&c<=0x206f))throw new Exception("Invalid attention label");
232
+ if(state=="connected"){
233
+ if(count==0)return "Watching";
234
+ if(label.Length==0)return count+" need input";
235
+ if(kind!="approval"&&kind!="input")throw new Exception("Invalid attention kind");
236
+ return label+" - "+(kind=="approval"?"Approval pending":"Input pending")+(count>1?" (+"+(count-1)+")":"");
237
+ }
238
+ return state=="paused"?"Watch paused":state=="connecting"?"Connecting...":"Watch offline";
239
+ }
223
240
  [STAThread] static int Main(string[] args){
224
241
  try{
225
242
  // GUI-subsystem processes have redirected handles but no console code
@@ -263,8 +280,9 @@ static class Program {
263
280
  Native.ShowWindow(target.Handle,4);Application.DoEvents();bool restored=!Native.IsIconic(target.Handle);
264
281
  bool focus=Native.GetForegroundWindow()==foreground;
265
282
  bool gridBounds=GridCell(3840,2160)==8&&GridCell(7680,4320)==12&&GridCell(32000,18000)==0&&GridCell(int.MaxValue,int.MaxValue)==0&&GridCell(0,0)==0;
266
- bool passed=clickThrough&&noActivate&&layered&&follows&&minimized&&hidden&&restored&&focus&&pet.PaintedDots>0&&gridBounds;
267
- 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;
283
+ bool labels=AttentionText("connected",2,"Worker & Review","approval")=="Worker & Review - Approval pending (+1)"&&AttentionText("connected",0,"","")=="Watching";
284
+ bool passed=clickThrough&&noActivate&&layered&&follows&&minimized&&hidden&&restored&&focus&&pet.PaintedDots>0&&gridBounds&&labels;
285
+ Emit(new {type="self-test",passed=passed,attentionLabels=labels,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;
268
286
  }
269
287
  }
270
288
  }
@@ -93,15 +93,24 @@ 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}});
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});
105
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);};
106
115
  // Disconnect on failure aborts startup. Only an acknowledged detach may live on.
107
116
  const timer=setTimeout(()=>finish(Error('Desktop startup timed out; no agent settings changed')),20000);
@@ -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
  };
@@ -10,12 +10,15 @@ import {controlAddress,parseHelperSpec} from '../desktop/platform.mjs';
10
10
 
11
11
  let helper,pump,timer,server,session,paths,owned=false,stopping=false,frames=0,paintedFrames=0,visible=false,selected=false,pickerVisible=false,pendingViewport=null,lastInteraction=null;
12
12
  let nativeError=null,ready=false,detached=false;
13
+ let attention=null,brain=null,attentionLabel=null;
13
14
  const returnStyle=process.env.RETRIEVIEWER_RETURN_STYLE??'auto';
15
+ const watchSource=process.env.RETRIEVIEWER_WATCH??'off';
16
+ const brainSource=process.env.RETRIEVIEWER_BRAIN??'off';
14
17
  let nativeStderr='';
15
18
  const sendParent=message=>{if(process.connected)try{process.send(message,error=>{if(error&&!detached)stop();});}catch{if(!detached)stop();}};
16
19
  const starting=()=>{if(stopping||(!process.connected&&!detached))throw Error('Desktop launcher closed during startup');};
17
20
  async function stop(){
18
- const first=!stopping;stopping=true;clearInterval(timer);pump?.dispose();
21
+ const first=!stopping;stopping=true;clearInterval(timer);attention?.dispose();brain?.dispose();pump?.dispose();
19
22
  if(helper&&!helper.killed){try{helper.stdin.end(JSON.stringify({type:'quit'})+'\n');}catch{}}
20
23
  if(server?.listening)server.close();
21
24
  if(owned)try{const current=JSON.parse(await readFile(paths.session,'utf8'));if(current.token===session.token)await unlink(paths.session);}catch{}
@@ -41,7 +44,7 @@ try{
41
44
  body+=bytes.toString('utf8');if(body.length>2048)return socket.destroy();if(!body.includes('\n'))return;
42
45
  let command;try{command=JSON.parse(body.split('\n')[0]);}catch{return socket.destroy();}
43
46
  if(command.token!==session.token||!['status','stop'].includes(command.command))return socket.destroy();
44
- socket.end(JSON.stringify(command.command==='stop'?{stopped:true}:{running:true,reachable:true,selected,visible,pickerVisible,frames,paintedFrames,lastInteraction,returnStyle,pid:process.pid,painterPid:helper?.pid,rssBytes:process.memoryUsage().rss})+'\n');
47
+ socket.end(JSON.stringify(command.command==='stop'?{stopped:true}:{running:true,reachable:true,selected,visible,pickerVisible,frames,paintedFrames,lastInteraction,returnStyle,attentionLabel,watch:{source:watchSource,...attention?.status??{state:'off',pendingCount:0}},brain:brain?.status??{provider:'off',state:'off'},pid:process.pid,painterPid:helper?.pid,rssBytes:process.memoryUsage().rss})+'\n');
45
48
  if(command.command==='stop')stop();
46
49
  });
47
50
  });
@@ -49,7 +52,8 @@ try{
49
52
  if(process.platform!=='win32')await chmod(session.pipe,0o600);
50
53
  starting();
51
54
  // GUI-subsystem exe: no console to hide. SW_HIDE here also hides its first UI.
52
- helper=spawn(spec.command,spec.args,{windowsHide:false,stdio:['pipe','pipe','pipe']});
55
+ const helperEnv={...process.env};delete helperEnv.OPENROUTER_API_KEY;
56
+ helper=spawn(spec.command,spec.args,{windowsHide:false,stdio:['pipe','pipe','pipe'],env:helperEnv});
53
57
  helper.stdin.on('error',()=>stop());helper.stderr.setEncoding('utf8');helper.stderr.on('data',s=>{nativeStderr=(nativeStderr+s).slice(-4096);});helper.once('error',error=>{sendParent({error:error.message});stop();});
54
58
  helper.once('exit',code=>{if(!stopping)sendParent({error:nativeError??('Desktop painter exited ('+code+')'+(nativeStderr?' — '+nativeStderr.trim():''))});stop();});
55
59
  const write=message=>{if(!stopping&&!helper.stdin.destroyed)helper.stdin.write(JSON.stringify(message)+'\n');};
@@ -62,6 +66,7 @@ try{
62
66
  else if(m.type==='picker')pickerVisible=m.visible===true;
63
67
  else if(m.type==='viewport'){pendingViewport=m;visible=m.visible;selected=m.selected===true;pump?.viewport(m);}
64
68
  else if(m.type==='ack'){frames++;if(m.painted)paintedFrames++;pump?.ack();}
69
+ else if(m.type==='attention-label'){if(typeof m.text!=='string'||m.text.length>160)throw Error('Invalid attention label');attentionLabel=m.text;}
65
70
  else if(m.type==='pause')pump?.pause(m.paused);
66
71
  else if(m.type==='interact'){const accepted=pump?.interact(m.kind)??false;lastInteraction={kind:m.kind,accepted};write({type:'interaction',kind:m.kind,accepted});}
67
72
  else if(m.type==='quit')stop();
@@ -70,6 +75,18 @@ try{
70
75
  starting();const {createPet}=await import('../../retrieviewer.mjs');starting();const pet=await createPet({returnStyle});
71
76
  if(stopping)pet.dispose();else{
72
77
  pump=createFramePump({pet,send:write});if(pendingViewport)pump.viewport(pendingViewport);
73
- write({type:'loaded',ink:process.env.RETRIEVIEWER_INK??'cream'});timer=setInterval(()=>{try{pump.tick();}catch(error){write({type:'error',message:error.message});stop();}},1000/spec.fps);
78
+ if(!['off','stackbite'].includes(watchSource))throw Error('Invalid desktop watch source');
79
+ if(!['off','openrouter'].includes(brainSource)||brainSource==='openrouter'&&watchSource!=='stackbite')throw Error('Invalid desktop brain/watch combination');
80
+ if(brainSource==='openrouter'){const {createOpenRouterBrain}=await import('../openrouter/brain.mjs');starting();brain=createOpenRouterBrain();}
81
+ if(watchSource==='stackbite'){
82
+ const [{createAttentionMonitor},{createStackBiteSource}]=await Promise.all([import('../../retrieviewer-attention.mjs'),import('../stackbite/attention.mjs')]);starting();
83
+ const source=createStackBiteSource();
84
+ attention=createAttentionMonitor({readSnapshot:source.readSnapshot,...brain?{judgeNotice:input=>brain.decide(input)}:{},onNotice:notice=>pump.notify(notice),onClear:()=>pump.clearNotice(),onChange:status=>{
85
+ // Older POSIX helpers deliberately reject unknown message types. They can
86
+ // run the visual cue, but only the Windows bar implements this status label.
87
+ if(spec.mode==='windows-overlay')write({type:'attention',state:status.state,count:status.pendingCount,label:status.target?.label??'',kind:status.target?.kind??''});
88
+ }});
89
+ }
90
+ write({type:'loaded',ink:process.env.RETRIEVIEWER_INK??'cream'});timer=setInterval(()=>{try{attention?.tick({active:!pump.hidden});pump.tick();}catch(error){write({type:'error',message:error.message});stop();}},1000/spec.fps);
74
91
  }
75
92
  }catch(error){sendParent({error:error.message});await stop();}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitefoundry/retrieviewer",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "description": "A fluffy Braille desktop pet for Windows and Linux X11, with experimental macOS support and an embeddable TUI engine.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://bitefoundry.github.io/retrieviewer/",
@@ -47,6 +47,14 @@
47
47
  "types": "./retrieviewer-plugin.d.mts",
48
48
  "default": "./retrieviewer-plugin.mjs"
49
49
  },
50
+ "./attention": {
51
+ "types": "./retrieviewer-attention.d.mts",
52
+ "default": "./retrieviewer-attention.mjs"
53
+ },
54
+ "./brain": {
55
+ "types": "./integrations/openrouter/brain.d.mts",
56
+ "default": "./integrations/openrouter/brain.mjs"
57
+ },
50
58
  "./tui": "./integrations/opencode/tui.tsx",
51
59
  "./opencode/tui": "./integrations/opencode/tui.tsx",
52
60
  "./package.json": "./package.json"
@@ -62,6 +70,8 @@
62
70
  "RELEASING.md",
63
71
  "retrieviewer.mjs",
64
72
  "retrieviewer.d.mts",
73
+ "retrieviewer-attention.mjs",
74
+ "retrieviewer-attention.d.mts",
65
75
  "retrieviewer-stdio.mjs",
66
76
  "retrieviewer-plugin.mjs",
67
77
  "retrieviewer-plugin.d.mts",
@@ -75,6 +85,11 @@
75
85
  "integrations/windows-overlay/worker.mjs",
76
86
  "integrations/windows-overlay/Overlay.cs",
77
87
  "integrations/desktop/platform.mjs",
88
+ "integrations/stackbite/attention.mjs",
89
+ "integrations/openrouter/brain.mjs",
90
+ "integrations/openrouter/brain.d.mts",
91
+ "BRAIN.md",
92
+ "ATTENTION.md",
78
93
  "integrations/linux-overlay/overlay.py",
79
94
  "integrations/linux-overlay/README.md",
80
95
  "integrations/macos-overlay/overlay.jxa",
@@ -26,7 +26,7 @@ const LABEL={
26
26
  belly:'배를 보이고 발라당 쉬는 중',sleep:'새근새근 잠자는 중',tug:'장난감을 물고 당기는 중',
27
27
  butterflyNotice:'나비를 발견하고 귀를 쫑긋하는 중',butterfly:'나비를 폴짝폴짝 쫓는 중',butterflyBye:'날아가는 나비를 바라보는 중',
28
28
  fetch:'공을 쫓아가는 중',miss:'굴러가는 공을 한 번 놓친 중',reChase:'공을 다시 쫓아가는 중',tumble:'우당탕 균형을 되찾는 중',pickup:'공을 무는 중',carry:'공을 물고 돌아오는 중',drop:'공을 내려놓는 중',offer:'공을 내려놓고 기다리는 중',
29
- treatSit:'간식 앞에 앉는 중',chew:'간식 먹는 중'
29
+ treatSit:'간식 앞에 앉는 중',chew:'간식 먹는 중',notifySit:'확인할 일을 발견하고 앉는 중',notifyContact:'확인할 일이 있어 사용자를 바라보는 중',notifyCome:'확인할 일을 알려주러 가까이 오는 중',notify:'확인할 일이 있는 쪽을 바라보는 중'
30
30
  };
31
31
  const PENDING_LABEL={treat:'곧 간식',tug:'곧 터그놀이'};
32
32
  // Baked interaction clips, played only while holding at the shared seated endpoint.
@@ -62,7 +62,7 @@ export function createCompanionLife(actor,{seed=731,energy=.8,returnStyle='auto'
62
62
  let clock=0,decisions=0,lastEpisode=null,quiet=0;
63
63
  let queue=[],hold=0,holdIntent=null,waiting=null,gesture=null;
64
64
  let intent='start',label=LABEL.start,pending=null,active=null,ball=null,treat=null;
65
- let fetchPlan=null;
65
+ let fetchPlan=null,notice=null;
66
66
 
67
67
  const cmd=(name,args,step,when)=>({cmd:name,args,intent:step,label:LABEL[step],when});
68
68
  const wait=(seconds,step)=>({hold:seconds,intent:step,label:LABEL[step]});
@@ -236,6 +236,14 @@ export function createCompanionLife(actor,{seed=731,energy=.8,returnStyle='auto'
236
236
  holdIntent=null;gesture=null;
237
237
  if(!safe)return;
238
238
  if(pending&&!active)startInteraction();
239
+ if(notice?.phase==='queued'&&!active&&!pending){
240
+ notice.phase='showing';
241
+ const far=()=>{const s=actor.sample(clock);return Math.hypot(s.x-HOME.x,s.z-HOME.z)>.8;};
242
+ queue=[sit('notifySit'),wait(.45,'notifyContact'),
243
+ fx(()=>{if(far())notice.phase='approaching';}),cmd('go',[HOME.x,HOME.z],'notifyCome',far),
244
+ sit('notifySit'),fx(()=>{notice.phase='showing';}),wait(.6,'notifyContact'),
245
+ cmd('look',[notice.side],'notify'),wait(2.5,'notify'),fx(()=>{notice=null;})];
246
+ }
239
247
  // Run instant effects; stop at the first hold or issued command (at most one command per update).
240
248
  for(;;){
241
249
  if(!queue.length){
@@ -257,6 +265,7 @@ export function createCompanionLife(actor,{seed=731,energy=.8,returnStyle='auto'
257
265
  if(!['ball','treat','tug'].includes(kind))return false;
258
266
  if(kind==='tug'&&typeof actor.tug!=='function')return false;
259
267
  if(pending||active||cooldowns[kind]>0)return false;
268
+ clearNotice();
260
269
  quiet=0;
261
270
  if(butterfly||lastEpisode==='butterfly'){
262
271
  butterfly=null;queue=[];hold=0;holdIntent=null;gesture=null;waiting=null;
@@ -304,6 +313,24 @@ export function createCompanionLife(actor,{seed=731,energy=.8,returnStyle='auto'
304
313
  return true;
305
314
  }
306
315
 
316
+ function clearNotice(){
317
+ if(!notice)return;
318
+ const approaching=notice.phase==='approaching';
319
+ notice=null;queue=[];hold=0;holdIntent=null;gesture=null;waiting=null;
320
+ intent='recover';label=LABEL.recover;
321
+ if(approaching)actor.attend?.();
322
+ // Let any connected head turn/sit finish naturally. No snap or new route.
323
+ }
324
+ function notify({kind,side='left'}={}){
325
+ if(!['approval','input'].includes(kind)||!['left','right'].includes(side))throw Error('Invalid pet notice');
326
+ // Explicit play/feeding always wins. Notifications never queue a second pet.
327
+ if(active||pending||notice)return false;
328
+ notice={kind,side,phase:'queued'};
329
+ butterfly=null;queue=[];hold=0;holdIntent=null;gesture=null;waiting=null;
330
+ actor.attend?.();
331
+ return true;
332
+ }
333
+
307
334
  // Recomputed from the live pose so the marker stays truthful before and after sitting.
308
335
  function treatReport(){
309
336
  if(!treat)return null;
@@ -322,13 +349,14 @@ export function createCompanionLife(actor,{seed=731,energy=.8,returnStyle='auto'
322
349
  }
323
350
 
324
351
  return {
325
- update,interact,
352
+ update,interact,notify,clearNotice,
326
353
  setReturnStyle(style){validateReturnStyle(style);returnStyle=style;return style;},
327
354
  get report(){
328
355
  const interaction=active??pending;
329
356
  return {
330
357
  intent:actor.state==='scramble'&&fetchPlan?'tumble':intent,label:pending?`${label} · ${PENDING_LABEL[pending]}`:actor.state==='scramble'&&fetchPlan?LABEL.tumble:label,episode:lastEpisode,interaction,busy:interaction!==null,
331
358
  ball:ballReport(),fetchPattern:fetchPlan?.pattern??null,returnStyle,returnMode:fetchPlan?.returnMode??null,treat:treatReport(),butterfly:butterflyPoint(),tug:actor.state==='tug'?{progress:actor.sample(clock).comfortPose?.progress??0,grip:1}:null,
359
+ attention:notice?{...notice}:null,
332
360
  snore:actor.state==='sleep'?((clock%3.6)/3.6):null,energy:needs.energy,drowsiness:needs.drowsiness,decisions,cooldowns:{...cooldowns},
333
361
  // Local hold clock only: progress freezes between updates; null outside the stationary hold.
334
362
  gesture:gesture&&SAFE.has(actor.state)?{clip:gesture.clip,progress:clamp(gesture.elapsed/gesture.duration,0,1)}:null
@@ -0,0 +1,35 @@
1
+ export interface AttentionSnapshot {
2
+ source: string;
3
+ scope: string;
4
+ /** label is a bounded local display name, never remote model input. */
5
+ items: {id: string; generation: string; turnId: number | null; requestId?: string; kind: 'approval' | 'input'; label?: string}[];
6
+ }
7
+ export interface AttentionStatus {
8
+ state: 'connecting' | 'connected' | 'unavailable' | 'paused' | 'stale' | 'off';
9
+ pendingCount: number;
10
+ target: {seatId: string; label: string; kind: 'approval' | 'input'} | null;
11
+ retainedNotices: number;
12
+ inFlight: boolean;
13
+ judging?: boolean;
14
+ lastJudgment?: 'rule_first' | 'model_notify' | 'rule_override' | 'rule_fallback' | null;
15
+ }
16
+ export function validateAttentionSnapshot(value: unknown): AttentionSnapshot;
17
+ /** Host owns tick/lifetime. Import and construction start no timers or I/O. */
18
+ export function createAttentionMonitor(options: {
19
+ readSnapshot: (options: {signal: AbortSignal}) => Promise<AttentionSnapshot>;
20
+ onNotice: (notice: {kind: 'approval' | 'input'; side: 'left'}) => boolean;
21
+ onClear?: () => void;
22
+ onChange?: (status: AttentionStatus) => void;
23
+ /** Anonymous metadata only. Runs after the immediate rule alert, never delays or suppresses it. */
24
+ judgeNotice?: (input: {kind: 'approval' | 'input'; pendingCount: number; signal: AbortSignal}) => Promise<{decision: 'notify' | 'ignore' | 'uncertain'}>;
25
+ judgeTimeoutMs?: number;
26
+ now?: () => number;
27
+ intervalMs?: number;
28
+ staleMs?: number;
29
+ noticeCooldownMs?: number;
30
+ }): {
31
+ tick(options?: {active?: boolean}): void;
32
+ readonly status: AttentionStatus;
33
+ settled(): Promise<void>;
34
+ dispose(): void;
35
+ };
@@ -0,0 +1,107 @@
1
+ // Opt-in attention coordinator. No I/O, timers, models or pet assets on import.
2
+ // Screen/app text is data, never an instruction or an executable action.
3
+ const MAX_ITEMS=32,MAX_SEEN=128;
4
+ const bounded=(value,max=128)=>typeof value==='string'&&value.length>0&&value.length<=max&&!/[\x00-\x1f\x7f]/.test(value);
5
+ // Cosmetic, local-only metadata. Never let a malformed title hide a real alert.
6
+ const localLabel=value=>typeof value==='string'?value.slice(0,256).replace(/[\x00-\x1f\x7f-\x9f\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g,'').replace(/\s+/g,' ').trim().slice(0,64).replace(/[\ud800-\udbff]$/,'')||undefined:undefined;
7
+ export function validateAttentionSnapshot(value){
8
+ if(!value||!bounded(value.source,32)||!bounded(value.scope,256)||!Array.isArray(value.items)||value.items.length>MAX_ITEMS)throw Error('Invalid attention snapshot');
9
+ const identities=new Set();
10
+ const items=value.items.map(item=>{
11
+ if(!item||!bounded(item.id)||!bounded(item.generation)||!(item.turnId===null||Number.isSafeInteger(item.turnId)&&item.turnId>=1)||item.requestId!==undefined&&!bounded(item.requestId)||item.turnId===null&&item.requestId===undefined||!['approval','input'].includes(item.kind))throw Error('Invalid attention item');
12
+ const key=JSON.stringify([item.id,item.generation,item.turnId,item.requestId??null]);if(identities.has(key))throw Error('Duplicate attention item');identities.add(key);
13
+ // No arbitrary source fields, output text, prompts, paths or tokens. A Seat's
14
+ // bounded display label is for local status only, never the remote judge.
15
+ const label=localLabel(item.label);
16
+ return {id:item.id,generation:item.generation,turnId:item.turnId,...item.requestId===undefined?{}:{requestId:item.requestId},kind:item.kind,...label?{label}:{}};
17
+ });
18
+ return {source:value.source,scope:value.scope,items};
19
+ }
20
+
21
+ export function createAttentionMonitor({readSnapshot,onNotice,onClear=()=>{},onChange=()=>{},judgeNotice,judgeTimeoutMs=3500,now=()=>performance.now(),intervalMs=5000,staleMs=15000,noticeCooldownMs=20000}={}){
22
+ if(typeof readSnapshot!=='function'||typeof onNotice!=='function'||typeof onClear!=='function'||typeof onChange!=='function'||typeof now!=='function')throw TypeError('Attention callbacks must be functions');
23
+ if(judgeNotice!==undefined&&typeof judgeNotice!=='function')throw TypeError('judgeNotice must be a function');
24
+ if(!Number.isFinite(judgeTimeoutMs)||judgeTimeoutMs<100||judgeTimeoutMs>15000)throw RangeError('Invalid judge timeout');
25
+ if(!Number.isFinite(intervalMs)||intervalMs<1000||intervalMs>60000||!Number.isFinite(staleMs)||staleMs<intervalMs*2||staleMs>300000||!Number.isFinite(noticeCooldownMs)||noticeCooldownMs<1000||noticeCooldownMs>300000)throw RangeError('Invalid attention timing');
26
+ let disposed=false,paused=false,flight=null,nextRead=0,nextNotice=0,lastSuccess=null,snapshot=null,state='connecting',lastReport='',cueKey=null,judgment=null,lastJudgment=null,target=null,targetKey='';
27
+ const seen=new Map();
28
+ const identity=(value,item)=>JSON.stringify([value.source,value.scope,item.id,item.generation,item.turnId,item.requestId??null]);
29
+ const refreshTarget=()=>{
30
+ const item=snapshot?.items.find(row=>identity(snapshot,row)===cueKey)??snapshot?.items.find(row=>row.kind==='approval')??snapshot?.items[0];
31
+ target=item?{seatId:item.id,label:item.label??(item.id.length<=32?item.id:'Seat '+item.id.slice(-8)),kind:item.kind}:null;
32
+ targetKey=JSON.stringify(target);
33
+ };
34
+ const clearCue=()=>{if(cueKey!==null){cueKey=null;refreshTarget();onClear();}};
35
+ const cancelJudgment=()=>{const old=judgment;judgment=null;old?.controller.abort();};
36
+ const report=()=>({state,pendingCount:snapshot?.items.length??0,target:target?{...target}:null,retainedNotices:seen.size,inFlight:flight!==null,...judgeNotice?{judging:!!judgment&&!judgment.ready,lastJudgment}:{}});
37
+ const changed=()=>{const key=state+'|'+(snapshot?.items.length??0)+'|'+targetKey+'|'+!!judgment+'|'+lastJudgment;if(key!==lastReport){lastReport=key;onChange(report());}};
38
+ const clear=next=>{snapshot=null;refreshTarget();lastSuccess=null;state=next;cancelJudgment();clearCue();changed();};
39
+ function startRead(t){
40
+ const operation={controller:new AbortController()};flight=operation;nextRead=t+intervalMs;
41
+ operation.done=(async()=>{
42
+ try{
43
+ const result=validateAttentionSnapshot(await readSnapshot({signal:operation.controller.signal}));
44
+ if(disposed||paused||flight!==operation)return;
45
+ // A scope change cannot inherit either pending facts or dedupe identities.
46
+ if(snapshot&&(snapshot.scope!==result.scope||snapshot.source!==result.source))seen.clear();
47
+ snapshot=result;lastSuccess=now();state='connected';
48
+ if(cueKey!==null&&!snapshot.items.some(item=>identity(snapshot,item)===cueKey))clearCue();
49
+ if(judgment&&!snapshot.items.some(item=>identity(snapshot,item)===judgment.key))cancelJudgment();
50
+ refreshTarget();
51
+ }catch{
52
+ if(disposed||paused||flight!==operation)return;
53
+ // No source stderr, credentials, or stale pending facts escape on failure.
54
+ clear('unavailable');
55
+ }finally{
56
+ if(flight===operation){flight=null;nextRead=now()+intervalMs;if(!disposed&&!paused)changed();}
57
+ }
58
+ })();
59
+ }
60
+ return {
61
+ tick({active=true}={}){
62
+ if(disposed)return;
63
+ const t=now();if(!Number.isFinite(t))throw RangeError('Invalid attention clock');
64
+ if(!active){
65
+ if(!paused){paused=true;const old=flight;flight=null;old?.controller.abort();clear('paused');}
66
+ return;
67
+ }
68
+ if(paused){paused=false;nextRead=t;state='connecting';changed();}
69
+ if(snapshot&&t-lastSuccess>=staleMs)clear('stale');
70
+ if(!flight&&t>=nextRead)startRead(t);
71
+ // Advice settles independently of alert delivery. It cannot delay, retract,
72
+ // repeat, or move an already-confirmed human-input notification.
73
+ if(judgment){
74
+ if(!judgment.ready&&t-judgment.start>=judgeTimeoutMs){judgment.ready=true;judgment.decision='uncertain';judgment.controller.abort();}
75
+ if(judgment.ready){lastJudgment=judgment.decision==='notify'?'model_notify':judgment.decision==='ignore'?'rule_override':'rule_fallback';judgment=null;}
76
+ }
77
+ // A refresh already in flight may resolve/replace the candidate. Do not emit
78
+ // an old cue in the microtask gap just before that fresh snapshot arrives.
79
+ if(!flight&&snapshot&&state==='connected'&&t<lastSuccess+staleMs&&t>=nextNotice){
80
+ // Approval and generic input for the same exact turn are one notification.
81
+ let item=null;
82
+ for(const row of snapshot.items)if(!seen.has(identity(snapshot,row))){if(!item)item=row;if(row.kind==='approval'){item=row;break;}}
83
+ if(item&&onNotice({kind:item.kind,side:'left'})===true){
84
+ const key=identity(snapshot,item);seen.set(key,true);cueKey=key;
85
+ refreshTarget();
86
+ if(judgeNotice){
87
+ cancelJudgment();lastJudgment='rule_first';
88
+ const operation={key,controller:new AbortController(),ready:false,start:t};judgment=operation;
89
+ const facts={kind:item.kind,pendingCount:snapshot.items.length,signal:operation.controller.signal};
90
+ operation.done=Promise.resolve().then(()=>{if(operation.controller.signal.aborted)throw Error('Canceled judgment');return judgeNotice(facts);}).then(result=>{
91
+ if(judgment===operation&&!disposed&&!paused&&!operation.controller.signal.aborted){operation.decision=['notify','ignore','uncertain'].includes(result?.decision)?result.decision:'uncertain';operation.ready=true;}
92
+ },()=>{if(judgment===operation&&!disposed&&!paused){operation.decision='uncertain';operation.ready=true;}});
93
+ }
94
+ // Still-pending identities cannot be evicted into repeated notifications.
95
+ const live=new Set(snapshot.items.map(row=>identity(snapshot,row)));
96
+ for(const prior of seen.keys()){if(seen.size<=MAX_SEEN)break;if(!live.has(prior))seen.delete(prior);}
97
+ nextNotice=t+noticeCooldownMs;
98
+ }
99
+ }
100
+ changed();
101
+ },
102
+ get status(){return report();},
103
+ // For host shutdown/verification, not a second polling loop.
104
+ async settled(){await flight?.done;await judgment?.done;},
105
+ dispose(){if(disposed)return;disposed=true;const old=flight;flight=null;old?.controller.abort();cancelJudgment();snapshot=null;refreshTarget();clearCue();seen.clear();state='off';},
106
+ };
107
+ }
@@ -13,7 +13,9 @@ Usage:
13
13
  retrieviewer doctor [--agent ID] [--json]
14
14
  retrieviewer agents [--json]
15
15
  retrieviewer preview [--original]
16
- retrieviewer desktop [--ink cream|gold|dark] [--return-style auto|brake|trot|run-trot]
16
+ retrieviewer desktop [--ink cream|gold|dark] [--return-style auto|brake|trot|run-trot] [--watch stackbite|off] [--brain openrouter|off]
17
+ retrieviewer brain-check [--json]
18
+ retrieviewer attention [--json]
17
19
  retrieviewer desktop-status [--json]
18
20
  retrieviewer desktop-stop
19
21
  retrieviewer desktop-clean --yes
@@ -28,12 +30,19 @@ use Codex normally. Exit from its tray icon or desktop-stop. No auto-start is ad
28
30
  Windows is verified; macOS and Linux X11 adapters are experimental. Native Wayland
29
31
  app tracking is not supported. Linux targets AlmaLinux8/GLIBC2.28, system Python3.6+.
30
32
  Return style defaults to auto (mix all three). Restart the companion to change it.
33
+ Watch is OFF by default. Inside a live StackBite Seat, --watch stackbite reads only
34
+ that Room's pending-input metadata. No screen capture, clicks or approvals.
35
+ Hidden/paused companions stop polling. attention performs one read-only check.
36
+ Brain is OFF by default. --brain openrouter requires --watch stackbite and
37
+ OPENROUTER_API_KEY; sends only anonymous event kinds/counts to paid DeepSeek V4.1
38
+ Flash 1017 with token price ceilings. Failures fall back to rule alerts.
39
+ brain-check sends one synthetic event and incurs API usage charges.
31
40
  `;
32
41
  export function parseArguments(args){
33
42
  const [command='help',...rest]=args,options={};
34
43
  for(let i=0;i<rest.length;i++){
35
44
  const token=rest[i];
36
- if(token==='--agent'||token==='--ink'||token==='--return-style'){
45
+ if(token==='--agent'||token==='--ink'||token==='--return-style'||token==='--watch'||token==='--brain'){
37
46
  const key=token.slice(2);
38
47
  if(options[key]||!rest[i+1]||rest[i+1].startsWith('-'))throw Error(token+' requires one value');
39
48
  options[key]=rest[++i];
@@ -43,19 +52,37 @@ export function parseArguments(args){
43
52
  if(options.agent&&!AGENTS.some(a=>a.id===options.agent))throw Error('Unknown agent: '+options.agent);
44
53
  if(options.ink&&!['cream','gold','dark'].includes(options.ink))throw Error('--ink must be cream, gold or dark');
45
54
  if(options['return-style']&&!['auto','brake','trot','run-trot'].includes(options['return-style']))throw Error('--return-style must be auto, brake, trot or run-trot');
46
- if(!['help','--help','-h','enable','disable','disconnect','status','doctor','agents','preview','desktop','desktop-status','desktop-stop','desktop-clean','desktop-remove'].includes(command))throw Error('Unknown command: '+command);
47
- const allowed={desktop:['ink','return-style'],enable:['agent','yes','json','dry-run'],disconnect:['agent','yes','json','dry-run'],disable:['agent','json'],status:['agent','json'],doctor:['agent','json'],agents:['json'],preview:['original'],'desktop-status':['json'],'desktop-clean':['yes'],'desktop-remove':['yes']};
55
+ if(options.watch&&!['off','stackbite'].includes(options.watch))throw Error('--watch must be stackbite or off');
56
+ if(options.brain&&!['off','openrouter'].includes(options.brain))throw Error('--brain must be openrouter or off');
57
+ if(options.brain==='openrouter'&&options.watch!=='stackbite')throw Error('--brain openrouter requires --watch stackbite');
58
+ if(!['help','--help','-h','enable','disable','disconnect','status','doctor','agents','preview','attention','brain-check','desktop','desktop-status','desktop-stop','desktop-clean','desktop-remove'].includes(command))throw Error('Unknown command: '+command);
59
+ const allowed={desktop:['ink','return-style','watch','brain'],attention:['json'],'brain-check':['json'],enable:['agent','yes','json','dry-run'],disconnect:['agent','yes','json','dry-run'],disable:['agent','json'],status:['agent','json'],doctor:['agent','json'],agents:['json'],preview:['original'],'desktop-status':['json'],'desktop-clean':['yes'],'desktop-remove':['yes']};
48
60
  for(const key of Object.keys(options))if(!(allowed[command]??[]).includes(key))throw Error(`--${key} is not supported by ${command}`);
49
61
  return {command,options};
50
62
  }
51
63
  export async function runCli(args,{input=process.stdin,output=process.stdout,error=process.stderr,installer=createInstaller(),desktop}={}){
52
64
  const {command,options}=parseArguments(args),emit=value=>output.write(JSON.stringify(value,null,2)+'\n');
53
65
  if(['help','--help','-h'].includes(command)){output.write(HELP);return 0;}
66
+ if(command==='brain-check'){
67
+ const {createOpenRouterBrain,BrainError}=await import('./integrations/openrouter/brain.mjs');
68
+ const brain=createOpenRouterBrain({timeoutMs:8000});
69
+ try{const result=await brain.decide({kind:'approval',pendingCount:1});emit({ok:true,synthetic:true,...result,brain:brain.status});return 0;}
70
+ catch(error){emit({ok:false,synthetic:true,error:error instanceof BrainError?error.code:'unavailable',brain:brain.status});return 2;}
71
+ finally{brain.dispose();}
72
+ }
73
+ if(command==='attention'){
74
+ const {createStackBiteSource}=await import('./integrations/stackbite/attention.mjs');
75
+ const snapshot=await createStackBiteSource().readSnapshot();
76
+ // Diagnostic metadata only; prompts/output/credentials were discarded upstream.
77
+ if(options.json)emit({...snapshot,pendingCount:snapshot.items.length});
78
+ else output.write(`StackBite current Room: ${snapshot.items.length} pending input item(s). No screen capture or actions.\n`);
79
+ return 0;
80
+ }
54
81
  if(command.startsWith('desktop')){
55
82
  if(['desktop-clean','desktop-remove'].includes(command)&&!options.yes)throw Error('Use '+command+' --yes to remove only the companion cache.');
56
83
  const api=desktop??await import('./integrations/windows-overlay/desktop.mjs');
57
84
  const action={'desktop':'startDesktop','desktop-status':'desktopStatus','desktop-stop':'stopDesktop','desktop-clean':'cleanDesktop','desktop-remove':'removeDesktop'}[command];
58
- emit(await api[action]({...options.ink?{ink:options.ink}:{},...options['return-style']?{returnStyle:options['return-style']}:{} }));return 0;
85
+ emit(await api[action]({...options.ink?{ink:options.ink}:{},...options['return-style']?{returnStyle:options['return-style']}:{},...options.watch?{watch:options.watch}:{},...options.brain?{brain:options.brain}:{} }));return 0;
59
86
  }
60
87
  if(command==='preview'){await import('./examples/live.mjs');return 0;}
61
88
  if(['status','doctor','agents'].includes(command)){
@@ -40,6 +40,8 @@ export interface PetState {
40
40
  [key: string]: unknown;
41
41
  }
42
42
  export interface LifeReport {
43
+ /** Metadata-triggered visual cue only. No host input or approval authority. */
44
+ attention: {kind: 'approval' | 'input'; side: 'left' | 'right'; phase: 'queued' | 'approaching' | 'showing'} | null;
43
45
  /** Last selected autonomous episode; null before the first decision. */
44
46
  episode: string | null;
45
47
  intent: string;
@@ -93,6 +95,10 @@ export interface Pet {
93
95
  needsFrame(options: FrameOptions): boolean;
94
96
  /** False when busy or cooling down. */
95
97
  interact(kind: 'ball' | 'treat' | 'tug'): boolean;
98
+ /** False during direct play/feeding or another cue. Does not operate the host. */
99
+ notify(notice: {kind: 'approval' | 'input'; side?: 'left' | 'right'}): boolean;
100
+ /** Cancel the cue, preserving any in-progress connected pose recovery. */
101
+ clearNotice(): void;
96
102
  /** Sets the next throw's return style. Does not interrupt a fetch in progress. */
97
103
  setReturnStyle(style: FetchReturnStyle): FetchReturnStyle;
98
104
  readonly disposed: boolean;
package/retrieviewer.mjs CHANGED
@@ -167,6 +167,9 @@ export async function createPet({loadAsset=defaultLoadAsset,seed,energy,pawTrans
167
167
  },
168
168
  // 'ball' | 'treat' | 'tug'. False when busy or cooling down; unknown kinds throw.
169
169
  interact(kind){alive();if(!INTERACTIONS.includes(kind))throw TypeError(`Unknown interaction: ${kind}`);return life.interact(kind);},
170
+ // A bounded visual cue only; never clicks, types, focuses or approves anything.
171
+ notify(notice){alive();return life.notify(notice);},
172
+ clearNotice(){alive();life.clearNotice();},
170
173
  // Applies to the next ball throw; never rewrites a fetch already in motion.
171
174
  setReturnStyle(style){alive();return life.setReturnStyle(style);},
172
175
  get disposed(){return disposed;},