@ham2k/extension-sdk 0.5.7 → 0.7.0

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.
@@ -1,3 +1,4 @@
1
+ import { TemplateError, renderTemplate } from "./templates.js";
1
2
  function utcDate(millis) {
2
3
  return new Date(millis).toISOString().slice(0, 10);
3
4
  }
@@ -59,10 +60,19 @@ function opValues(operation, extra = {}) {
59
60
  function qsoValues(qso) {
60
61
  const their = qso.their ?? {};
61
62
  const our = qso.our ?? {};
63
+ const callField = qso.callField ?? {};
64
+ const guess = their.guess ?? {};
62
65
  const startMillis = Number(qso.startAtMillis ?? 0);
63
66
  const serialSent = refField(qso.refs, "ourSerial");
64
67
  return {
65
- call: their.call ?? "",
68
+ // `||`, not `??`: a blank reading still means "no call", and the field
69
+ // as typed is a better answer than keying nothing.
70
+ call: callField.call || their.call || "",
71
+ // The panel's split of `call` — a logged contact's call is a full one.
72
+ fullCall: callField.fullCall ?? their.call ?? "",
73
+ partial: callField.partial ?? "",
74
+ nextCall: callField.nextCall ?? "",
75
+ nextPartial: callField.nextPartial ?? "",
66
76
  their,
67
77
  our,
68
78
  band: qso.band ?? "",
@@ -80,12 +90,26 @@ function qsoValues(qso) {
80
90
  // that is where these get typed.
81
91
  rst: our.sent ?? "",
82
92
  serial: serialSent,
93
+ // What was typed, else what the lookup found — the two places a CW
94
+ // message's "GM BOB" or "NY" would otherwise have to be chained by hand.
95
+ // `name` is the first word only: a lookup's "Robert J Smith" keyed in
96
+ // full is not how anyone greets a station.
97
+ state: firstText(their.state, guess.state),
98
+ name: firstText(their.name, guess.name).split(/\s+/)[0],
83
99
  notes: qso.notes ?? "",
84
100
  refs: qso.refs ?? [],
85
101
  ...startMillis > 0 ? dateValues(startMillis) : { date: "", dateCompact: "", time: "", at: "" },
86
102
  startAtMillis: startMillis
87
103
  };
88
104
  }
105
+ function firstText(...values) {
106
+ for (const value of values) {
107
+ if (typeof value !== "string") continue;
108
+ const text = value.trim();
109
+ if (text !== "") return text;
110
+ }
111
+ return "";
112
+ }
89
113
  function refField(refs, field) {
90
114
  if (!Array.isArray(refs)) return "";
91
115
  for (const ref of refs) {
@@ -115,7 +139,7 @@ function logValues(values) {
115
139
  }
116
140
  function templateContext(args) {
117
141
  const nowMillis = args.nowMillis ?? Date.now();
118
- return {
142
+ const context = {
119
143
  app: { name: args.appName ?? "" },
120
144
  now: utcIso(nowMillis),
121
145
  ...args.operation ? { op: opValues(args.operation, { qsoCount: args.qsoCount, atMillis: args.atMillis }) } : {},
@@ -123,6 +147,23 @@ function templateContext(args) {
123
147
  ...args.log ? { log: logValues(args.log) } : {},
124
148
  ...args.config ? { config: args.config } : {}
125
149
  };
150
+ if (args.keyer) context.keyer = keyerValues(context, args.keyer.messages ?? [], args.keyer.current ? [args.keyer.current] : []);
151
+ return context;
152
+ }
153
+ function keyerValues(base, messages, chain) {
154
+ const keyer = {};
155
+ messages.forEach((template, i) => {
156
+ const n = i + 1;
157
+ Object.defineProperty(keyer, `msg${n}`, {
158
+ enumerable: true,
159
+ get() {
160
+ const path = [...chain, n];
161
+ if (chain.includes(n)) throw new TemplateError(`a message includes itself: ${path.map((k) => `F${k}`).join(" \u2192 ")}`);
162
+ return renderTemplate(template ?? "", { ...base, keyer: keyerValues(base, messages, path) });
163
+ }
164
+ });
165
+ });
166
+ return keyer;
126
167
  }
127
168
  export {
128
169
  dateValues,
@@ -0,0 +1,187 @@
1
+ const MAX_TIMERS_PER_EXTENSION = 16;
2
+ const FIRE_COST_MILLIS = 1;
3
+ const TIMER_DUTY_CYCLE = 0.1;
4
+ const TIMER_BURST_MILLIS = 5e3;
5
+ const MAX_CONSECUTIVE_FAILURES = 5;
6
+ const RUN_SLICE_MILLIS = 1e3;
7
+ const MAX_DELAY_MILLIS = 2 ** 31 - 1;
8
+ function createTimerQueue(host) {
9
+ const timers = /* @__PURE__ */ new Map();
10
+ const accounts = /* @__PURE__ */ new Set();
11
+ let nextId = 1;
12
+ let armed = null;
13
+ let inCallback = null;
14
+ function rearm() {
15
+ let earliest = null;
16
+ for (const t of timers.values()) {
17
+ if (earliest === null || t.deadline < earliest) earliest = t.deadline;
18
+ }
19
+ if (earliest === armed) return;
20
+ armed = earliest;
21
+ host.wake(earliest === null ? null : Math.max(0, Math.ceil(earliest - host.realNow())));
22
+ }
23
+ function suspend(owner, reason) {
24
+ if (owner.suspended || owner.revoked) return;
25
+ owner.suspended = reason;
26
+ for (const t of [...timers.values()]) {
27
+ if (t.owner === owner) timers.delete(t.id);
28
+ }
29
+ const why = {
30
+ budget: `its callbacks used more than ${TIMER_DUTY_CYCLE * 100}% of the time`,
31
+ failures: `${MAX_CONSECUTIVE_FAILURES} callbacks in a row failed`,
32
+ interrupted: "a callback ran past the engine's time limit"
33
+ }[reason];
34
+ host.log(`ERROR ${owner.key} timers suspended: ${why}`);
35
+ host.suspended(owner.key, reason);
36
+ rearm();
37
+ }
38
+ function cancelAll(owner) {
39
+ for (const t of [...timers.values()]) {
40
+ if (t.owner === owner) timers.delete(t.id);
41
+ }
42
+ }
43
+ function refuse(owner, why) {
44
+ if (!owner.refusalsLogged.has(why)) {
45
+ owner.refusalsLogged.add(why);
46
+ host.log(`ERROR ${owner.key} set a timer ${why}; it will not fire`);
47
+ }
48
+ return nextId++;
49
+ }
50
+ function charge(owner, cost) {
51
+ const now = host.realNow();
52
+ owner.debt = Math.max(0, owner.debt - (now - owner.chargedAt) * TIMER_DUTY_CYCLE) + cost;
53
+ owner.chargedAt = now;
54
+ if (owner.debt > TIMER_BURST_MILLIS) suspend(owner, "budget");
55
+ }
56
+ function failed(owner, e) {
57
+ host.log(`ERROR ${owner.key} timer callback: ${host.describeError(e)}`);
58
+ owner.failures++;
59
+ if (owner.failures >= MAX_CONSECUTIVE_FAILURES) suspend(owner, "failures");
60
+ }
61
+ function succeeded(owner) {
62
+ owner.failures = 0;
63
+ }
64
+ function fire(t, wakeCost) {
65
+ const startedAt = host.realNow();
66
+ let result;
67
+ let threw = false;
68
+ inCallback = t;
69
+ try {
70
+ result = t.callback(...t.args);
71
+ } catch (e) {
72
+ threw = true;
73
+ failed(t.owner, e);
74
+ }
75
+ inCallback = null;
76
+ charge(t.owner, host.realNow() - startedAt + wakeCost);
77
+ if (threw) return;
78
+ succeeded(t.owner);
79
+ if (result instanceof Promise) {
80
+ result.catch((e) => host.log(`ERROR ${t.owner.key} timer callback: ${host.describeError(e)}`));
81
+ }
82
+ }
83
+ return {
84
+ owner(key) {
85
+ const owner = { key, debt: 0, chargedAt: host.realNow(), failures: 0, suspended: null, revoked: false, refusalsLogged: /* @__PURE__ */ new Set() };
86
+ accounts.add(owner);
87
+ return owner;
88
+ },
89
+ set(handle, callback, delay, repeat, args) {
90
+ const owner = handle;
91
+ if (typeof callback !== "function") throw new TypeError("timer callback must be a function");
92
+ if (owner.revoked) return refuse(owner, "after it was unloaded");
93
+ if (owner.suspended) return refuse(owner, "while its timers are suspended");
94
+ let live = 0;
95
+ for (const t of timers.values()) if (t.owner === owner) live++;
96
+ if (live >= MAX_TIMERS_PER_EXTENSION) return refuse(owner, `beyond its ${MAX_TIMERS_PER_EXTENSION} live timers`);
97
+ const n = Number(delay);
98
+ const ms = n > 0 ? Math.min(Math.trunc(n), MAX_DELAY_MILLIS) : 0;
99
+ const id = nextId++;
100
+ timers.set(id, {
101
+ id,
102
+ owner,
103
+ callback,
104
+ args,
105
+ deadline: host.realNow() + ms,
106
+ interval: repeat ? ms : null
107
+ });
108
+ rearm();
109
+ return id;
110
+ },
111
+ /// Ids are guessable, so one owner's clear never reaches another's timer.
112
+ clear(handle, id) {
113
+ const t = timers.get(id);
114
+ if (!t || t.owner !== handle) return;
115
+ timers.delete(t.id);
116
+ rearm();
117
+ },
118
+ /// Fires what was due when it started. A timer set by one of these
119
+ /// callbacks waits for the next wake even at zero delay: running it here
120
+ /// would let a zero-delay chain spin inside a single evaluation.
121
+ run() {
122
+ armed = null;
123
+ if (inCallback) {
124
+ const cutShort = inCallback;
125
+ inCallback = null;
126
+ suspend(cutShort.owner, "interrupted");
127
+ }
128
+ const now = host.realNow();
129
+ const due = [...timers.values()].filter((t) => t.deadline <= now).sort((a, b) => a.deadline - b.deadline || a.id - b.id);
130
+ const woken = /* @__PURE__ */ new Set();
131
+ for (const t of due) {
132
+ if (host.realNow() - now >= RUN_SLICE_MILLIS) break;
133
+ if (timers.get(t.id) !== t) continue;
134
+ if (t.interval === null) timers.delete(t.id);
135
+ const wakeCost = woken.has(t.owner) ? 0 : FIRE_COST_MILLIS;
136
+ woken.add(t.owner);
137
+ fire(t, wakeCost);
138
+ if (t.interval !== null && timers.get(t.id) === t) t.deadline = host.realNow() + t.interval;
139
+ }
140
+ rearm();
141
+ },
142
+ /// Cancels what one activation — one that failed — has set, and nothing
143
+ /// of a newer activation's: a dev reload can start the new one before the
144
+ /// old one's failure arrives. The handle stays good, because the hooks
145
+ /// that activation registered before failing are still called.
146
+ cancel(handle) {
147
+ cancelAll(handle);
148
+ rearm();
149
+ },
150
+ /// Revokes every activation of [keys]: their timers go, their handles
151
+ /// ignore new ones, and a reloaded extension starts clean on a new handle.
152
+ drop(keys) {
153
+ for (const owner of [...accounts]) {
154
+ if (!keys.includes(owner.key)) continue;
155
+ owner.revoked = true;
156
+ accounts.delete(owner);
157
+ cancelAll(owner);
158
+ }
159
+ rearm();
160
+ }
161
+ };
162
+ }
163
+ function removeAmbientTimers(global = globalThis) {
164
+ for (const name of ["setTimeout", "setInterval", "clearTimeout", "clearInterval"]) {
165
+ try {
166
+ delete global[name];
167
+ } catch (e) {
168
+ }
169
+ if (typeof global[name] === "function") {
170
+ try {
171
+ global[name] = void 0;
172
+ } catch (e) {
173
+ }
174
+ }
175
+ }
176
+ return ["setTimeout", "setInterval", "clearTimeout", "clearInterval"].every((name) => typeof global[name] !== "function");
177
+ }
178
+ export {
179
+ FIRE_COST_MILLIS,
180
+ MAX_CONSECUTIVE_FAILURES,
181
+ MAX_TIMERS_PER_EXTENSION,
182
+ RUN_SLICE_MILLIS,
183
+ TIMER_BURST_MILLIS,
184
+ TIMER_DUTY_CYCLE,
185
+ createTimerQueue,
186
+ removeAmbientTimers
187
+ };
package/dist/timers.js ADDED
@@ -0,0 +1,42 @@
1
+ const bound = {};
2
+ let preferredKey = null;
3
+ let activated = false;
4
+ function adoptExtensionTimers(key, currentKey, timers) {
5
+ activated = true;
6
+ preferredKey = currentKey;
7
+ if (timers) bound[key] = timers;
8
+ }
9
+ function boundTimers() {
10
+ return (preferredKey !== null ? bound[preferredKey] : void 0) ?? Object.values(bound)[0];
11
+ }
12
+ const refusalsLogged = /* @__PURE__ */ new Set();
13
+ function set(callback, delay, repeat, args) {
14
+ const timers = boundTimers();
15
+ if (timers) return timers.set(callback, delay, repeat, args);
16
+ const why = activated ? "this version of HaLo has no timers" : "set before the extension activated";
17
+ if (!refusalsLogged.has(why)) {
18
+ refusalsLogged.add(why);
19
+ const who = preferredKey !== null ? `${preferredKey}: ` : "";
20
+ globalThis.__polo?.log(`ERROR ${who}a timer was ignored: ${why}`);
21
+ }
22
+ return 0;
23
+ }
24
+ function setTimeout(callback, delay, ...args) {
25
+ return set(callback, delay, false, args);
26
+ }
27
+ function setInterval(callback, delay, ...args) {
28
+ return set(callback, delay, true, args);
29
+ }
30
+ function clearTimeout(id) {
31
+ boundTimers()?.clear(id);
32
+ }
33
+ function clearInterval(id) {
34
+ clearTimeout(id);
35
+ }
36
+ export {
37
+ adoptExtensionTimers,
38
+ clearInterval,
39
+ clearTimeout,
40
+ setInterval,
41
+ setTimeout
42
+ };
@@ -37,7 +37,7 @@ The same manifest a built-in extension carries, plus `api`:
37
37
  "version": "1.2.3",
38
38
  "description": "Notes about the stations I work",
39
39
  "category": "dashboard",
40
- "api": 1,
40
+ "api": 2,
41
41
  "domains": ["notes.example.org"],
42
42
  "icon": "note-text"
43
43
  }
@@ -45,7 +45,12 @@ The same manifest a built-in extension carries, plus `api`:
45
45
 
46
46
  `api` is the extension API the bundle was built against. The host refuses one
47
47
  it does not speak, so an older app meeting a newer bundle says so instead of
48
- failing somewhere deep in a hook.
48
+ failing somewhere deep in a hook — and the catalog does not list it for that
49
+ app at all. Declare the lowest version that has what the bundle uses, so it
50
+ reaches every app that can run it: 2 adds `host.webSocket`, and the packer
51
+ refuses a manifest declaring `webSockets` under anything less; 3 adds timers
52
+ and `onShow`/`onHide`, and from 3 the build rewrites a bare `setTimeout` into
53
+ the SDK's (README's "Timers").
49
54
 
50
55
  Three fields are **refused** in a distributed bundle, and it is worth
51
56
  understanding why:
@@ -254,7 +259,7 @@ It reports every problem at once rather than one per run:
254
259
  h2kext-pack: ./build is not ready to package:
255
260
  manifest.json: missing required field 'name'
256
261
  manifest.json: key 'my-clock' must start with your callsign — 'my' is not one (e.g. ki2d-my-clock)
257
- manifest.json: missing 'api' — declare the extension API this was built against (currently 1)
262
+ manifest.json: missing 'api' — declare the extension API this was built against (currently 3)
258
263
  ```
259
264
 
260
265
  `--force-name` waives the naming convention below. It exists for Ham2K's own
@@ -318,8 +323,9 @@ needs one degrades the same way it does in a build without that value
318
323
  configured.
319
324
 
320
325
  The sandbox is otherwise the same one the built-in extensions run in — no
321
- filesystem, no network beyond the `domains` the manifest declares and the user
322
- approved, no timers.
326
+ filesystem, no network beyond the `domains` and `webSockets` the manifest
327
+ declares and the user approved, and timers only within their budget
328
+ (README's "Timers").
323
329
 
324
330
  ## Installing one
325
331
 
@@ -342,6 +348,14 @@ that key is compared — a bundle can change completely and still ask for the
342
348
  same things. That may be worth revisiting once a bundle can be shown to come
343
349
  from whoever it claims; it is not worth it while a key is all there is.
344
350
 
351
+ **Update all is the one exception.** It takes every pending catalog update on
352
+ a single confirmation that names each extension and the version it moves to,
353
+ and skips the per-extension screen for each of them: asking once per entry for
354
+ the one thing the operator asked for once is how a screen gets pressed through
355
+ unread. It is a confirmation of a job the operator started, never a way to
356
+ start one — `install_consent_scope_test` holds it to the one function that
357
+ shows that confirmation first.
358
+
345
359
  Removing an extension removes what it stored with it.
346
360
 
347
361
  ### Installing from a link
@@ -477,7 +491,14 @@ takes the plain one above.
477
491
  At most once every six hours — on launch and on resume — the app asks the
478
492
  catalog whether anything installed has a newer release, and says so in the
479
493
  status bar and on the row; the notice comes down with the last update taken.
480
- It never installs unasked. A release the catalog has revoked is put on record
494
+ It never installs unasked: the notice's **Update all** is the panel's own,
495
+ behind the same confirmation, and **More info** opens the panel.
496
+
497
+ An install or update narrates itself as one status-bar progress item, from the
498
+ first downloaded byte to "Installed", and the Extensions panel mirrors that
499
+ item on the row being installed. Installs share one queue
500
+ (`ExtensionInstaller`): downloads run side by side, while the writes go one at
501
+ a time, since each restarts the runtime. A release the catalog has revoked is put on record
481
502
  (`extensionRevoked`: version and note) and stops loading on the spot,
482
503
  whatever the operator's own switch says — the row shows "Revoked" with the
483
504
  note and no switch, and the record dies with an install of another version
package/docs/hooks.md CHANGED
@@ -2,11 +2,30 @@
2
2
 
3
3
  Hooks are registered under a **category** with an optional **key** (defaults
4
4
  to the extension key) and **priority** (higher runs first; default 0). The
5
- host invokes them two ways:
5
+ host invokes them three ways:
6
6
 
7
7
  - `invokeHook(category, key?, method, args)` — best (highest-priority) hook.
8
8
  - `invokeHookAll(category, method, args)` — every hook in the category, one
9
9
  bridge round-trip, per-source error isolation.
10
+ - `invokeHookBatch(calls)` — several `invokeHook` calls, one bridge
11
+ round-trip, per-call error isolation. Each call reaches the same hook it
12
+ would reach on its own, so a hook cannot tell a batched call from a single
13
+ one.
14
+
15
+ The categories are a closed list. `registerHook` takes a `HookCategory`
16
+ ([`extensions/sdk/src/hookCategories.ts`](https://github.com/ham2k/halo/blob/main/extensions/sdk/src/hookCategories.ts))
17
+ or `ref:<type>`, so a misspelled category fails the typecheck rather than
18
+ registering a hook nothing calls, and the build rejects a manifest `hooks`
19
+ entry that is neither. A new category goes into that list — and, when the host
20
+ calls it, into the host's catalog as well
21
+ (`packages/halo_core/lib/src/hook_names.dart`).
22
+
23
+ In both shapes that answer many hooks at once, one hook failing costs its own
24
+ entry and nothing else — whether it throws, has no handler, or answers
25
+ something JSON cannot carry (a circular object, a `BigInt`). One that runs out
26
+ of time does too, when the caller asks for partial results; otherwise, and for
27
+ a batch of several calls that runs out of time before any answer has reached
28
+ the host, the whole call fails.
10
29
 
11
30
  All argument and result types below are defined in
12
31
  [`extensions/sdk/src/types.ts`](https://github.com/ham2k/halo/blob/main/extensions/sdk/src/types.ts). Every
@@ -422,6 +441,18 @@ interface RefHandlerHook {
422
441
  etc.); results update ref chips asynchronously in the UI. Implemented by:
423
442
  `pota` (both types).
424
443
 
444
+ **The core asks about references in batches.** Every reference an ADIF import
445
+ or a deep link brings is decorated in one bridge round-trip, and so is every
446
+ link a list of search results needs, every outline a map draws, and every
447
+ phrase of an operation's title. Each reference is still its own call, to the
448
+ handler `invokeHook` would pick for it (qualified claims included), so a
449
+ handler implements the single-reference methods above and nothing else. The
450
+ time budget belongs to the batch, though: ten seconds for all of its calls
451
+ together. A handler that has not answered by then costs only its own
452
+ reference — it stays as it was, with no name, link or outline — and every
453
+ other reference keeps its answer. A `decorateRef` that goes to the network
454
+ should answer well within that.
455
+
425
456
  `linkForRef` says where a reference can be read about on the web — the
426
457
  program's own page for it (POTA's park page, SOTA's summit page), or, for a
427
458
  contest, the rules the event is run under. The Operation Setup sheet asks for
@@ -499,11 +530,13 @@ interface ActivityHook {
499
530
  suggest?(args: SuggestArgs, ctx): Promise<ActivitySuggestion[]>
500
531
  processQsoBeforeSave?(args: { qso, operation }, ctx): Promise<Patch | null>
501
532
  }
502
- // LoggingControlDescriptor: {key, label, shortLabel?, icon?, color?, order?,
503
- // optionType?, allowsMultiple?, editable?, input}
533
+ // LoggingControlDescriptor: {key, label, shortLabel?, skipFocus?, icon?, color?,
534
+ // order?, optionType?, allowsMultiple?, editable?, input}
504
535
  // `shortLabel` is the same name in fewer characters, for a field the logging
505
536
  // row has squeezed below its label's width. The core measures and picks; the
506
537
  // extension only says what the short form is.
538
+ // `skipFocus: true` takes a main logging field out of the Space and Tab cycle;
539
+ // the arrow keys still reach it. Every shipped contest sets it on Our Serial.
507
540
  // SuggestArgs: {operation, location?: {lat, lon}, callsign?, searchTerm?, scoped?}
508
541
  // ActivitySuggestion: Ref & {distance?, relevance?, allowsMultiple?}
509
542
 
@@ -596,8 +629,12 @@ true when THIS extension alone was asked — the operator tapped its "Activity
596
629
  Types" row or typed its key as a scope — which a bare scope otherwise cannot be
597
630
  told from the nearby list, since neither carries a `searchTerm`. An extension
598
631
  that keeps itself out of the nearby list (`cwt`, offered only around a session)
599
- answers a scoped call anyway: it is the one being asked. The core fans
600
- out to every `activity` hook via `invokeHookAll`, merges results, and ranks them
632
+ answers a scoped call anyway: it is the one being asked. The core fans out to
633
+ every `activity` hook via `invokeHookAll`; a scoped search asks only its own
634
+ hooks, in one batch (`invokeHookBatch`); and a search naming a type without the
635
+ colon asks that type's hooks in one batch AND fans out to every hook — two
636
+ round-trips, kept apart so a fan-out that runs out of time cannot cost the named
637
+ type's answers. The core merges results and ranks them
601
638
  in TWO BANDS (`app/lib/tools/activity_suggestions.dart`): everything carrying a
602
639
  `distance` first, by `distance / relevance` ascending — a more relevant hit
603
640
  counts as nearer — then everything without one, by DESCENDING `relevance`, with
@@ -713,9 +750,38 @@ file claims its own reference; ask every hook and both programs'
713
750
  program to guess which is its. The delegate cannot work out who called it, so
714
751
  naming yourself is not optional.
715
752
 
716
- The full ADIF export names no `mainHandler` and every hook contributes —
717
- polo's `includeOtherRefs`, which only its full export sets. That is the export
718
- claiming no program: the complete copy the operator keeps.
753
+ The full ADIF export names no `mainHandler`, and every program the contact
754
+ belongs to contributes — polo's `includeOtherRefs`, which only its full export
755
+ sets. That is the export claiming no program: the complete copy the operator
756
+ keeps.
757
+
758
+ **The full export asks only the extensions a contact belongs to.** For each
759
+ reference on the contact's operation (the segment-effective one) and on the
760
+ contact itself, the kernel takes every extension holding the strongest claim
761
+ on it — the longest `ref:<type>/<code>` prefix that begins the reference's
762
+ code, or else the bare `ref:<type>` — and asks those extensions' hooks, and no
763
+ other's. A hunted reference on the contact counts as much as an activation on
764
+ the operation, so a chaser's log keeps its `SOTA_REF`s. A longer claim excludes
765
+ a shorter one: an old `{type: 'qp', ref: 'CA'}` reference belongs to the party
766
+ that claims `qp/ca`, not also to the extension claiming all of `qp`. Equal
767
+ claims are all asked, whatever their priority: WCA also registers
768
+ `ref:ecaActivation` to resolve a castle while ECA is off, and ECA's fields
769
+ must not depend on which of the two registered first. An installed extension the
770
+ contact has nothing to do with is never asked, so a hook that forgets to check
771
+ its own references costs nothing here; checking them is still how a hook
772
+ behaves correctly when named by a program's own export.
773
+
774
+ An extension that genuinely has something to say about every contact opts in
775
+ from its **manifest**, `"adifFieldsScope": "always"`, and is asked whatever the
776
+ contact carries. The field lives in the manifest, not on the hook, so the
777
+ packer can check it without running the bundle: a manifest listing
778
+ `adifFields` with neither a `ref:` type nor the opt-in is refused at packing
779
+ and at publishing, and the app's own build refuses it too. The kernel reads the
780
+ opt-in from the extension's definition, so spread the manifest into
781
+ `defineExtension`, and it logs an error at activation for an extension that
782
+ registers `adifFields` but answers for no reference and does not opt in.
783
+ `mainHandler` and `includeFieldsFrom` are unaffected: a named hook is asked
784
+ whatever the contact carries.
719
785
 
720
786
  An export can also name a **list** of other hooks it accepts,
721
787
  `includeFieldsFrom` — polo's all-or-nothing flag narrowed to what the program
@@ -728,13 +794,13 @@ would claim no reference at all.
728
794
 
729
795
  **A field name is written once per record, and the first to carry a value
730
796
  wins.** ADIF gives no meaning to a repeated field, so where two hooks answer
731
- the same name — the full export asks everyone, and a park and a summit both
732
- answer `MY_SIG` — the later one is dropped, and the contact's own fields
797
+ the same name — the full export asks every program on the contact, and a park
798
+ and a summit both answer `MY_SIG` — the later one is dropped, and the contact's own fields
733
799
  outrank all of them. Hooks are asked main handler first, then each
734
800
  `includeFieldsFrom` key in the order given, so that order decides — whichever
735
801
  of the two methods each hook answered. app-polo resolves a collision the same
736
- way ("keep the first one defined"). The **full export**, which asks everyone,
737
- has no such order: hooks answering `fieldCombinationsForOneQSO` are asked as one
802
+ way ("keep the first one defined"). The **full export**, which asks every
803
+ program on the contact, has no such order: hooks answering `fieldCombinationsForOneQSO` are asked as one
738
804
  group and hooks answering `fieldsForOneQSO` as another, so which of two programs
739
805
  keeps a shared `MY_SIG` there is not something either of them chose.
740
806
 
@@ -1161,8 +1227,10 @@ standing beside your credit, on the one row that can show only one.
1161
1227
  A scorer's internal *rules* stay private to its scoresheet: contests don't
1162
1228
  share a rule vocabulary, so only notices, alerts, points and summary tallies
1163
1229
  cross the boundary. `scoreQso` **may mutate** the scoresheet it's given and
1164
- return it; copying accumulated state per QSO is quadratic, and the harness
1165
- copies any checkpoint at its boundary.
1230
+ return it; copying accumulated state per QSO is quadratic. The batch pass
1231
+ copies the checkpoint it resumes from, once; the live paths hand the scorer a
1232
+ copy-on-write view of it instead (`copyOnWrite` in the SDK), so a candidate's
1233
+ writes land in the view and the held sheet is never touched.
1166
1234
 
1167
1235
  ```ts
1168
1236
  // The wire shape the harness produces, for reference:
@@ -1171,17 +1239,19 @@ interface ScoringHook {
1171
1239
  scoreQso?(args: ScoreQsoRequest, ctx): Promise<QsoScoreNotices>
1172
1240
  scoreCandidates?(args: ScoreCandidatesRequest, ctx): Promise<ScoreCandidatesResult>
1173
1241
  }
1174
- // ScoreQsosRequest: {operation, qsos, ref?, segments?, resumeFrom?, resumeDay?}
1242
+ // ScoreQsosRequest: {operation, qsos, ref?, segments?, resumeFrom?, resumeDay?, resumeKey?, checkpointKey?}
1175
1243
  // ScoreQsosResult: {
1176
1244
  // qsoScores: Record<uuid, QsoScoreVerdict>, // one per QSO claimed; see above
1177
1245
  // daySections: {day, count, scores: Record<tallyKey, ScoreTally>}[],
1178
1246
  // operationSummary: Record<tallyKey, ScoreTally>,
1179
1247
  // scoresheet, // checkpoint to resume from
1180
1248
  // scoresheetDay?, // the day it ends on, handed back as resumeDay
1249
+ // checkpointHeld?, // true: the harness kept the end sheet under checkpointKey
1181
1250
  // }
1182
- // ScoreQsoRequest: {operation, qso, excludeUuid?, ref?, segments?, resumeFrom?, resumeDay?, qsos?}
1183
- // ScoreCandidatesRequest: {operation, candidates: {key, qso}[], ref?, segments?, resumeFrom?, resumeDay?, qsos?}
1184
- // ScoreCandidatesResult: Record<candidateKey, QsoScoreNotices>
1251
+ // ScoreQsoRequest: {operation, qso, excludeUuid?, ref?, segments?, resumeFrom?, resumeDay?, resumeKey?, qsos?}
1252
+ // QsoScoreNotices: {notices, alerts, dupe?, checkpointHeld?}
1253
+ // ScoreCandidatesRequest: {operation, candidates: {key, qso}[], ref?, segments?, resumeFrom?, resumeDay?, resumeKey?, qsos?}
1254
+ // ScoreCandidatesResult: Record<candidateKey, QsoScoreNotices> + {"$checkpointHeld": true} when a checkpoint was named
1185
1255
  ```
1186
1256
 
1187
1257
  All three arrive already implemented by `contestScorer()`; a scorer writes the
@@ -1192,23 +1262,48 @@ both live paths for free.
1192
1262
  A candidate that carries no `startAtMillis` (the draft's time is still
1193
1263
  automatic) reaches the scorer stamped with the current time on both live
1194
1264
  paths, so a day-bucketed rule judges it as of now rather than at the epoch; a
1195
- candidate that states a time keeps it. On both live paths a `resumeFrom`
1196
- checkpoint, with the `qsos` logged since it, stands in for the whole-log read:
1197
- the harness copies the checkpoint, replays that tail onto it (continuing the
1198
- day `resumeDay` names, so no scorer's per-day tally restarts mid-day) and
1199
- scores the candidate against the result. The host sends a checkpoint only when
1200
- it can prove the log is what the checkpoint folded plus appended contacts;
1201
- otherwise the harness reads the log through `ctx.getQsos` and folds it cold.
1202
- The batched `scoreQsos` resumes the same way: after the first pass of a
1203
- session, an append arrives as `resumeFrom` plus the appended contacts, and only
1204
- their verdicts come back.
1265
+ candidate that states a time keeps it. On both live paths a checkpoint, with
1266
+ the `qsos` logged since it, stands in for the whole-log read: the harness
1267
+ views the checkpoint copy-on-write, replays that tail onto the view
1268
+ (continuing the day `resumeDay` names, so no scorer's per-day tally restarts
1269
+ mid-day) and scores the candidate against the result. The host sends a
1270
+ checkpoint only when it can prove the log is what the checkpoint folded plus
1271
+ appended contacts; otherwise the harness reads the log through `ctx.getQsos`
1272
+ and folds it cold. The batched `scoreQsos` resumes the same way: after the
1273
+ first pass of a session, an append arrives as a checkpoint plus the appended
1274
+ contacts, and only their verdicts come back.
1275
+
1276
+ **A checkpoint is held in the runtime and named, not sent, once it is
1277
+ there.** A sheet the size of a long log's worked-call map crossing the bridge
1278
+ on every keystroke would be most of the keystroke's cost. So the host names
1279
+ each checkpoint (`checkpointKey`, derived from the checkpoint's stamp) when
1280
+ the batch pass produces it, and the harness keeps the end sheet under that
1281
+ name — one per operation, the last few operations. A live call, or the next
1282
+ append, then carries `resumeKey` alone; a `resumeFrom` that arrives beside a
1283
+ `resumeKey` is filed under it too. A name the harness does not hold — a
1284
+ runtime restarted since, or one operation more than it keeps — is an error
1285
+ (`resume-missing`), never a silent cold fold, and the host answers it by
1286
+ sending the sheet once more. The host names a checkpoint only to a harness
1287
+ that has said `checkpointHeld: true` for it; a scorer built on an SDK without
1288
+ the cache never says so and keeps receiving the sheet. A `scoreCandidates`
1289
+ answer says it under the reserved key `$checkpointHeld`, since its other
1290
+ entries are the caller's candidate keys — a candidate key never starts with
1291
+ `$`.
1292
+
1293
+ The view a scorer gets on the live paths takes every kind of write —
1294
+ assignment, `push`, `delete`, `Object.defineProperty` — and refuses the ones
1295
+ that would reach the base sheet by other means: `Object.freeze` (or `seal`,
1296
+ `preventExtensions`) on it throws. A sheet is plain JSON; one that is frozen
1297
+ or carries accessors is outside the contract and throws at the first touch.
1298
+
1205
1299
  `scoreCandidates` answers the same question for many at once, which is what the
1206
1300
  Spots Panel asks to mark the spots already worked: the log is folded once per
1207
- scorer and every candidate scored against a COPY of the result, so no candidate
1208
- can become another's duplicate. Candidates carry no uuid — a spot is not a
1209
- record — so results come back under caller-chosen keys.
1301
+ scorer and every candidate scored against its own copy-on-write VIEW of the
1302
+ result, so no candidate can become another's duplicate and no candidate pays
1303
+ for a copy of the log. Candidates carry no uuid — a spot is not a record — so
1304
+ results come back under caller-chosen keys.
1210
1305
 
1211
- `Qsos.watchForOperation` already filters deleted rows and orders by
1306
+ `QsosRepository.forOperation` already filters deleted rows and orders by
1212
1307
  `startAtMillis`, so unlike web-lofi's `analyzeAndSectionQSOs` there's no
1213
1308
  `deleted`/`event` row bookkeeping on this side of the bridge.
1214
1309
  `ScoringService` (`app/lib/services/scoring_service.dart`) writes
@@ -1448,6 +1543,14 @@ fires when a lookup is ANSWERED, not while a call is being typed, and the
1448
1543
  guess is inside `args.qso` either way — declaring `'lookup'` is enough to be
1449
1544
  sent the draft, so what it saves over `'qso'` is renders, not the QSO.
1450
1545
 
1546
+ Beside the QSON, a draft carries one key a logged record never does:
1547
+ `callField`, the call field read at the cursor (`call`, `fullCall`,
1548
+ `partial`, `nextCall`, `nextPartial`). `their.call` is the field as typed, which may be a whole
1549
+ `//` stack or comma list; `callField.call` is the one call Enter logs. It
1550
+ is what [templates.md](templates.md)'s `qso.call` renders from, so a panel
1551
+ rendering a template needs nothing more, and moving the cursor within a
1552
+ stack or a list wakes a `'qso'` panel even when the QSON is unchanged.
1553
+
1451
1554
  `'lookup'` fires when the panel is shown a callsign that HAS an answer —
1452
1555
  which includes opening a QSO whose stored record already carries one, not
1453
1556
  only a fresh lookup resolving. It does not fire when a lookup starts, fails,