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.
- {chartremotely-0.2.12 → chartremotely-0.2.13}/PKG-INFO +35 -4
- {chartremotely-0.2.12 → chartremotely-0.2.13}/README.md +33 -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.12 → chartremotely-0.2.13}/chartremotely/requestlog.py +12 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/server.py +55 -3
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/setup.py +20 -6
- chartremotely-0.2.13/chartremotely/shortcut.py +74 -0
- chartremotely-0.2.13/chartremotely/voice.py +127 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely.egg-info/PKG-INFO +35 -4
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely.egg-info/SOURCES.txt +8 -2
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely.egg-info/requires.txt +3 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/pyproject.toml +4 -1
- chartremotely-0.2.13/tests/test_hearing.py +206 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_relay.py +4 -2
- {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_setup.py +17 -6
- chartremotely-0.2.13/tests/test_voice.py +186 -0
- chartremotely-0.2.12/chartremotely/shortcut.py +0 -56
- {chartremotely-0.2.12 → chartremotely-0.2.13}/LICENSE +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/__init__.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/answer.py +0 -0
- /chartremotely-0.2.12/chartremotely/assets/shortcut-template.plist → /chartremotely-0.2.13/chartremotely/assets/shortcut-ask.plist +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/ax.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/cli.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/config.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/doctor.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/guilock.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/keystore.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/layout.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/mcpclient.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/patron.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/popups.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/push.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/recent.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/registry.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/relay.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/resolve.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/scales.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/screen.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/services.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/snapshot.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/studies.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/symbol.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/tailnet.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/timeframe.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/understand.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/vocab.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely/window.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely.egg-info/dependency_links.txt +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely.egg-info/entry_points.txt +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/chartremotely.egg-info/top_level.txt +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/setup.cfg +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_patron.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_popups.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_push.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_recent.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_requestlog.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_resolve.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_scales.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_screen.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_snapshot.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_stamp_changelog.py +0 -0
- {chartremotely-0.2.12 → chartremotely-0.2.13}/tests/test_timeframe.py +0 -0
- {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.
|
|
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".
|
|
@@ -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.
|
|
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"
|
|
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.
|
|
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()
|