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.
- {chartremotely-0.2.11 → chartremotely-0.2.13}/PKG-INFO +48 -4
- {chartremotely-0.2.11 → chartremotely-0.2.13}/README.md +46 -3
- chartremotely-0.2.13/chartremotely/assets/shortcut-voice.plist +259 -0
- chartremotely-0.2.13/chartremotely/displays.py +129 -0
- chartremotely-0.2.13/chartremotely/hearing.py +222 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/patron.py +4 -1
- chartremotely-0.2.13/chartremotely/recent.py +50 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/requestlog.py +25 -3
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/resolve.py +99 -7
- chartremotely-0.2.13/chartremotely/scales.py +188 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/server.py +55 -3
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/setup.py +20 -6
- chartremotely-0.2.13/chartremotely/shortcut.py +74 -0
- chartremotely-0.2.13/chartremotely/understand.py +226 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/vocab.py +31 -5
- chartremotely-0.2.13/chartremotely/voice.py +127 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely.egg-info/PKG-INFO +48 -4
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely.egg-info/SOURCES.txt +13 -2
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely.egg-info/requires.txt +3 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/pyproject.toml +4 -1
- chartremotely-0.2.13/tests/test_hearing.py +206 -0
- chartremotely-0.2.13/tests/test_recent.py +30 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/tests/test_relay.py +4 -2
- {chartremotely-0.2.11 → chartremotely-0.2.13}/tests/test_requestlog.py +9 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/tests/test_resolve.py +54 -1
- chartremotely-0.2.13/tests/test_scales.py +70 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/tests/test_setup.py +17 -6
- chartremotely-0.2.13/tests/test_understand.py +87 -0
- chartremotely-0.2.13/tests/test_voice.py +186 -0
- chartremotely-0.2.11/chartremotely/scales.py +0 -77
- chartremotely-0.2.11/chartremotely/shortcut.py +0 -56
- {chartremotely-0.2.11 → chartremotely-0.2.13}/LICENSE +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/__init__.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/answer.py +0 -0
- /chartremotely-0.2.11/chartremotely/assets/shortcut-template.plist → /chartremotely-0.2.13/chartremotely/assets/shortcut-ask.plist +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/ax.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/cli.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/config.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/doctor.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/guilock.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/keystore.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/layout.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/mcpclient.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/popups.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/push.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/registry.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/relay.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/screen.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/services.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/snapshot.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/studies.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/symbol.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/tailnet.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/timeframe.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely/window.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely.egg-info/dependency_links.txt +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely.egg-info/entry_points.txt +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/chartremotely.egg-info/top_level.txt +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/setup.cfg +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/tests/test_patron.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/tests/test_popups.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/tests/test_push.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/tests/test_screen.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/tests/test_snapshot.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.13}/tests/test_stamp_changelog.py +0 -0
- {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.
|
|
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"
|
|
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,
|
|
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.
|
|
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"
|
|
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,
|
|
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.
|
|
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()
|