chartremotely 0.2.12__tar.gz → 0.2.13__tar.gz

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.
Files changed (65) hide show
  1. {chartremotely-0.2.12 → chartremotely-0.2.13}/PKG-INFO +35 -4
  2. {chartremotely-0.2.12 → chartremotely-0.2.13}/README.md +33 -3
  3. chartremotely-0.2.13/chartremotely/assets/shortcut-voice.plist +259 -0
  4. chartremotely-0.2.13/chartremotely/displays.py +129 -0
  5. chartremotely-0.2.13/chartremotely/hearing.py +222 -0
  6. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/requestlog.py +12 -0
  7. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/server.py +55 -3
  8. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/setup.py +20 -6
  9. chartremotely-0.2.13/chartremotely/shortcut.py +74 -0
  10. chartremotely-0.2.13/chartremotely/voice.py +127 -0
  11. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely.egg-info/PKG-INFO +35 -4
  12. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely.egg-info/SOURCES.txt +8 -2
  13. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely.egg-info/requires.txt +3 -0
  14. {chartremotely-0.2.12 → chartremotely-0.2.13}/pyproject.toml +4 -1
  15. chartremotely-0.2.13/tests/test_hearing.py +206 -0
  16. {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_relay.py +4 -2
  17. {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_setup.py +17 -6
  18. chartremotely-0.2.13/tests/test_voice.py +186 -0
  19. chartremotely-0.2.12/chartremotely/shortcut.py +0 -56
  20. {chartremotely-0.2.12 → chartremotely-0.2.13}/LICENSE +0 -0
  21. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/__init__.py +0 -0
  22. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/answer.py +0 -0
  23. /chartremotely-0.2.12/chartremotely/assets/shortcut-template.plist → /chartremotely-0.2.13/chartremotely/assets/shortcut-ask.plist +0 -0
  24. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/ax.py +0 -0
  25. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/cli.py +0 -0
  26. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/config.py +0 -0
  27. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/doctor.py +0 -0
  28. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/guilock.py +0 -0
  29. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/keystore.py +0 -0
  30. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/layout.py +0 -0
  31. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/mcpclient.py +0 -0
  32. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/patron.py +0 -0
  33. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/popups.py +0 -0
  34. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/push.py +0 -0
  35. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/recent.py +0 -0
  36. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/registry.py +0 -0
  37. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/relay.py +0 -0
  38. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/resolve.py +0 -0
  39. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/scales.py +0 -0
  40. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/screen.py +0 -0
  41. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/services.py +0 -0
  42. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/snapshot.py +0 -0
  43. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/studies.py +0 -0
  44. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/symbol.py +0 -0
  45. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/tailnet.py +0 -0
  46. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/timeframe.py +0 -0
  47. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/understand.py +0 -0
  48. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/vocab.py +0 -0
  49. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/window.py +0 -0
  50. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely.egg-info/dependency_links.txt +0 -0
  51. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely.egg-info/entry_points.txt +0 -0
  52. {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely.egg-info/top_level.txt +0 -0
  53. {chartremotely-0.2.12 → chartremotely-0.2.13}/setup.cfg +0 -0
  54. {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_patron.py +0 -0
  55. {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_popups.py +0 -0
  56. {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_push.py +0 -0
  57. {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_recent.py +0 -0
  58. {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_requestlog.py +0 -0
  59. {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_resolve.py +0 -0
  60. {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_scales.py +0 -0
  61. {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_screen.py +0 -0
  62. {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_snapshot.py +0 -0
  63. {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_stamp_changelog.py +0 -0
  64. {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_timeframe.py +0 -0
  65. {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_understand.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.2.12
3
+ Version: 0.2.13
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -215,6 +215,7 @@ Requires-Dist: pyobjc-framework-ApplicationServices>=10; extra == "macos"
215
215
  Requires-Dist: pyobjc-framework-Cocoa>=10; extra == "macos"
216
216
  Requires-Dist: pyobjc-framework-Quartz>=10; extra == "macos"
217
217
  Requires-Dist: pyobjc-framework-Security>=10; extra == "macos"
218
+ Requires-Dist: mlx-whisper>=0.4.3; (sys_platform == "darwin" and platform_machine == "arm64") and extra == "macos"
218
219
  Provides-Extra: setup
219
220
  Requires-Dist: tollbooth-dpyc[nostr]==0.90.0; extra == "setup"
220
221
  Provides-Extra: dev
@@ -315,7 +316,34 @@ ignores everything sent to it.
315
316
 
316
317
  ## Voice
317
318
 
318
- "Hey Siri, ChartRemotely" asks three questions:
319
+ "Hey Siri, ChartRemotely", then **one sentence**:
320
+
321
+ > "Palantir, half, on mac mini." · "Shopify thirty minutes." · "Nvidia daily." · "GE hourly on the studio."
322
+
323
+ Say the company, the scale, and - if it is not this Mac - "on" the display.
324
+ The Shortcut records for about six seconds (tap to finish sooner where your
325
+ device offers it) and sends the recording to the Mac that made it. That Mac
326
+ hears it with **Whisper, running locally** on its own GPU (`mlx-whisper`,
327
+ Apple silicon only) - nothing is sent to Apple or anyone else to transcribe -
328
+ primed with the words it expects: the ten scale mnemonics, bar phrases like
329
+ "thirty minutes", the companies you chart lately and the names of your
330
+ displays. The recording is deleted as soon as it is heard; only the text is
331
+ logged.
332
+
333
+ If something is missing or unclear you are asked, and the Shortcut listens
334
+ again (up to three times), keeping what it already understood: "I heard
335
+ Nvidia but no scale. Say the scale, like half or daily. I'm listening." -
336
+ "Daily." Or "Did you mean PS (Pershing Square) or MSGS (Madison Square
337
+ Garden)? Say the one you meant. I'm listening."
338
+
339
+ **First run.** `chartremotely setup` fetches the speech model,
340
+ `whisper-large-v3-turbo` (about 1.6 GB, once, from Hugging Face). The
341
+ listener loads it when it starts, so a sentence takes about a second to hear.
342
+ Setup also learns your display names (the free `chart_agent_status`, asked at
343
+ most every four hours) so "on the studio" is understood.
344
+
345
+ **ChartRemotely Ask** is the fallback Shortcut, installed alongside: it asks
346
+ three questions and takes typed or dictated answers -
319
347
 
320
348
  1. **Which company?** — a spoken name or a spelled ticker.
321
349
  2. **What scale?** — a time frame mnemonic (below), or "as is".
@@ -355,7 +383,8 @@ Palantir; "shop if y" is Shopify); when two companies are too close to call
355
383
  you are asked - "Did you mean PS (Pershing Square) or MSGS (Madison Square
356
384
  Garden)?" - rather than shown the wrong chart, and the symbols you chart
357
385
  lately settle a near-tie. `understand()` parses a whole sentence the same way
358
- ("Palantir half on the mini").
386
+ ("Palantir half on the mini") - that is what the ChartRemotely Shortcut
387
+ uses on the sentence Whisper heard.
359
388
 
360
389
  Each command handled is logged as one line — time, the command's first word,
361
390
  the "Where?" as said, what was heard for a company or scale lookup, and the
@@ -397,7 +426,9 @@ and runs `chartremotely setup`, which:
397
426
  3. gives the Mac its tailnet address with `tailscale serve`;
398
427
  4. installs the listener and the relay as launchd agents;
399
428
  5. asks macOS for Accessibility and Screen Recording for the Python that runs them;
400
- 6. makes this Mac's ChartRemotely voice Shortcut and opens it for import.
429
+ 6. fetches the Whisper speech model (about 1.6 GB, once) and your display names;
430
+ 7. makes this Mac's two Shortcuts, "ChartRemotely" (one spoken sentence) and
431
+ "ChartRemotely Ask" (three questions), and opens them for import.
401
432
 
402
433
  Run it again at any time: finished steps are skipped. `chartremotely doctor`
403
434
  reports anything still missing.
@@ -91,7 +91,34 @@ ignores everything sent to it.
91
91
 
92
92
  ## Voice
93
93
 
94
- "Hey Siri, ChartRemotely" asks three questions:
94
+ "Hey Siri, ChartRemotely", then **one sentence**:
95
+
96
+ > "Palantir, half, on mac mini." · "Shopify thirty minutes." · "Nvidia daily." · "GE hourly on the studio."
97
+
98
+ Say the company, the scale, and - if it is not this Mac - "on" the display.
99
+ The Shortcut records for about six seconds (tap to finish sooner where your
100
+ device offers it) and sends the recording to the Mac that made it. That Mac
101
+ hears it with **Whisper, running locally** on its own GPU (`mlx-whisper`,
102
+ Apple silicon only) - nothing is sent to Apple or anyone else to transcribe -
103
+ primed with the words it expects: the ten scale mnemonics, bar phrases like
104
+ "thirty minutes", the companies you chart lately and the names of your
105
+ displays. The recording is deleted as soon as it is heard; only the text is
106
+ logged.
107
+
108
+ If something is missing or unclear you are asked, and the Shortcut listens
109
+ again (up to three times), keeping what it already understood: "I heard
110
+ Nvidia but no scale. Say the scale, like half or daily. I'm listening." -
111
+ "Daily." Or "Did you mean PS (Pershing Square) or MSGS (Madison Square
112
+ Garden)? Say the one you meant. I'm listening."
113
+
114
+ **First run.** `chartremotely setup` fetches the speech model,
115
+ `whisper-large-v3-turbo` (about 1.6 GB, once, from Hugging Face). The
116
+ listener loads it when it starts, so a sentence takes about a second to hear.
117
+ Setup also learns your display names (the free `chart_agent_status`, asked at
118
+ most every four hours) so "on the studio" is understood.
119
+
120
+ **ChartRemotely Ask** is the fallback Shortcut, installed alongside: it asks
121
+ three questions and takes typed or dictated answers -
95
122
 
96
123
  1. **Which company?** — a spoken name or a spelled ticker.
97
124
  2. **What scale?** — a time frame mnemonic (below), or "as is".
@@ -131,7 +158,8 @@ Palantir; "shop if y" is Shopify); when two companies are too close to call
131
158
  you are asked - "Did you mean PS (Pershing Square) or MSGS (Madison Square
132
159
  Garden)?" - rather than shown the wrong chart, and the symbols you chart
133
160
  lately settle a near-tie. `understand()` parses a whole sentence the same way
134
- ("Palantir half on the mini").
161
+ ("Palantir half on the mini") - that is what the ChartRemotely Shortcut
162
+ uses on the sentence Whisper heard.
135
163
 
136
164
  Each command handled is logged as one line — time, the command's first word,
137
165
  the "Where?" as said, what was heard for a company or scale lookup, and the
@@ -173,7 +201,9 @@ and runs `chartremotely setup`, which:
173
201
  3. gives the Mac its tailnet address with `tailscale serve`;
174
202
  4. installs the listener and the relay as launchd agents;
175
203
  5. asks macOS for Accessibility and Screen Recording for the Python that runs them;
176
- 6. makes this Mac's ChartRemotely voice Shortcut and opens it for import.
204
+ 6. fetches the Whisper speech model (about 1.6 GB, once) and your display names;
205
+ 7. makes this Mac's two Shortcuts, "ChartRemotely" (one spoken sentence) and
206
+ "ChartRemotely Ask" (three questions), and opens them for import.
177
207
 
178
208
  Run it again at any time: finished steps are skipped. `chartremotely doctor`
179
209
  reports anything still missing.
@@ -0,0 +1,259 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>WFWorkflowActions</key>
6
+ <array>
7
+ <dict>
8
+ <key>WFWorkflowActionIdentifier</key>
9
+ <string>is.workflow.actions.repeat.count</string>
10
+ <key>WFWorkflowActionParameters</key>
11
+ <dict>
12
+ <key>GroupingIdentifier</key>
13
+ <string>2A5B6C7D-8E9F-4A0B-8C1D-2E3F4A5B6C7D</string>
14
+ <key>WFControlFlowMode</key>
15
+ <integer>0</integer>
16
+ <key>WFRepeatCount</key>
17
+ <integer>3</integer>
18
+ </dict>
19
+ </dict>
20
+ <dict>
21
+ <key>WFWorkflowActionIdentifier</key>
22
+ <string>is.workflow.actions.recordaudio</string>
23
+ <key>WFWorkflowActionParameters</key>
24
+ <dict>
25
+ <key>CustomOutputName</key>
26
+ <string>Sentence</string>
27
+ <key>UUID</key>
28
+ <string>7B3E2C1A-4D5F-4A6B-8C9D-0E1F2A3B4C5D</string>
29
+ <key>WFRecordingCompression</key>
30
+ <string>Normal</string>
31
+ <key>WFRecordingEnd</key>
32
+ <string>After Time</string>
33
+ <key>WFRecordingStart</key>
34
+ <string>Immediately</string>
35
+ <key>WFRecordingTimeInterval</key>
36
+ <dict>
37
+ <key>Value</key>
38
+ <dict>
39
+ <key>Magnitude</key>
40
+ <string>6</string>
41
+ <key>Unit</key>
42
+ <string>sec</string>
43
+ </dict>
44
+ <key>WFSerializationType</key>
45
+ <string>WFQuantityFieldValue</string>
46
+ </dict>
47
+ </dict>
48
+ </dict>
49
+ <dict>
50
+ <key>WFWorkflowActionIdentifier</key>
51
+ <string>is.workflow.actions.downloadurl</string>
52
+ <key>WFWorkflowActionParameters</key>
53
+ <dict>
54
+ <key>Advanced</key>
55
+ <true/>
56
+ <key>CustomOutputName</key>
57
+ <string>Reply</string>
58
+ <key>ShowHeaders</key>
59
+ <true/>
60
+ <key>UUID</key>
61
+ <string>9C4F3D2B-5E6A-4B7C-9D0E-1F2A3B4C5D6E</string>
62
+ <key>WFHTTPBodyType</key>
63
+ <string>File</string>
64
+ <key>WFHTTPHeaders</key>
65
+ <dict>
66
+ <key>Value</key>
67
+ <dict>
68
+ <key>WFDictionaryFieldValueItems</key>
69
+ <array>
70
+ <dict>
71
+ <key>WFItemType</key>
72
+ <integer>0</integer>
73
+ <key>WFKey</key>
74
+ <dict>
75
+ <key>Value</key>
76
+ <dict>
77
+ <key>string</key>
78
+ <string>X-Token</string>
79
+ </dict>
80
+ <key>WFSerializationType</key>
81
+ <string>WFTextTokenString</string>
82
+ </dict>
83
+ <key>WFValue</key>
84
+ <dict>
85
+ <key>Value</key>
86
+ <dict>
87
+ <key>string</key>
88
+ <string>__CHARTREMOTELY_TOKEN__</string>
89
+ </dict>
90
+ <key>WFSerializationType</key>
91
+ <string>WFTextTokenString</string>
92
+ </dict>
93
+ </dict>
94
+ </array>
95
+ </dict>
96
+ <key>WFSerializationType</key>
97
+ <string>WFDictionaryFieldValue</string>
98
+ </dict>
99
+ <key>WFHTTPMethod</key>
100
+ <string>POST</string>
101
+ <key>WFRequestVariable</key>
102
+ <dict>
103
+ <key>Value</key>
104
+ <dict>
105
+ <key>OutputName</key>
106
+ <string>Sentence</string>
107
+ <key>OutputUUID</key>
108
+ <string>7B3E2C1A-4D5F-4A6B-8C9D-0E1F2A3B4C5D</string>
109
+ <key>Type</key>
110
+ <string>ActionOutput</string>
111
+ </dict>
112
+ <key>WFSerializationType</key>
113
+ <string>WFTextTokenAttachment</string>
114
+ </dict>
115
+ <key>WFURL</key>
116
+ <string>__CHARTREMOTELY_URL__?hear=1</string>
117
+ </dict>
118
+ </dict>
119
+ <dict>
120
+ <key>WFWorkflowActionIdentifier</key>
121
+ <string>is.workflow.actions.speaktext</string>
122
+ <key>WFWorkflowActionParameters</key>
123
+ <dict>
124
+ <key>WFSpeakTextWait</key>
125
+ <true/>
126
+ <key>WFText</key>
127
+ <dict>
128
+ <key>Value</key>
129
+ <dict>
130
+ <key>attachmentsByRange</key>
131
+ <dict>
132
+ <key>{0, 1}</key>
133
+ <dict>
134
+ <key>OutputName</key>
135
+ <string>Reply</string>
136
+ <key>OutputUUID</key>
137
+ <string>9C4F3D2B-5E6A-4B7C-9D0E-1F2A3B4C5D6E</string>
138
+ <key>Type</key>
139
+ <string>ActionOutput</string>
140
+ </dict>
141
+ </dict>
142
+ <key>string</key>
143
+ <string></string>
144
+ </dict>
145
+ <key>WFSerializationType</key>
146
+ <string>WFTextTokenString</string>
147
+ </dict>
148
+ </dict>
149
+ </dict>
150
+ <dict>
151
+ <key>WFWorkflowActionIdentifier</key>
152
+ <string>is.workflow.actions.conditional</string>
153
+ <key>WFWorkflowActionParameters</key>
154
+ <dict>
155
+ <key>GroupingIdentifier</key>
156
+ <string>4D6E7F80-9A1B-4C2D-8E3F-4A5B6C7D8E9F</string>
157
+ <key>WFCondition</key>
158
+ <integer>99</integer>
159
+ <key>WFConditionalActionString</key>
160
+ <string>listening</string>
161
+ <key>WFControlFlowMode</key>
162
+ <integer>0</integer>
163
+ <key>WFInput</key>
164
+ <dict>
165
+ <key>Type</key>
166
+ <string>Variable</string>
167
+ <key>Variable</key>
168
+ <dict>
169
+ <key>Value</key>
170
+ <dict>
171
+ <key>Aggrandizements</key>
172
+ <array>
173
+ <dict>
174
+ <key>CoercionItemClass</key>
175
+ <string>WFStringContentItem</string>
176
+ <key>Type</key>
177
+ <string>WFCoercionVariableAggrandizement</string>
178
+ </dict>
179
+ </array>
180
+ <key>OutputName</key>
181
+ <string>Reply</string>
182
+ <key>OutputUUID</key>
183
+ <string>9C4F3D2B-5E6A-4B7C-9D0E-1F2A3B4C5D6E</string>
184
+ <key>Type</key>
185
+ <string>ActionOutput</string>
186
+ </dict>
187
+ <key>WFSerializationType</key>
188
+ <string>WFTextTokenAttachment</string>
189
+ </dict>
190
+ </dict>
191
+ </dict>
192
+ </dict>
193
+ <dict>
194
+ <key>WFWorkflowActionIdentifier</key>
195
+ <string>is.workflow.actions.conditional</string>
196
+ <key>WFWorkflowActionParameters</key>
197
+ <dict>
198
+ <key>GroupingIdentifier</key>
199
+ <string>4D6E7F80-9A1B-4C2D-8E3F-4A5B6C7D8E9F</string>
200
+ <key>WFControlFlowMode</key>
201
+ <integer>1</integer>
202
+ </dict>
203
+ </dict>
204
+ <dict>
205
+ <key>WFWorkflowActionIdentifier</key>
206
+ <string>is.workflow.actions.exit</string>
207
+ <key>WFWorkflowActionParameters</key>
208
+ <dict/>
209
+ </dict>
210
+ <dict>
211
+ <key>WFWorkflowActionIdentifier</key>
212
+ <string>is.workflow.actions.conditional</string>
213
+ <key>WFWorkflowActionParameters</key>
214
+ <dict>
215
+ <key>GroupingIdentifier</key>
216
+ <string>4D6E7F80-9A1B-4C2D-8E3F-4A5B6C7D8E9F</string>
217
+ <key>UUID</key>
218
+ <string>6F8091A2-BC3D-4E4F-A051-6C7D8E9FA0B1</string>
219
+ <key>WFControlFlowMode</key>
220
+ <integer>2</integer>
221
+ </dict>
222
+ </dict>
223
+ <dict>
224
+ <key>WFWorkflowActionIdentifier</key>
225
+ <string>is.workflow.actions.repeat.count</string>
226
+ <key>WFWorkflowActionParameters</key>
227
+ <dict>
228
+ <key>GroupingIdentifier</key>
229
+ <string>2A5B6C7D-8E9F-4A0B-8C1D-2E3F4A5B6C7D</string>
230
+ <key>UUID</key>
231
+ <string>5E7F8091-AB2C-4D3E-9F40-5B6C7D8E9FA0</string>
232
+ <key>WFControlFlowMode</key>
233
+ <integer>2</integer>
234
+ </dict>
235
+ </dict>
236
+ </array>
237
+ <key>WFWorkflowClientVersion</key>
238
+ <string>2605.0.3</string>
239
+ <key>WFWorkflowIcon</key>
240
+ <dict>
241
+ <key>WFWorkflowIconGlyphNumber</key>
242
+ <integer>59511</integer>
243
+ <key>WFWorkflowIconStartColor</key>
244
+ <integer>463140863</integer>
245
+ </dict>
246
+ <key>WFWorkflowImportQuestions</key>
247
+ <array/>
248
+ <key>WFWorkflowInputContentItemClasses</key>
249
+ <array/>
250
+ <key>WFWorkflowMinimumClientVersion</key>
251
+ <integer>900</integer>
252
+ <key>WFWorkflowMinimumClientVersionString</key>
253
+ <string>900</string>
254
+ <key>WFWorkflowTypes</key>
255
+ <array>
256
+ <string>Watch</string>
257
+ </array>
258
+ </dict>
259
+ </plist>
@@ -0,0 +1,129 @@
1
+ """The owner's display names, so a sentence can say where: "... on mac mini".
2
+
3
+ :func:`understand.understand` splits "on <display>" off a sentence only when
4
+ it knows the names, and :mod:`hearing` primes Whisper with them. They come
5
+ from the operator's free ``chart_agent_status`` tool, asked as the owner
6
+ (their npub and voice sign-in), and are cached in
7
+ ``~/.local/share/chartremotely/displays.json`` - labels only, never a token.
8
+
9
+ The list changes only when a display is paired or forgotten, so it is asked
10
+ for at most every :data:`MAX_AGE` seconds, after setup, and in the background:
11
+ a voice request never waits for it, and uses what is cached (this Mac's own
12
+ label at least) meanwhile.
13
+
14
+ Stdlib only.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import threading
21
+ import time
22
+ from collections.abc import Callable
23
+
24
+ from . import config, keystore, mcpclient, requestlog
25
+ from .config import DATA_DIR
26
+
27
+ PATH = DATA_DIR / "displays.json"
28
+ MAX_AGE = 4 * 3600
29
+ TOOL = "chart_agent_status"
30
+
31
+
32
+ def _read(path=None) -> dict:
33
+ try:
34
+ data = json.loads((path or PATH).read_text())
35
+ except (OSError, ValueError):
36
+ return {}
37
+ return data if isinstance(data, dict) else {}
38
+
39
+
40
+ def load(path=None) -> list[str]:
41
+ """The cached display labels, this Mac's first; [] when none are cached."""
42
+ data = _read(path)
43
+ labels = data.get("labels")
44
+ return [str(x) for x in labels if isinstance(x, str) and x.strip()] if isinstance(labels, list) else []
45
+
46
+
47
+ def stale(path=None, now: Callable[[], float] = time.time) -> bool:
48
+ fetched = _read(path).get("fetched_at")
49
+ return not isinstance(fetched, (int, float)) or now() - fetched > MAX_AGE
50
+
51
+
52
+ def labels_from(payload: dict, agent_id: str | None) -> list[str]:
53
+ """Display labels from a ``chart_agent_status`` answer, this Mac's first."""
54
+ rows = payload.get("displays") if isinstance(payload, dict) else None
55
+ if not isinstance(rows, list):
56
+ return []
57
+ mine, others = [], []
58
+ for row in rows:
59
+ if not isinstance(row, dict):
60
+ continue
61
+ label = " ".join(str(row.get("label") or "").split())[:64]
62
+ if not label:
63
+ continue
64
+ (mine if agent_id and row.get("agent_id") == agent_id else others).append(label)
65
+ out: list[str] = []
66
+ for label in mine + others:
67
+ if label not in out:
68
+ out.append(label)
69
+ return out
70
+
71
+
72
+ def refresh(cfg: dict | None = None, client_for=None, path=None) -> list[str] | None:
73
+ """Ask the operator for the owner's displays and cache them.
74
+
75
+ Returns the labels, or None when they could not be had (not paired, not
76
+ signed in, operator unreachable) - the cache is then left as it was.
77
+ Never raises.
78
+ """
79
+ cfg = cfg or config.load()
80
+ base = (cfg.get("operator_url") or "").rstrip("/")
81
+ npub = cfg.get("owner_npub")
82
+ if not (base and npub and cfg.get("agent_id")):
83
+ return None
84
+ try:
85
+ token = keystore.load_token(npub)
86
+ if not token:
87
+ return None
88
+ payload = (client_for or mcpclient.Client)(base, timeout=30.0).call(TOOL, {"npub": npub, "dpop_token": token})
89
+ except Exception as exc: # noqa: BLE001 - a missing list must never cost a reply
90
+ requestlog.outcome("serve", TOOL, "", f"display list failed: {type(exc).__name__}")
91
+ return None
92
+ if payload.get("error") or payload.get("success") is False:
93
+ requestlog.outcome("serve", TOOL, "", str(payload.get("error") or "refused"))
94
+ return None
95
+ labels = labels_from(payload, cfg.get("agent_id"))
96
+ target = path or PATH
97
+ try:
98
+ target.parent.mkdir(parents=True, exist_ok=True)
99
+ tmp = target.with_suffix(".tmp")
100
+ tmp.write_text(json.dumps({"fetched_at": time.time(), "labels": labels}))
101
+ tmp.replace(target)
102
+ except OSError:
103
+ pass
104
+ return labels
105
+
106
+
107
+ _refreshing = threading.Lock()
108
+
109
+
110
+ def refresh_in_background(**kwargs) -> bool:
111
+ """Start one :func:`refresh`; False when one is already running."""
112
+ if not _refreshing.acquire(blocking=False):
113
+ return False
114
+
115
+ def work() -> None:
116
+ try:
117
+ refresh(**kwargs)
118
+ finally:
119
+ _refreshing.release()
120
+
121
+ threading.Thread(target=work, name="refresh-displays", daemon=True).start()
122
+ return True
123
+
124
+
125
+ def current() -> list[str]:
126
+ """The labels to use now; starts a background refresh when they are old."""
127
+ if stale():
128
+ refresh_in_background()
129
+ return load()