@ham2k/extension-sdk 0.5.7 → 0.6.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.
package/AGENTS.md CHANGED
@@ -103,6 +103,7 @@ evaluating one script. Reach the outside world through `host`:
103
103
  import { host } from "@ham2k/extension-sdk"
104
104
 
105
105
  await host.fetch(url) // https only, and only domains your manifest lists
106
+ host.webSocket(url) // wss:// to hosts your manifest's webSockets lists
106
107
  await host.kvGet('key') // storage, namespaced to your extension
107
108
  await host.kvSet('key', value)
108
109
  await host.showForm(form) // ask the user something
package/dist/base64.js ADDED
@@ -0,0 +1,38 @@
1
+ const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2
+ const VALUES = new Map([...ALPHABET].map((c, i) => [c, i]));
3
+ function bytesToBase64(bytes) {
4
+ let out = "";
5
+ for (let i = 0; i < bytes.length; i += 3) {
6
+ const a = bytes[i];
7
+ const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
8
+ const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
9
+ const n = a << 16 | b << 8 | c;
10
+ out += ALPHABET[n >> 18 & 63] + ALPHABET[n >> 12 & 63];
11
+ out += i + 1 < bytes.length ? ALPHABET[n >> 6 & 63] : "=";
12
+ out += i + 2 < bytes.length ? ALPHABET[n & 63] : "=";
13
+ }
14
+ return out;
15
+ }
16
+ function base64ToBytes(text) {
17
+ if (text.length % 4 !== 0) throw new Error("invalid base64");
18
+ const padding = text.endsWith("==") ? 2 : text.endsWith("=") ? 1 : 0;
19
+ const bytes = new Uint8Array(text.length / 4 * 3 - padding);
20
+ let o = 0;
21
+ for (let i = 0; i < text.length; i += 4) {
22
+ let n = 0;
23
+ for (let j = 0; j < 4; j++) {
24
+ const ch = text[i + j];
25
+ const value = ch === "=" && i + 4 === text.length && j >= 4 - padding ? 0 : VALUES.get(ch);
26
+ if (value === void 0) throw new Error("invalid base64");
27
+ n = n << 6 | value;
28
+ }
29
+ if (o < bytes.length) bytes[o++] = n >> 16 & 255;
30
+ if (o < bytes.length) bytes[o++] = n >> 8 & 255;
31
+ if (o < bytes.length) bytes[o++] = n & 255;
32
+ }
33
+ return bytes;
34
+ }
35
+ export {
36
+ base64ToBytes,
37
+ bytesToBase64
38
+ };
@@ -0,0 +1,71 @@
1
+ function copyOnWrite(base) {
2
+ return isContainer(base) ? wrap(base) : base;
3
+ }
4
+ function isContainer(value) {
5
+ return typeof value === "object" && value !== null;
6
+ }
7
+ function wrap(target) {
8
+ const own = /* @__PURE__ */ new Map();
9
+ const deleted = /* @__PURE__ */ new Set();
10
+ return new Proxy(target, {
11
+ get(t, key) {
12
+ if (own.has(key)) return own.get(key);
13
+ if (deleted.has(key)) return void 0;
14
+ const value = Reflect.get(t, key);
15
+ if (isContainer(value) && Object.prototype.hasOwnProperty.call(t, key)) {
16
+ const view = wrap(value);
17
+ own.set(key, view);
18
+ return view;
19
+ }
20
+ return value;
21
+ },
22
+ set(_t, key, value) {
23
+ own.set(key, value);
24
+ deleted.delete(key);
25
+ return true;
26
+ },
27
+ has(t, key) {
28
+ if (deleted.has(key)) return false;
29
+ return own.has(key) || key in t;
30
+ },
31
+ deleteProperty(t, key) {
32
+ const descriptor = Reflect.getOwnPropertyDescriptor(t, key);
33
+ if (descriptor && !descriptor.configurable) return false;
34
+ own.delete(key);
35
+ if (descriptor) deleted.add(key);
36
+ return true;
37
+ },
38
+ ownKeys(t) {
39
+ const keys = Reflect.ownKeys(t).filter((key) => !deleted.has(key));
40
+ for (const key of own.keys()) {
41
+ if (!keys.includes(key)) keys.push(key);
42
+ }
43
+ return keys;
44
+ },
45
+ getOwnPropertyDescriptor(t, key) {
46
+ if (deleted.has(key)) return void 0;
47
+ const descriptor = Reflect.getOwnPropertyDescriptor(t, key);
48
+ if (!own.has(key)) return descriptor;
49
+ return descriptor ? { ...descriptor, value: own.get(key) } : { value: own.get(key), writable: true, enumerable: true, configurable: true };
50
+ },
51
+ // The other ways to write. Without these traps `Object.defineProperty`
52
+ // and `Object.freeze` fall through to the base — the held checkpoint —
53
+ // and one such call would enter a candidate into every later verdict, or
54
+ // lock the sheet for the rest of the session.
55
+ defineProperty(_t, key, descriptor) {
56
+ if ("get" in descriptor || "set" in descriptor) return false;
57
+ own.set(key, descriptor.value);
58
+ deleted.delete(key);
59
+ return true;
60
+ },
61
+ preventExtensions() {
62
+ return false;
63
+ },
64
+ setPrototypeOf() {
65
+ return false;
66
+ }
67
+ });
68
+ }
69
+ export {
70
+ copyOnWrite
71
+ };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // @ham2k/extension-sdk 0.5.7
1
+ // @ham2k/extension-sdk 0.6.0
2
2
  /** Experimental v1 scene contract. All coordinates are in the scene's viewBox. */
3
3
  export interface SvgScene {
4
4
  version: 1;
@@ -394,6 +394,7 @@ export interface LoggingControlDescriptor {
394
394
  key: string;
395
395
  label: string;
396
396
  shortLabel?: string;
397
+ skipFocus?: boolean;
397
398
  icon?: string;
398
399
  color?: string;
399
400
  order?: number;
@@ -634,12 +635,15 @@ export interface ScoreQsosRequest {
634
635
  ref?: Record<string, JSONValue>;
635
636
  resumeFrom?: JSONValue;
636
637
  resumeDay?: number;
638
+ resumeKey?: string;
639
+ checkpointKey?: string;
637
640
  }
638
641
  export interface ScoreQsosResult {
639
642
  qsoScores: Record<string, QsoScoreVerdict>;
640
643
  daySections: DayScoreSummary[];
641
644
  operationSummary: Record<string, ScoreTally>;
642
645
  scoresheet?: JSONValue;
646
+ checkpointHeld?: true;
643
647
  scoresheetDay?: number;
644
648
  }
645
649
  export interface ScoreQsoRequest {
@@ -651,11 +655,13 @@ export interface ScoreQsoRequest {
651
655
  resumeFrom?: JSONValue;
652
656
  resumeDay?: number;
653
657
  qsos?: Record<string, JSONValue>[];
658
+ resumeKey?: string;
654
659
  }
655
660
  export interface QsoScoreNotices {
656
661
  notices: string[];
657
662
  alerts: string[];
658
663
  dupe?: boolean;
664
+ checkpointHeld?: true;
659
665
  }
660
666
  export interface ScoreCandidatesRequest {
661
667
  operation: Record<string, JSONValue>;
@@ -668,8 +674,11 @@ export interface ScoreCandidatesRequest {
668
674
  resumeFrom?: JSONValue;
669
675
  resumeDay?: number;
670
676
  qsos?: Record<string, JSONValue>[];
677
+ resumeKey?: string;
671
678
  }
672
- export type ScoreCandidatesResult = Record<string, QsoScoreNotices>;
679
+ export type ScoreCandidatesResult = Record<string, QsoScoreNotices> & {
680
+ $checkpointHeld?: true;
681
+ };
673
682
  export interface ScoringHook {
674
683
  scoreQsos(args: ScoreQsosRequest, ctx: HookContext): Promise<ScoreQsosResult>;
675
684
  scoreQso?(args: ScoreQsoRequest, ctx: HookContext): Promise<QsoScoreNotices>;
@@ -953,6 +962,7 @@ export interface ExtensionManifest {
953
962
  enabledByDefault?: boolean;
954
963
  hooks?: string[];
955
964
  domains?: string[];
965
+ webSockets?: string[];
956
966
  allowUserAgentOverride?: boolean;
957
967
  requiresLocation?: boolean;
958
968
  requiresRadioRead?: boolean;
@@ -997,6 +1007,29 @@ export interface FetchResponse {
997
1007
  status: number;
998
1008
  body: string;
999
1009
  }
1010
+ export interface WebSocketOptions {
1011
+ protocols?: string[];
1012
+ }
1013
+ export interface WebSocketCloseEvent {
1014
+ code: number;
1015
+ reason: string;
1016
+ wasClean: boolean;
1017
+ }
1018
+ export interface ExtensionWebSocket {
1019
+ readonly url: string;
1020
+ readonly readyState: 0 | 1 | 2 | 3;
1021
+ readonly protocol: string;
1022
+ onopen: (() => void) | null;
1023
+ onmessage: ((event: {
1024
+ data: string | ArrayBuffer;
1025
+ }) => void) | null;
1026
+ onerror: ((event: {
1027
+ message: string;
1028
+ }) => void) | null;
1029
+ onclose: ((event: WebSocketCloseEvent) => void) | null;
1030
+ send(data: string | ArrayBuffer | ArrayBufferView): void;
1031
+ close(code?: number, reason?: string): void;
1032
+ }
1000
1033
  export interface DeviceLocation {
1001
1034
  latitude: number;
1002
1035
  longitude: number;
@@ -1461,6 +1494,8 @@ export declare function isUnexpectedSideband(mode: string | undefined | null, {
1461
1494
  freqKHz?: number;
1462
1495
  band?: string;
1463
1496
  }): boolean;
1497
+ export declare const RESUME_MISSING = "resume-missing";
1498
+ export declare const CHECKPOINT_HELD_KEY = "$checkpointHeld";
1464
1499
  export interface ContestScorerOptions {
1465
1500
  scope?: ScoringScope;
1466
1501
  }
@@ -1693,6 +1728,7 @@ export declare const host: {
1693
1728
  }): Promise<PanelRadioTuneResult>;
1694
1729
  tuneRadio(args: PanelRadioTune): Promise<PanelRadioTuneResult>;
1695
1730
  fetch(url: string, options?: FetchOptions): Promise<FetchResponse>;
1731
+ webSocket(url: string, options?: WebSocketOptions): ExtensionWebSocket;
1696
1732
  kvGet(key: string): Promise<JSONValue | null>;
1697
1733
  getLocation(): Promise<DeviceLocation | null>;
1698
1734
  getVersionInfo(): Promise<VersionInfo>;
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { base64ToBytes, bytesToBase64 } from "./base64.js";
1
2
  export * from "./types.js";
2
3
  export * from "./svgScene.js";
3
4
  export * from "./i18n.js";
@@ -38,6 +39,85 @@ function defineExtension(def) {
38
39
  }
39
40
  });
40
41
  }
42
+ const MAX_FRAME_SIZE = 1024 * 1024;
43
+ function utf8Length(text) {
44
+ let length = 0;
45
+ for (const ch of text) {
46
+ const cp = ch.codePointAt(0);
47
+ length += cp < 128 ? 1 : cp < 2048 ? 2 : cp < 65536 ? 3 : 4;
48
+ }
49
+ return length;
50
+ }
51
+ function openWebSocket(call, url, options) {
52
+ let readyState = 0;
53
+ let protocol = "";
54
+ const failed = (what) => (e) => kernel().log(`webSocket ${what} on ${url} failed: ${e instanceof Error ? e.message : String(e)}`);
55
+ const socket = {
56
+ url,
57
+ get readyState() {
58
+ return readyState;
59
+ },
60
+ get protocol() {
61
+ return protocol;
62
+ },
63
+ onopen: null,
64
+ onmessage: null,
65
+ onerror: null,
66
+ onclose: null,
67
+ send(data) {
68
+ if (readyState !== 1) throw new Error("WebSocket is not open");
69
+ let frame;
70
+ if (typeof data === "string") {
71
+ if (data.length > MAX_FRAME_SIZE) throw new Error("WebSocket message too big");
72
+ frame = { data };
73
+ } else {
74
+ const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
75
+ if (bytes.length > MAX_FRAME_SIZE) throw new Error("WebSocket message too big");
76
+ frame = { binary: bytesToBase64(bytes) };
77
+ }
78
+ call("webSocketSend", { socketId, ...frame }).catch(failed("send"));
79
+ },
80
+ close(code, reason) {
81
+ if (code !== void 0 && (!Number.isInteger(code) || code !== 1e3 && (code < 3e3 || code > 4999))) {
82
+ throw new Error("close code must be 1000 or 3000-4999");
83
+ }
84
+ if (reason !== void 0 && (typeof reason !== "string" || utf8Length(reason) > 123)) {
85
+ throw new Error("close reason must be a string of at most 123 UTF-8 bytes");
86
+ }
87
+ if (readyState >= 2) return;
88
+ const before = readyState;
89
+ readyState = 2;
90
+ call("webSocketClose", { socketId, ...code !== void 0 ? { code } : {}, ...reason !== void 0 ? { reason } : {} }).catch((e) => {
91
+ if (readyState === 2) readyState = before;
92
+ failed("close")(e);
93
+ });
94
+ }
95
+ };
96
+ if (typeof kernel().registerSocket !== "function") throw new Error("host.webSocket needs a newer version of HaLo");
97
+ const socketId = kernel().registerSocket((event) => {
98
+ switch (event.type) {
99
+ case "open":
100
+ if (readyState !== 0) return;
101
+ readyState = 1;
102
+ protocol = String(event.protocol ?? "");
103
+ return socket.onopen?.();
104
+ case "message": {
105
+ const data = typeof event.binary === "string" ? base64ToBytes(event.binary).buffer : String(event.data);
106
+ return socket.onmessage?.({ data });
107
+ }
108
+ case "error":
109
+ return socket.onerror?.({ message: String(event.message ?? "") });
110
+ case "close":
111
+ readyState = 3;
112
+ return socket.onclose?.({ code: Number(event.code), reason: String(event.reason ?? ""), wasClean: event.wasClean === true });
113
+ }
114
+ });
115
+ call("webSocketOpen", { socketId, url, ...options?.protocols ? { protocols: options.protocols } : {} }).catch((e) => {
116
+ kernel().socketEvent(socketId, JSON.stringify({ type: "error", message: e instanceof Error ? e.message : String(e) }));
117
+ kernel().socketEvent(socketId, JSON.stringify({ type: "close", code: 1006, reason: "", wasClean: false }));
118
+ });
119
+ return socket;
120
+ }
41
121
  const hooks = {
42
122
  /// EVERY hook in the category — including the CALLER's own. A hook that
43
123
  /// delegates within its own category therefore re-enters itself; use
@@ -76,6 +156,11 @@ const host = {
76
156
  async fetch(url, options) {
77
157
  return await hostCallFn()("fetch", { url, ...options });
78
158
  },
159
+ /// A WebSocket to a host the manifest's `webSockets` lists — see
160
+ /// `ExtensionWebSocket`. Returns at once, connecting, as a browser's does.
161
+ webSocket(url, options) {
162
+ return openWebSocket(hostCallFn(), url, options);
163
+ },
79
164
  async kvGet(key) {
80
165
  return await hostCallFn()("kvGet", { ns: currentExtensionKey, key });
81
166
  },
package/dist/scoring.js CHANGED
@@ -1,9 +1,13 @@
1
+ import { copyOnWrite } from "./copyOnWrite.js";
1
2
  const DAY_IN_MILLIS = 1e3 * 60 * 60 * 24;
3
+ const HELD_CHECKPOINTS = 4;
4
+ const RESUME_MISSING = "resume-missing";
5
+ const CHECKPOINT_HELD_KEY = "$checkpointHeld";
2
6
  function copySheet(sheet) {
3
7
  return JSON.parse(JSON.stringify(sheet));
4
8
  }
5
- function openCheckpoint(resumeFrom, resumeDay) {
6
- return { sheet: copySheet(resumeFrom), day: typeof resumeDay === "number" ? resumeDay : void 0 };
9
+ function operationUuidOf(operation) {
10
+ return typeof operation.uuid === "string" ? operation.uuid : "";
7
11
  }
8
12
  function dayOf(qso) {
9
13
  const millis = Number(qso.startAtMillis ?? 0);
@@ -25,6 +29,26 @@ function candidateAsOfNow(qso) {
25
29
  function contestScorer(scorer, options = {}) {
26
30
  const scope = options.scope ?? "always";
27
31
  const scopedRefTypes = typeof scope === "object" && Array.isArray(scope.refTypes) ? scope.refTypes : void 0;
32
+ const held = /* @__PURE__ */ new Map();
33
+ function hold(operationUuid, key, sheet, day) {
34
+ held.delete(operationUuid);
35
+ held.set(operationUuid, { key, sheet, day });
36
+ while (held.size > HELD_CHECKPOINTS) held.delete(held.keys().next().value);
37
+ }
38
+ function resolveCheckpoint(args) {
39
+ const operationUuid = operationUuidOf(args.operation);
40
+ const key = typeof args.resumeKey === "string" ? args.resumeKey : void 0;
41
+ if (args.resumeFrom !== void 0) {
42
+ const day = typeof args.resumeDay === "number" ? args.resumeDay : void 0;
43
+ const sheet = args.resumeFrom;
44
+ if (key !== void 0) hold(operationUuid, key, sheet, day);
45
+ return { key: key ?? "", sheet, day };
46
+ }
47
+ if (key === void 0) return void 0;
48
+ const entry = held.get(operationUuid);
49
+ if (entry === void 0 || entry.key !== key) throw new Error(`${RESUME_MISSING}: ${key}`);
50
+ return entry;
51
+ }
28
52
  function refOfScopedType(operation) {
29
53
  if (!scopedRefTypes) return void 0;
30
54
  const refs = operation.refs ?? [];
@@ -66,11 +90,12 @@ function contestScorer(scorer, options = {}) {
66
90
  return { sheet, day: currentDay };
67
91
  }
68
92
  async function liveScoresheet(args, ctx, excludeUuid) {
69
- if (args.resumeFrom !== void 0) {
70
- const { sheet, day } = openCheckpoint(args.resumeFrom, args.resumeDay);
93
+ const resumed = resolveCheckpoint(args);
94
+ if (resumed !== void 0) {
95
+ const sheet = copyOnWrite(resumed.sheet);
71
96
  const tail = chronological(args.qsos ?? []).filter((q) => !excludeUuid || q.uuid !== excludeUuid);
72
- if (tail.length === 0) return { sheet, day };
73
- return fold(sheet, tail, args, ctx, void 0, void 0, args.segments, day);
97
+ if (tail.length === 0) return { sheet, day: resumed.day };
98
+ return fold(sheet, tail, args, ctx, void 0, void 0, args.segments, resumed.day);
74
99
  }
75
100
  const uuid = typeof args.operation.uuid === "string" ? args.operation.uuid : void 0;
76
101
  const log = uuid ? await ctx.getQsos?.(uuid) ?? [] : [];
@@ -81,8 +106,8 @@ function contestScorer(scorer, options = {}) {
81
106
  scope,
82
107
  async scoreQsos(args, ctx) {
83
108
  const qsos = chronological(args.qsos);
84
- const resumed = args.resumeFrom !== void 0 ? openCheckpoint(args.resumeFrom, args.resumeDay) : void 0;
85
- const start = resumed?.sheet ?? scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx);
109
+ const resumed = resolveCheckpoint(args);
110
+ const start = resumed !== void 0 ? copySheet(resumed.sheet) : scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx);
86
111
  const qsoScores = {};
87
112
  const daySections = [];
88
113
  const { sheet: end, day: endDay } = fold(
@@ -103,12 +128,15 @@ function contestScorer(scorer, options = {}) {
103
128
  args.segments,
104
129
  resumed?.day
105
130
  );
131
+ const checkpointHeld = typeof args.checkpointKey === "string";
132
+ if (checkpointHeld) hold(operationUuidOf(args.operation), args.checkpointKey, end, endDay);
106
133
  return {
107
134
  qsoScores,
108
135
  daySections,
109
136
  operationSummary: scorer.summarizeScore({ scoresheet: end, operation: args.operation, ref: args.ref, scope: "operation" }, ctx),
110
137
  scoresheet: end,
111
- ...endDay !== void 0 && { scoresheetDay: endDay }
138
+ ...endDay !== void 0 && { scoresheetDay: endDay },
139
+ ...checkpointHeld && { checkpointHeld: true }
112
140
  };
113
141
  },
114
142
  /// Live path: score the candidate against the log so far and report only
@@ -117,26 +145,35 @@ function contestScorer(scorer, options = {}) {
117
145
  async scoreQso(args, ctx) {
118
146
  const { sheet } = await liveScoresheet(args, ctx, args.excludeUuid);
119
147
  const { score } = scorer.scoreQso({ scoresheet: sheet, qso: candidateAsOfNow(args.qso), operation: args.operation, ref: args.ref, isNewDay: false }, ctx);
120
- return { notices: score.notices ?? [], alerts: score.alerts ?? [], ...score.dupe !== void 0 && { dupe: score.dupe } };
148
+ const checkpointHeld = typeof args.resumeKey === "string";
149
+ return {
150
+ notices: score.notices ?? [],
151
+ alerts: score.alerts ?? [],
152
+ ...score.dupe !== void 0 && { dupe: score.dupe },
153
+ ...checkpointHeld && { checkpointHeld: true }
154
+ };
121
155
  },
122
156
  /// The same live judgement as `scoreQso`, for many candidates at once —
123
157
  /// the Spots Panel annotating every spot on screen. The log is folded ONCE
124
- /// and each candidate scored against its own copy of the result, so a
158
+ /// and each candidate scored against its own view of the result, so a
125
159
  /// candidate can neither see nor become a duplicate of another (both spots
126
160
  /// for the same unworked call read as unworked, which is what an operator
127
161
  /// scanning a band expects).
128
162
  ///
129
- /// The copy is per candidate rather than per call because
163
+ /// The view is per candidate rather than per call because
130
164
  /// `ContestScorer.scoreQso` may mutate the scoresheet it is handed — the
131
165
  /// contract that makes the batch fold linear. Without it the first
132
- /// candidate's call would count as worked for every candidate after it.
166
+ /// candidate's call would count as worked for every candidate after it. A
167
+ /// view, not a copy: copying a long log's worked-call map forty times over
168
+ /// was the cost of the whole pass.
133
169
  async scoreCandidates(args, ctx) {
134
170
  const { sheet: base } = await liveScoresheet(args, ctx);
135
171
  const result = {};
172
+ if (typeof args.resumeKey === "string") result[CHECKPOINT_HELD_KEY] = true;
136
173
  for (const candidate of args.candidates) {
137
174
  const { score } = scorer.scoreQso(
138
175
  {
139
- scoresheet: copySheet(base),
176
+ scoresheet: copyOnWrite(base),
140
177
  qso: candidateAsOfNow(candidate.qso),
141
178
  operation: args.operation,
142
179
  ref: args.ref,
@@ -158,6 +195,8 @@ function tally(key, forScope, total, extra = {}) {
158
195
  return { key, for: forScope, total, ...extra };
159
196
  }
160
197
  export {
198
+ CHECKPOINT_HELD_KEY,
199
+ RESUME_MISSING,
161
200
  contestScorer,
162
201
  tally
163
202
  };
@@ -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,10 @@ 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.
49
52
 
50
53
  Three fields are **refused** in a distributed bundle, and it is worth
51
54
  understanding why:
@@ -254,7 +257,7 @@ It reports every problem at once rather than one per run:
254
257
  h2kext-pack: ./build is not ready to package:
255
258
  manifest.json: missing required field 'name'
256
259
  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)
260
+ manifest.json: missing 'api' — declare the extension API this was built against (currently 2)
258
261
  ```
259
262
 
260
263
  `--force-name` waives the naming convention below. It exists for Ham2K's own
@@ -318,8 +321,8 @@ needs one degrades the same way it does in a build without that value
318
321
  configured.
319
322
 
320
323
  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.
324
+ filesystem, no network beyond the `domains` and `webSockets` the manifest
325
+ declares and the user approved, no timers.
323
326
 
324
327
  ## Installing one
325
328
 
package/docs/hooks.md CHANGED
@@ -499,11 +499,13 @@ interface ActivityHook {
499
499
  suggest?(args: SuggestArgs, ctx): Promise<ActivitySuggestion[]>
500
500
  processQsoBeforeSave?(args: { qso, operation }, ctx): Promise<Patch | null>
501
501
  }
502
- // LoggingControlDescriptor: {key, label, shortLabel?, icon?, color?, order?,
503
- // optionType?, allowsMultiple?, editable?, input}
502
+ // LoggingControlDescriptor: {key, label, shortLabel?, skipFocus?, icon?, color?,
503
+ // order?, optionType?, allowsMultiple?, editable?, input}
504
504
  // `shortLabel` is the same name in fewer characters, for a field the logging
505
505
  // row has squeezed below its label's width. The core measures and picks; the
506
506
  // extension only says what the short form is.
507
+ // `skipFocus: true` takes a main logging field out of the Space and Tab cycle;
508
+ // the arrow keys still reach it. Every shipped contest sets it on Our Serial.
507
509
  // SuggestArgs: {operation, location?: {lat, lon}, callsign?, searchTerm?, scoped?}
508
510
  // ActivitySuggestion: Ref & {distance?, relevance?, allowsMultiple?}
509
511
 
@@ -1161,8 +1163,10 @@ standing beside your credit, on the one row that can show only one.
1161
1163
  A scorer's internal *rules* stay private to its scoresheet: contests don't
1162
1164
  share a rule vocabulary, so only notices, alerts, points and summary tallies
1163
1165
  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.
1166
+ return it; copying accumulated state per QSO is quadratic. The batch pass
1167
+ copies the checkpoint it resumes from, once; the live paths hand the scorer a
1168
+ copy-on-write view of it instead (`copyOnWrite` in the SDK), so a candidate's
1169
+ writes land in the view and the held sheet is never touched.
1166
1170
 
1167
1171
  ```ts
1168
1172
  // The wire shape the harness produces, for reference:
@@ -1171,17 +1175,19 @@ interface ScoringHook {
1171
1175
  scoreQso?(args: ScoreQsoRequest, ctx): Promise<QsoScoreNotices>
1172
1176
  scoreCandidates?(args: ScoreCandidatesRequest, ctx): Promise<ScoreCandidatesResult>
1173
1177
  }
1174
- // ScoreQsosRequest: {operation, qsos, ref?, segments?, resumeFrom?, resumeDay?}
1178
+ // ScoreQsosRequest: {operation, qsos, ref?, segments?, resumeFrom?, resumeDay?, resumeKey?, checkpointKey?}
1175
1179
  // ScoreQsosResult: {
1176
1180
  // qsoScores: Record<uuid, QsoScoreVerdict>, // one per QSO claimed; see above
1177
1181
  // daySections: {day, count, scores: Record<tallyKey, ScoreTally>}[],
1178
1182
  // operationSummary: Record<tallyKey, ScoreTally>,
1179
1183
  // scoresheet, // checkpoint to resume from
1180
1184
  // scoresheetDay?, // the day it ends on, handed back as resumeDay
1185
+ // checkpointHeld?, // true: the harness kept the end sheet under checkpointKey
1181
1186
  // }
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>
1187
+ // ScoreQsoRequest: {operation, qso, excludeUuid?, ref?, segments?, resumeFrom?, resumeDay?, resumeKey?, qsos?}
1188
+ // QsoScoreNotices: {notices, alerts, dupe?, checkpointHeld?}
1189
+ // ScoreCandidatesRequest: {operation, candidates: {key, qso}[], ref?, segments?, resumeFrom?, resumeDay?, resumeKey?, qsos?}
1190
+ // ScoreCandidatesResult: Record<candidateKey, QsoScoreNotices> + {"$checkpointHeld": true} when a checkpoint was named
1185
1191
  ```
1186
1192
 
1187
1193
  All three arrive already implemented by `contestScorer()`; a scorer writes the
@@ -1192,23 +1198,48 @@ both live paths for free.
1192
1198
  A candidate that carries no `startAtMillis` (the draft's time is still
1193
1199
  automatic) reaches the scorer stamped with the current time on both live
1194
1200
  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.
1201
+ candidate that states a time keeps it. On both live paths a checkpoint, with
1202
+ the `qsos` logged since it, stands in for the whole-log read: the harness
1203
+ views the checkpoint copy-on-write, replays that tail onto the view
1204
+ (continuing the day `resumeDay` names, so no scorer's per-day tally restarts
1205
+ mid-day) and scores the candidate against the result. The host sends a
1206
+ checkpoint only when it can prove the log is what the checkpoint folded plus
1207
+ appended contacts; otherwise the harness reads the log through `ctx.getQsos`
1208
+ and folds it cold. The batched `scoreQsos` resumes the same way: after the
1209
+ first pass of a session, an append arrives as a checkpoint plus the appended
1210
+ contacts, and only their verdicts come back.
1211
+
1212
+ **A checkpoint is held in the runtime and named, not sent, once it is
1213
+ there.** A sheet the size of a long log's worked-call map crossing the bridge
1214
+ on every keystroke would be most of the keystroke's cost. So the host names
1215
+ each checkpoint (`checkpointKey`, derived from the checkpoint's stamp) when
1216
+ the batch pass produces it, and the harness keeps the end sheet under that
1217
+ name — one per operation, the last few operations. A live call, or the next
1218
+ append, then carries `resumeKey` alone; a `resumeFrom` that arrives beside a
1219
+ `resumeKey` is filed under it too. A name the harness does not hold — a
1220
+ runtime restarted since, or one operation more than it keeps — is an error
1221
+ (`resume-missing`), never a silent cold fold, and the host answers it by
1222
+ sending the sheet once more. The host names a checkpoint only to a harness
1223
+ that has said `checkpointHeld: true` for it; a scorer built on an SDK without
1224
+ the cache never says so and keeps receiving the sheet. A `scoreCandidates`
1225
+ answer says it under the reserved key `$checkpointHeld`, since its other
1226
+ entries are the caller's candidate keys — a candidate key never starts with
1227
+ `$`.
1228
+
1229
+ The view a scorer gets on the live paths takes every kind of write —
1230
+ assignment, `push`, `delete`, `Object.defineProperty` — and refuses the ones
1231
+ that would reach the base sheet by other means: `Object.freeze` (or `seal`,
1232
+ `preventExtensions`) on it throws. A sheet is plain JSON; one that is frozen
1233
+ or carries accessors is outside the contract and throws at the first touch.
1234
+
1205
1235
  `scoreCandidates` answers the same question for many at once, which is what the
1206
1236
  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.
1237
+ scorer and every candidate scored against its own copy-on-write VIEW of the
1238
+ result, so no candidate can become another's duplicate and no candidate pays
1239
+ for a copy of the log. Candidates carry no uuid — a spot is not a record — so
1240
+ results come back under caller-chosen keys.
1210
1241
 
1211
- `Qsos.watchForOperation` already filters deleted rows and orders by
1242
+ `QsosRepository.forOperation` already filters deleted rows and orders by
1212
1243
  `startAtMillis`, so unlike web-lofi's `analyzeAndSectionQSOs` there's no
1213
1244
  `deleted`/`event` row bookkeeping on this side of the bridge.
1214
1245
  `ScoringService` (`app/lib/services/scoring_service.dart`) writes
package/docs/templates.md CHANGED
@@ -224,7 +224,7 @@ will jump rather than count. Show HH:MM.
224
224
  | Panel documents | `custom-text`'s content and tab name | by the operator, in the panel's config form |
225
225
  | Export filenames and titles | Registered export types and `sdk/src/exportSettings.ts` | Settings → Exports, globally and per type |
226
226
  | ADIF NOTES / COMMENT / QSLMSG | Export type settings, consumed by `core/adif` | Settings → Exports; empty templates suppress a field |
227
- | CW messages | Radio settings `cwMessage1..8`, keyed on F1-F8 through the radio (docs/design/cat.md § CW keying) — rendered by the `template` hook (hooks.md) | by the operator, in the Station dialog's Messages… dialog |
227
+ | CW messages | Radio settings `cwMessage1..8`, keyed on F1-F8 through the radio (docs/design/cat.md § CW keying) — rendered by the `template` hook (hooks.md) | by the operator, in the CW Keyer dialog (Settings → Radio, or the Station dialog's keyer row) |
228
228
 
229
229
  NOTES and COMMENT default to QSO notes and are withheld when private data
230
230
  is off. QSLMSG defaults to empty for program exports, and for the whole-log
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "0.5.7",
3
+ "version": "0.6.0",
4
4
  "description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
5
5
  "keywords": [
6
6
  "ham2k",