chartremotely 0.2.11__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 (66) hide show
  1. {chartremotely-0.2.11 → chartremotely-0.2.13}/PKG-INFO +48 -4
  2. {chartremotely-0.2.11 → chartremotely-0.2.13}/README.md +46 -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.11 → chartremotely-0.2.13}/chartremotely/patron.py +4 -1
  7. chartremotely-0.2.13/chartremotely/recent.py +50 -0
  8. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/requestlog.py +25 -3
  9. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/resolve.py +99 -7
  10. chartremotely-0.2.13/chartremotely/scales.py +188 -0
  11. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/server.py +55 -3
  12. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/setup.py +20 -6
  13. chartremotely-0.2.13/chartremotely/shortcut.py +74 -0
  14. chartremotely-0.2.13/chartremotely/understand.py +226 -0
  15. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/vocab.py +31 -5
  16. chartremotely-0.2.13/chartremotely/voice.py +127 -0
  17. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely.egg-info/PKG-INFO +48 -4
  18. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely.egg-info/SOURCES.txt +13 -2
  19. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely.egg-info/requires.txt +3 -0
  20. {chartremotely-0.2.11 → chartremotely-0.2.13}/pyproject.toml +4 -1
  21. chartremotely-0.2.13/tests/test_hearing.py +206 -0
  22. chartremotely-0.2.13/tests/test_recent.py +30 -0
  23. {chartremotely-0.2.11 → chartremotely-0.2.13}/tests/test_relay.py +4 -2
  24. {chartremotely-0.2.11 → chartremotely-0.2.13}/tests/test_requestlog.py +9 -0
  25. {chartremotely-0.2.11 → chartremotely-0.2.13}/tests/test_resolve.py +54 -1
  26. chartremotely-0.2.13/tests/test_scales.py +70 -0
  27. {chartremotely-0.2.11 → chartremotely-0.2.13}/tests/test_setup.py +17 -6
  28. chartremotely-0.2.13/tests/test_understand.py +87 -0
  29. chartremotely-0.2.13/tests/test_voice.py +186 -0
  30. chartremotely-0.2.11/chartremotely/scales.py +0 -77
  31. chartremotely-0.2.11/chartremotely/shortcut.py +0 -56
  32. {chartremotely-0.2.11 → chartremotely-0.2.13}/LICENSE +0 -0
  33. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/__init__.py +0 -0
  34. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/answer.py +0 -0
  35. /chartremotely-0.2.11/chartremotely/assets/shortcut-template.plist → /chartremotely-0.2.13/chartremotely/assets/shortcut-ask.plist +0 -0
  36. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/ax.py +0 -0
  37. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/cli.py +0 -0
  38. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/config.py +0 -0
  39. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/doctor.py +0 -0
  40. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/guilock.py +0 -0
  41. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/keystore.py +0 -0
  42. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/layout.py +0 -0
  43. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/mcpclient.py +0 -0
  44. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/popups.py +0 -0
  45. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/push.py +0 -0
  46. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/registry.py +0 -0
  47. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/relay.py +0 -0
  48. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/screen.py +0 -0
  49. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/services.py +0 -0
  50. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/snapshot.py +0 -0
  51. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/studies.py +0 -0
  52. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/symbol.py +0 -0
  53. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/tailnet.py +0 -0
  54. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/timeframe.py +0 -0
  55. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/window.py +0 -0
  56. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely.egg-info/dependency_links.txt +0 -0
  57. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely.egg-info/entry_points.txt +0 -0
  58. {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely.egg-info/top_level.txt +0 -0
  59. {chartremotely-0.2.11 → chartremotely-0.2.13}/setup.cfg +0 -0
  60. {chartremotely-0.2.11 → chartremotely-0.2.13}/tests/test_patron.py +0 -0
  61. {chartremotely-0.2.11 → chartremotely-0.2.13}/tests/test_popups.py +0 -0
  62. {chartremotely-0.2.11 → chartremotely-0.2.13}/tests/test_push.py +0 -0
  63. {chartremotely-0.2.11 → chartremotely-0.2.13}/tests/test_screen.py +0 -0
  64. {chartremotely-0.2.11 → chartremotely-0.2.13}/tests/test_snapshot.py +0 -0
  65. {chartremotely-0.2.11 → chartremotely-0.2.13}/tests/test_stamp_changelog.py +0 -0
  66. {chartremotely-0.2.11 → chartremotely-0.2.13}/tests/test_timeframe.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.2.11
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".
@@ -345,8 +373,22 @@ expired. Answer the DM on your phone to renew." — a fresh DM is already on its
345
373
  way, and once you answer it (within about half an hour) the next command
346
374
  works again.
347
375
 
376
+ **What Siri heard.** Dictation matches against all of English, so the Mac
377
+ repairs what it hears against the three small vocabularies it actually needs:
378
+ companies in the SEC list, the ten time frames, and your display names. A
379
+ scale may be a mnemonic, a bar size ("thirty minutes", "an hour", "four
380
+ hours", "a day") or one word that sounds like exactly one mnemonic ("have",
381
+ "alf" are half). A company may be mis-heard ("volunteer", "pal and tear" are
382
+ Palantir; "shop if y" is Shopify); when two companies are too close to call
383
+ you are asked - "Did you mean PS (Pershing Square) or MSGS (Madison Square
384
+ Garden)?" - rather than shown the wrong chart, and the symbols you chart
385
+ lately settle a near-tie. `understand()` parses a whole sentence the same way
386
+ ("Palantir half on the mini") - that is what the ChartRemotely Shortcut
387
+ uses on the sentence Whisper heard.
388
+
348
389
  Each command handled is logged as one line — time, the command's first word,
349
- the "Where?" as said, and the reply if it was an ERR — plus one line when its
390
+ the "Where?" as said, what was heard for a company or scale lookup, and the
391
+ reply if it was an ERR — plus one line when its
350
392
  tool call ends (the tool, "Where?" and any refusal) — to
351
393
  `~/Library/Logs/chartremotely-serve.log` (voice) or `chartremotely-relay.log`
352
394
  (from the operator). Never a token, a secret or a picture.
@@ -384,7 +426,9 @@ and runs `chartremotely setup`, which:
384
426
  3. gives the Mac its tailnet address with `tailscale serve`;
385
427
  4. installs the listener and the relay as launchd agents;
386
428
  5. asks macOS for Accessibility and Screen Recording for the Python that runs them;
387
- 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.
388
432
 
389
433
  Run it again at any time: finished steps are skipped. `chartremotely doctor`
390
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".
@@ -121,8 +148,22 @@ expired. Answer the DM on your phone to renew." — a fresh DM is already on its
121
148
  way, and once you answer it (within about half an hour) the next command
122
149
  works again.
123
150
 
151
+ **What Siri heard.** Dictation matches against all of English, so the Mac
152
+ repairs what it hears against the three small vocabularies it actually needs:
153
+ companies in the SEC list, the ten time frames, and your display names. A
154
+ scale may be a mnemonic, a bar size ("thirty minutes", "an hour", "four
155
+ hours", "a day") or one word that sounds like exactly one mnemonic ("have",
156
+ "alf" are half). A company may be mis-heard ("volunteer", "pal and tear" are
157
+ Palantir; "shop if y" is Shopify); when two companies are too close to call
158
+ you are asked - "Did you mean PS (Pershing Square) or MSGS (Madison Square
159
+ Garden)?" - rather than shown the wrong chart, and the symbols you chart
160
+ lately settle a near-tie. `understand()` parses a whole sentence the same way
161
+ ("Palantir half on the mini") - that is what the ChartRemotely Shortcut
162
+ uses on the sentence Whisper heard.
163
+
124
164
  Each command handled is logged as one line — time, the command's first word,
125
- the "Where?" as said, and the reply if it was an ERR — plus one line when its
165
+ the "Where?" as said, what was heard for a company or scale lookup, and the
166
+ reply if it was an ERR — plus one line when its
126
167
  tool call ends (the tool, "Where?" and any refusal) — to
127
168
  `~/Library/Logs/chartremotely-serve.log` (voice) or `chartremotely-relay.log`
128
169
  (from the operator). Never a token, a secret or a picture.
@@ -160,7 +201,9 @@ and runs `chartremotely setup`, which:
160
201
  3. gives the Mac its tailnet address with `tailscale serve`;
161
202
  4. installs the listener and the relay as launchd agents;
162
203
  5. asks macOS for Accessibility and Screen Recording for the Python that runs them;
163
- 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.
164
207
 
165
208
  Run it again at any time: finished steps are skipped. `chartremotely doctor`
166
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()