@bitefoundry/retrieviewer 0.0.14 → 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 CHANGED
@@ -58,8 +58,14 @@ a remote server does not automatically give a local Windows pet remote access.
58
58
 
59
59
  ## Behavior and limits
60
60
 
61
- - A new exact Room/Seat/generation/turn/request cues one connected sit-and-look-left.
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.
62
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.
63
69
  - Explicit ball/treat/tug interactions win. A rejected cue retries only while
64
70
  its source fact is fresh. Distinct cues are at least 20 seconds apart.
65
71
  - Resolved/disconnected/stale facts clear the cue. Source errors are unavailable,
@@ -76,11 +82,35 @@ These are separate short runs, not a steady-state/peak comparison or a guarantee
76
82
  of 1 MiB overhead. Native-helper and transient StackBite CLI memory are additional.
77
83
  No new image payload, model or runtime package dependency was added.
78
84
 
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
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
81
93
  integration remains unverified. Capture/overlay support is a separate platform
82
94
  question. No new Wayland or AlmaLinux compatibility claim is made here.
83
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
+
84
114
  ## Reuse decision
85
115
 
86
116
  The first source already exists in StackBite, so a fixed-argv adapter is smaller
package/BRAIN.md CHANGED
@@ -55,17 +55,21 @@ truncated output, tool calls and responses over 32 KiB. No returned text is run,
55
55
  typed, used as a coordinate, or displayed as a factual summary.
56
56
 
57
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;
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;
62
64
  this slice establishes a bounded backend before optional richer perception.
63
65
 
64
- Each exact pending item is judged once while fresh. Resolution, generation
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
65
68
  replacement, stale source, pause, hide and shutdown cancel its request; late
66
69
  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`
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`
69
73
  allows 8 seconds for diagnosis, not for normal pet reactions.
70
74
 
71
75
  ## Resource and request limits
@@ -109,7 +113,13 @@ pending event, and worker/painter both exited. Node RSS was about 128 MiB in tha
109
113
  short run, excluding helper/CLI memory. This is not an incremental or steady-state
110
114
  RAM measurement. Existing live companion state was not changed.
111
115
 
112
- All 217 applicable tests passed (two macOS-only checks skipped), including mocked
116
+ For the 0.0.14 baseline, all 217 applicable tests passed (two macOS-only checks skipped), including mocked
113
117
  JSON validation, price ceilings, timeout, cache, budget, cancellation and stale
114
118
  results. The earlier free Gemma attempt returned 429 and never produced a verified
115
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
@@ -109,6 +109,8 @@ npm uninstall -g @bitefoundry/retrieviewer
109
109
  ## StackBite 알림
110
110
 
111
111
  0.0.14부터 [승인·입력 대기 알림](ATTENTION.md)을 선택해서 켤 수 있어요.
112
+ 0.0.15는 LLM을 기다리지 않고 알리고, Windows에 Seat 이름과 대기 종류를 표시해요.
113
+ 강아지도 기존 그림으로 가까이 와서 알림 방향을 봐요. 이름은 외부로 보내지 않아요.
112
114
  화면 캡처나 자동 승인 없이 현재 Room의 메타데이터만 읽어요.
113
115
  업데이트 후, 화면이 보이는 PC의 실행 중인 StackBite Seat에서 실행하세요.
114
116
 
package/README.md CHANGED
@@ -137,6 +137,10 @@ It is opt-in and does not capture screens or operate other apps.
137
137
  An [optional paid DeepSeek/OpenRouter backend](./BRAIN.md) can judge anonymous event metadata;
138
138
  offline rules remain the default and preserve confirmed pending alerts.
139
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
+
140
144
  After updating, run inside a live StackBite Seat on the display computer:
141
145
 
142
146
  ```sh
package/RELEASING.md CHANGED
@@ -1,4 +1,22 @@
1
- # Retrieviewer 0.0.14 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)
2
20
 
3
21
  Current target: `@bitefoundry/retrieviewer@0.0.14`, public `latest`.
4
22
  On 2026-10-24 the owner authorized npm publication of the metadata-only StackBite
@@ -24,11 +24,11 @@ export function projectStackBiteSeats(value,{roomId}={}){
24
24
  const turnId=Number.isSafeInteger(status.turn_id)&&status.turn_id>0?status.turn_id:null;
25
25
  const requestId=identifier(status.input_request_id)?status.input_request_id:undefined;
26
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'});
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
28
  }else{
29
29
  // Older explicit lifecycle facts are accepted only with an exact turn.
30
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'});
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
32
  }
33
33
  }
34
34
  return validateAttentionSnapshot({source:'stackbite',scope:roomId,items});
@@ -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;string attentionText=null;
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.
@@ -154,8 +154,8 @@ sealed class Companion : ApplicationContext {
154
154
  if(type=="interaction"){status.Text=attentionText??(Program.Bool(m,"accepted")?"Ready!":"Please wait");continue;}
155
155
  if(type=="attention"){
156
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;
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
159
  continue;
160
160
  }
161
161
  if(type=="error"){status.Text="Engine error";Quit();return;}
@@ -216,7 +216,7 @@ sealed class Companion : ApplicationContext {
216
216
  }
217
217
  }
218
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();}
219
- 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);}
220
220
  }
221
221
 
222
222
  static class Program {
@@ -226,6 +226,17 @@ static class Program {
226
226
  internal static int Int(Dictionary<string,object> m,string key){return Convert.ToInt32(m[key]);}
227
227
  internal static string Str(Dictionary<string,object> m,string key){return Convert.ToString(m[key]);}
228
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
+ }
229
240
  [STAThread] static int Main(string[] args){
230
241
  try{
231
242
  // GUI-subsystem processes have redirected handles but no console code
@@ -269,8 +280,9 @@ static class Program {
269
280
  Native.ShowWindow(target.Handle,4);Application.DoEvents();bool restored=!Native.IsIconic(target.Handle);
270
281
  bool focus=Native.GetForegroundWindow()==foreground;
271
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;
272
- bool passed=clickThrough&&noActivate&&layered&&follows&&minimized&&hidden&&restored&&focus&&pet.PaintedDots>0&&gridBounds;
273
- Emit(new {type="self-test",passed=passed,gridBounds=gridBounds,clickThroughStyle=clickThrough,noActivateStyle=noActivate,layered=layered,followGeometry=follows,expectedX=expectedX,expectedY=expectedY,actualX=actual.Left,actualY=actual.Top,minimize=minimized,hide=hidden,restore=restored,foregroundPreserved=focus,dots=pet.PaintedDots,proof=proof});return passed?0:1;
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;
274
286
  }
275
287
  }
276
288
  }
@@ -10,7 +10,7 @@ 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
+ let attention=null,brain=null,attentionLabel=null;
14
14
  const returnStyle=process.env.RETRIEVIEWER_RETURN_STYLE??'auto';
15
15
  const watchSource=process.env.RETRIEVIEWER_WATCH??'off';
16
16
  const brainSource=process.env.RETRIEVIEWER_BRAIN??'off';
@@ -44,7 +44,7 @@ try{
44
44
  body+=bytes.toString('utf8');if(body.length>2048)return socket.destroy();if(!body.includes('\n'))return;
45
45
  let command;try{command=JSON.parse(body.split('\n')[0]);}catch{return socket.destroy();}
46
46
  if(command.token!==session.token||!['status','stop'].includes(command.command))return socket.destroy();
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');
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');
48
48
  if(command.command==='stop')stop();
49
49
  });
50
50
  });
@@ -66,6 +66,7 @@ try{
66
66
  else if(m.type==='picker')pickerVisible=m.visible===true;
67
67
  else if(m.type==='viewport'){pendingViewport=m;visible=m.visible;selected=m.selected===true;pump?.viewport(m);}
68
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;}
69
70
  else if(m.type==='pause')pump?.pause(m.paused);
70
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});}
71
72
  else if(m.type==='quit')stop();
@@ -83,7 +84,7 @@ try{
83
84
  attention=createAttentionMonitor({readSnapshot:source.readSnapshot,...brain?{judgeNotice:input=>brain.decide(input)}:{},onNotice:notice=>pump.notify(notice),onClear:()=>pump.clearNotice(),onChange:status=>{
84
85
  // Older POSIX helpers deliberately reject unknown message types. They can
85
86
  // 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
+ if(spec.mode==='windows-overlay')write({type:'attention',state:status.state,count:status.pendingCount,label:status.target?.label??'',kind:status.target?.kind??''});
87
88
  }});
88
89
  }
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitefoundry/retrieviewer",
3
- "version": "0.0.14",
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/",
@@ -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:'간식 먹는 중',notifySit:'확인할 일을 발견하고 앉는 중',notify:'확인할 일이 있는 쪽을 바라보는 중'
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.
@@ -238,7 +238,11 @@ export function createCompanionLife(actor,{seed=731,energy=.8,returnStyle='auto'
238
238
  if(pending&&!active)startInteraction();
239
239
  if(notice?.phase==='queued'&&!active&&!pending){
240
240
  notice.phase='showing';
241
- queue=[sit('notifySit'),cmd('look',[notice.side],'notify'),wait(2.5,'notify'),fx(()=>{notice=null;})];
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;})];
242
246
  }
243
247
  // Run instant effects; stop at the first hold or issued command (at most one command per update).
244
248
  for(;;){
@@ -311,7 +315,10 @@ export function createCompanionLife(actor,{seed=731,energy=.8,returnStyle='auto'
311
315
 
312
316
  function clearNotice(){
313
317
  if(!notice)return;
318
+ const approaching=notice.phase==='approaching';
314
319
  notice=null;queue=[];hold=0;holdIntent=null;gesture=null;waiting=null;
320
+ intent='recover';label=LABEL.recover;
321
+ if(approaching)actor.attend?.();
315
322
  // Let any connected head turn/sit finish naturally. No snap or new route.
316
323
  }
317
324
  function notify({kind,side='left'}={}){
@@ -1,15 +1,17 @@
1
1
  export interface AttentionSnapshot {
2
2
  source: string;
3
3
  scope: string;
4
- items: {id: string; generation: string; turnId: number | null; requestId?: string; kind: 'approval' | 'input'}[];
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}[];
5
6
  }
6
7
  export interface AttentionStatus {
7
8
  state: 'connecting' | 'connected' | 'unavailable' | 'paused' | 'stale' | 'off';
8
9
  pendingCount: number;
10
+ target: {seatId: string; label: string; kind: 'approval' | 'input'} | null;
9
11
  retainedNotices: number;
10
12
  inFlight: boolean;
11
13
  judging?: boolean;
12
- lastJudgment?: 'model_notify' | 'rule_override' | 'rule_fallback' | null;
14
+ lastJudgment?: 'rule_first' | 'model_notify' | 'rule_override' | 'rule_fallback' | null;
13
15
  }
14
16
  export function validateAttentionSnapshot(value: unknown): AttentionSnapshot;
15
17
  /** Host owns tick/lifetime. Import and construction start no timers or I/O. */
@@ -18,7 +20,7 @@ export function createAttentionMonitor(options: {
18
20
  onNotice: (notice: {kind: 'approval' | 'input'; side: 'left'}) => boolean;
19
21
  onClear?: () => void;
20
22
  onChange?: (status: AttentionStatus) => void;
21
- /** Anonymous metadata only. Confirmed pending alerts cannot be suppressed. */
23
+ /** Anonymous metadata only. Runs after the immediate rule alert, never delays or suppresses it. */
22
24
  judgeNotice?: (input: {kind: 'approval' | 'input'; pendingCount: number; signal: AbortSignal}) => Promise<{decision: 'notify' | 'ignore' | 'uncertain'}>;
23
25
  judgeTimeoutMs?: number;
24
26
  now?: () => number;
@@ -2,14 +2,18 @@
2
2
  // Screen/app text is data, never an instruction or an executable action.
3
3
  const MAX_ITEMS=32,MAX_SEEN=128;
4
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;
5
7
  export function validateAttentionSnapshot(value){
6
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');
7
9
  const identities=new Set();
8
10
  const items=value.items.map(item=>{
9
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');
10
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);
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
+ // 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}:{}};
13
17
  });
14
18
  return {source:value.source,scope:value.scope,items};
15
19
  }
@@ -19,14 +23,19 @@ export function createAttentionMonitor({readSnapshot,onNotice,onClear=()=>{},onC
19
23
  if(judgeNotice!==undefined&&typeof judgeNotice!=='function')throw TypeError('judgeNotice must be a function');
20
24
  if(!Number.isFinite(judgeTimeoutMs)||judgeTimeoutMs<100||judgeTimeoutMs>15000)throw RangeError('Invalid judge timeout');
21
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');
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;
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='';
23
27
  const seen=new Map();
24
28
  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();}};
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();}};
26
35
  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();};
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();};
30
39
  function startRead(t){
31
40
  const operation={controller:new AbortController()};flight=operation;nextRead=t+intervalMs;
32
41
  operation.done=(async()=>{
@@ -38,6 +47,7 @@ export function createAttentionMonitor({readSnapshot,onNotice,onClear=()=>{},onC
38
47
  snapshot=result;lastSuccess=now();state='connected';
39
48
  if(cueKey!==null&&!snapshot.items.some(item=>identity(snapshot,item)===cueKey))clearCue();
40
49
  if(judgment&&!snapshot.items.some(item=>identity(snapshot,item)===judgment.key))cancelJudgment();
50
+ refreshTarget();
41
51
  }catch{
42
52
  if(disposed||paused||flight!==operation)return;
43
53
  // No source stderr, credentials, or stale pending facts escape on failure.
@@ -58,35 +68,33 @@ export function createAttentionMonitor({readSnapshot,onNotice,onClear=()=>{},onC
58
68
  if(paused){paused=false;nextRead=t;state='connecting';changed();}
59
69
  if(snapshot&&t-lastSuccess>=staleMs)clear('stale');
60
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
+ }
61
77
  // A refresh already in flight may resolve/replace the candidate. Do not emit
62
78
  // an old cue in the microtask gap just before that fresh snapshot arrives.
63
79
  if(!flight&&snapshot&&state==='connected'&&t<lastSuccess+staleMs&&t>=nextNotice){
64
80
  // Approval and generic input for the same exact turn are one notification.
65
81
  let item=null;
66
82
  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;
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;
71
89
  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
90
  operation.done=Promise.resolve().then(()=>{if(operation.controller.signal.aborted)throw Error('Canceled judgment');return judgeNotice(facts);}).then(result=>{
74
91
  if(judgment===operation&&!disposed&&!paused&&!operation.controller.signal.aborted){operation.decision=['notify','ignore','uncertain'].includes(result?.decision)?result.decision:'uncertain';operation.ready=true;}
75
92
  },()=>{if(judgment===operation&&!disposed&&!paused){operation.decision='uncertain';operation.ready=true;}});
76
93
  }
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
94
  // Still-pending identities cannot be evicted into repeated notifications.
86
95
  const live=new Set(snapshot.items.map(row=>identity(snapshot,row)));
87
96
  for(const prior of seen.keys()){if(seen.size<=MAX_SEEN)break;if(!live.has(prior))seen.delete(prior);}
88
97
  nextNotice=t+noticeCooldownMs;
89
- cancelJudgment();
90
98
  }
91
99
  }
92
100
  changed();
@@ -94,6 +102,6 @@ export function createAttentionMonitor({readSnapshot,onNotice,onClear=()=>{},onC
94
102
  get status(){return report();},
95
103
  // For host shutdown/verification, not a second polling loop.
96
104
  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';},
105
+ dispose(){if(disposed)return;disposed=true;const old=flight;flight=null;old?.controller.abort();cancelJudgment();snapshot=null;refreshTarget();clearCue();seen.clear();state='off';},
98
106
  };
99
107
  }
@@ -41,7 +41,7 @@ export interface PetState {
41
41
  }
42
42
  export interface LifeReport {
43
43
  /** Metadata-triggered visual cue only. No host input or approval authority. */
44
- attention: {kind: 'approval' | 'input'; side: 'left' | 'right'; phase: 'queued' | 'showing'} | null;
44
+ attention: {kind: 'approval' | 'input'; side: 'left' | 'right'; phase: 'queued' | 'approaching' | 'showing'} | null;
45
45
  /** Last selected autonomous episode; null before the first decision. */
46
46
  episode: string | null;
47
47
  intent: string;