@bitefoundry/retrieviewer 0.0.13 → 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/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,24 @@
1
- # Retrieviewer 0.0.13 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)
2
22
 
3
23
  Current target: `@bitefoundry/retrieviewer@0.0.13`, public `latest`.
4
24
  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'});
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.
@@ -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;
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,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');};
@@ -70,6 +74,18 @@ try{
70
74
  starting();const {createPet}=await import('../../retrieviewer.mjs');starting();const pet=await createPet({returnStyle});
71
75
  if(stopping)pet.dispose();else{
72
76
  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);
77
+ if(!['off','stackbite'].includes(watchSource))throw Error('Invalid desktop watch source');
78
+ if(!['off','openrouter'].includes(brainSource)||brainSource==='openrouter'&&watchSource!=='stackbite')throw Error('Invalid desktop brain/watch combination');
79
+ if(brainSource==='openrouter'){const {createOpenRouterBrain}=await import('../openrouter/brain.mjs');starting();brain=createOpenRouterBrain();}
80
+ if(watchSource==='stackbite'){
81
+ const [{createAttentionMonitor},{createStackBiteSource}]=await Promise.all([import('../../retrieviewer-attention.mjs'),import('../stackbite/attention.mjs')]);starting();
82
+ const source=createStackBiteSource();
83
+ attention=createAttentionMonitor({readSnapshot:source.readSnapshot,...brain?{judgeNotice:input=>brain.decide(input)}:{},onNotice:notice=>pump.notify(notice),onClear:()=>pump.clearNotice(),onChange:status=>{
84
+ // Older POSIX helpers deliberately reject unknown message types. They can
85
+ // run the visual cue, but only the Windows bar implements this status label.
86
+ if(spec.mode==='windows-overlay')write({type:'attention',state:status.state,count:status.pendingCount});
87
+ }});
88
+ }
89
+ 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
90
  }
75
91
  }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.14",
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:'확인할 일을 발견하고 앉는 중',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,10 @@ 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
+ queue=[sit('notifySit'),cmd('look',[notice.side],'notify'),wait(2.5,'notify'),fx(()=>{notice=null;})];
242
+ }
239
243
  // Run instant effects; stop at the first hold or issued command (at most one command per update).
240
244
  for(;;){
241
245
  if(!queue.length){
@@ -257,6 +261,7 @@ export function createCompanionLife(actor,{seed=731,energy=.8,returnStyle='auto'
257
261
  if(!['ball','treat','tug'].includes(kind))return false;
258
262
  if(kind==='tug'&&typeof actor.tug!=='function')return false;
259
263
  if(pending||active||cooldowns[kind]>0)return false;
264
+ clearNotice();
260
265
  quiet=0;
261
266
  if(butterfly||lastEpisode==='butterfly'){
262
267
  butterfly=null;queue=[];hold=0;holdIntent=null;gesture=null;waiting=null;
@@ -304,6 +309,21 @@ export function createCompanionLife(actor,{seed=731,energy=.8,returnStyle='auto'
304
309
  return true;
305
310
  }
306
311
 
312
+ function clearNotice(){
313
+ if(!notice)return;
314
+ notice=null;queue=[];hold=0;holdIntent=null;gesture=null;waiting=null;
315
+ // Let any connected head turn/sit finish naturally. No snap or new route.
316
+ }
317
+ function notify({kind,side='left'}={}){
318
+ if(!['approval','input'].includes(kind)||!['left','right'].includes(side))throw Error('Invalid pet notice');
319
+ // Explicit play/feeding always wins. Notifications never queue a second pet.
320
+ if(active||pending||notice)return false;
321
+ notice={kind,side,phase:'queued'};
322
+ butterfly=null;queue=[];hold=0;holdIntent=null;gesture=null;waiting=null;
323
+ actor.attend?.();
324
+ return true;
325
+ }
326
+
307
327
  // Recomputed from the live pose so the marker stays truthful before and after sitting.
308
328
  function treatReport(){
309
329
  if(!treat)return null;
@@ -322,13 +342,14 @@ export function createCompanionLife(actor,{seed=731,energy=.8,returnStyle='auto'
322
342
  }
323
343
 
324
344
  return {
325
- update,interact,
345
+ update,interact,notify,clearNotice,
326
346
  setReturnStyle(style){validateReturnStyle(style);returnStyle=style;return style;},
327
347
  get report(){
328
348
  const interaction=active??pending;
329
349
  return {
330
350
  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
351
  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,
352
+ attention:notice?{...notice}:null,
332
353
  snore:actor.state==='sleep'?((clock%3.6)/3.6):null,energy:needs.energy,drowsiness:needs.drowsiness,decisions,cooldowns:{...cooldowns},
333
354
  // Local hold clock only: progress freezes between updates; null outside the stationary hold.
334
355
  gesture:gesture&&SAFE.has(actor.state)?{clip:gesture.clip,progress:clamp(gesture.elapsed/gesture.duration,0,1)}:null
@@ -0,0 +1,33 @@
1
+ export interface AttentionSnapshot {
2
+ source: string;
3
+ scope: string;
4
+ items: {id: string; generation: string; turnId: number | null; requestId?: string; kind: 'approval' | 'input'}[];
5
+ }
6
+ export interface AttentionStatus {
7
+ state: 'connecting' | 'connected' | 'unavailable' | 'paused' | 'stale' | 'off';
8
+ pendingCount: number;
9
+ retainedNotices: number;
10
+ inFlight: boolean;
11
+ judging?: boolean;
12
+ lastJudgment?: 'model_notify' | 'rule_override' | 'rule_fallback' | null;
13
+ }
14
+ export function validateAttentionSnapshot(value: unknown): AttentionSnapshot;
15
+ /** Host owns tick/lifetime. Import and construction start no timers or I/O. */
16
+ export function createAttentionMonitor(options: {
17
+ readSnapshot: (options: {signal: AbortSignal}) => Promise<AttentionSnapshot>;
18
+ onNotice: (notice: {kind: 'approval' | 'input'; side: 'left'}) => boolean;
19
+ onClear?: () => void;
20
+ onChange?: (status: AttentionStatus) => void;
21
+ /** Anonymous metadata only. Confirmed pending alerts cannot be suppressed. */
22
+ judgeNotice?: (input: {kind: 'approval' | 'input'; pendingCount: number; signal: AbortSignal}) => Promise<{decision: 'notify' | 'ignore' | 'uncertain'}>;
23
+ judgeTimeoutMs?: number;
24
+ now?: () => number;
25
+ intervalMs?: number;
26
+ staleMs?: number;
27
+ noticeCooldownMs?: number;
28
+ }): {
29
+ tick(options?: {active?: boolean}): void;
30
+ readonly status: AttentionStatus;
31
+ settled(): Promise<void>;
32
+ dispose(): void;
33
+ };
@@ -0,0 +1,99 @@
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
+ export function validateAttentionSnapshot(value){
6
+ if(!value||!bounded(value.source,32)||!bounded(value.scope,256)||!Array.isArray(value.items)||value.items.length>MAX_ITEMS)throw Error('Invalid attention snapshot');
7
+ const identities=new Set();
8
+ const items=value.items.map(item=>{
9
+ 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');
10
+ 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);
11
+ // Do not retain arbitrary source fields, output text, prompts, paths or tokens.
12
+ return {id:item.id,generation:item.generation,turnId:item.turnId,...item.requestId===undefined?{}:{requestId:item.requestId},kind:item.kind};
13
+ });
14
+ return {source:value.source,scope:value.scope,items};
15
+ }
16
+
17
+ export function createAttentionMonitor({readSnapshot,onNotice,onClear=()=>{},onChange=()=>{},judgeNotice,judgeTimeoutMs=3500,now=()=>performance.now(),intervalMs=5000,staleMs=15000,noticeCooldownMs=20000}={}){
18
+ if(typeof readSnapshot!=='function'||typeof onNotice!=='function'||typeof onClear!=='function'||typeof onChange!=='function'||typeof now!=='function')throw TypeError('Attention callbacks must be functions');
19
+ if(judgeNotice!==undefined&&typeof judgeNotice!=='function')throw TypeError('judgeNotice must be a function');
20
+ if(!Number.isFinite(judgeTimeoutMs)||judgeTimeoutMs<100||judgeTimeoutMs>15000)throw RangeError('Invalid judge timeout');
21
+ 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');
22
+ let disposed=false,paused=false,flight=null,nextRead=0,nextNotice=0,lastSuccess=null,snapshot=null,state='connecting',lastReport='',cueKey=null,judgment=null,lastJudgment=null;
23
+ const seen=new Map();
24
+ const identity=(value,item)=>JSON.stringify([value.source,value.scope,item.id,item.generation,item.turnId,item.requestId??null]);
25
+ const clearCue=()=>{if(cueKey!==null){cueKey=null;onClear();}};
26
+ const cancelJudgment=()=>{const old=judgment;judgment=null;old?.controller.abort();};
27
+ const report=()=>({state,pendingCount:snapshot?.items.length??0,retainedNotices:seen.size,inFlight:flight!==null,...judgeNotice?{judging:!!judgment&&!judgment.ready,lastJudgment}:{}});
28
+ const changed=()=>{const key=state+'|'+(snapshot?.items.length??0);if(key!==lastReport){lastReport=key;onChange(report());}};
29
+ const clear=next=>{snapshot=null;lastSuccess=null;state=next;cancelJudgment();clearCue();changed();};
30
+ function startRead(t){
31
+ const operation={controller:new AbortController()};flight=operation;nextRead=t+intervalMs;
32
+ operation.done=(async()=>{
33
+ try{
34
+ const result=validateAttentionSnapshot(await readSnapshot({signal:operation.controller.signal}));
35
+ if(disposed||paused||flight!==operation)return;
36
+ // A scope change cannot inherit either pending facts or dedupe identities.
37
+ if(snapshot&&(snapshot.scope!==result.scope||snapshot.source!==result.source))seen.clear();
38
+ snapshot=result;lastSuccess=now();state='connected';
39
+ if(cueKey!==null&&!snapshot.items.some(item=>identity(snapshot,item)===cueKey))clearCue();
40
+ if(judgment&&!snapshot.items.some(item=>identity(snapshot,item)===judgment.key))cancelJudgment();
41
+ }catch{
42
+ if(disposed||paused||flight!==operation)return;
43
+ // No source stderr, credentials, or stale pending facts escape on failure.
44
+ clear('unavailable');
45
+ }finally{
46
+ if(flight===operation){flight=null;nextRead=now()+intervalMs;if(!disposed&&!paused)changed();}
47
+ }
48
+ })();
49
+ }
50
+ return {
51
+ tick({active=true}={}){
52
+ if(disposed)return;
53
+ const t=now();if(!Number.isFinite(t))throw RangeError('Invalid attention clock');
54
+ if(!active){
55
+ if(!paused){paused=true;const old=flight;flight=null;old?.controller.abort();clear('paused');}
56
+ return;
57
+ }
58
+ if(paused){paused=false;nextRead=t;state='connecting';changed();}
59
+ if(snapshot&&t-lastSuccess>=staleMs)clear('stale');
60
+ if(!flight&&t>=nextRead)startRead(t);
61
+ // A refresh already in flight may resolve/replace the candidate. Do not emit
62
+ // an old cue in the microtask gap just before that fresh snapshot arrives.
63
+ if(!flight&&snapshot&&state==='connected'&&t<lastSuccess+staleMs&&t>=nextNotice){
64
+ // Approval and generic input for the same exact turn are one notification.
65
+ let item=null;
66
+ for(const row of snapshot.items)if(!seen.has(identity(snapshot,row))){if(!item)item=row;if(row.kind==='approval'){item=row;break;}}
67
+ if(item&&judgeNotice){
68
+ const key=identity(snapshot,item);
69
+ if(judgment?.key!==key){
70
+ cancelJudgment();const operation={key,controller:new AbortController(),ready:false,start:t};judgment=operation;
71
+ const facts={kind:item.kind,pendingCount:snapshot.items.length,signal:operation.controller.signal};
72
+ // Only anonymous enums/counts leave the coordinator; identities stay local.
73
+ operation.done=Promise.resolve().then(()=>{if(operation.controller.signal.aborted)throw Error('Canceled judgment');return judgeNotice(facts);}).then(result=>{
74
+ if(judgment===operation&&!disposed&&!paused&&!operation.controller.signal.aborted){operation.decision=['notify','ignore','uncertain'].includes(result?.decision)?result.decision:'uncertain';operation.ready=true;}
75
+ },()=>{if(judgment===operation&&!disposed&&!paused){operation.decision='uncertain';operation.ready=true;}});
76
+ }
77
+ if(!judgment.ready&&t-judgment.start>=judgeTimeoutMs){judgment.ready=true;judgment.decision='uncertain';judgment.controller.abort();}
78
+ if(!judgment.ready){changed();return;}
79
+ // These are confirmed pending human-input facts, not speculative screen
80
+ // guesses. A model may advise, but may never hide them or authorize input.
81
+ }
82
+ if(item&&onNotice({kind:item.kind,side:'left'})===true){
83
+ if(judgment)lastJudgment=judgment.decision==='notify'?'model_notify':judgment.decision==='ignore'?'rule_override':'rule_fallback';
84
+ const key=identity(snapshot,item);seen.set(key,true);cueKey=key;
85
+ // Still-pending identities cannot be evicted into repeated notifications.
86
+ const live=new Set(snapshot.items.map(row=>identity(snapshot,row)));
87
+ for(const prior of seen.keys()){if(seen.size<=MAX_SEEN)break;if(!live.has(prior))seen.delete(prior);}
88
+ nextNotice=t+noticeCooldownMs;
89
+ cancelJudgment();
90
+ }
91
+ }
92
+ changed();
93
+ },
94
+ get status(){return report();},
95
+ // For host shutdown/verification, not a second polling loop.
96
+ async settled(){await flight?.done;await judgment?.done;},
97
+ dispose(){if(disposed)return;disposed=true;const old=flight;flight=null;old?.controller.abort();cancelJudgment();clearCue();snapshot=null;seen.clear();state='off';},
98
+ };
99
+ }
@@ -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' | '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;},