@oh-my-pi/pi-natives 18.3.1 → 18.3.3

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.
@@ -2,7 +2,7 @@ interface AdaptedDesktopCapabilities {
2
2
  readonly [key: string]: unknown;
3
3
  readonly ax: boolean;
4
4
  readonly backgroundWindowInput: boolean;
5
- readonly deliveryModes: readonly string[];
5
+ readonly takeover: boolean;
6
6
  readonly axPermission: string;
7
7
  }
8
8
 
@@ -14,10 +14,10 @@ interface AdaptedDesktopSession {
14
14
  target: string,
15
15
  x: number,
16
16
  y: number,
17
- options?: { button?: string; count?: number; modifiers?: string[]; deliveryMode?: string },
17
+ options?: { button?: string; count?: number; modifiers?: string[]; takeover?: boolean },
18
18
  ): Promise<void>;
19
- typeText(target: string, text: string, options?: { deliveryMode?: string }): Promise<void>;
20
- keyChord(target: string, keys: string[], options?: { deliveryMode?: string }): Promise<void>;
19
+ typeText(target: string, text: string, options?: { takeover?: boolean }): Promise<void>;
20
+ keyChord(target: string, keys: string[], options?: { takeover?: boolean }): Promise<void>;
21
21
  close(): Promise<void>;
22
22
  }
23
23
 
@@ -33,7 +33,7 @@ function normalizeCapabilities(capabilities) {
33
33
  ...capabilities,
34
34
  ax: false,
35
35
  backgroundWindowInput: false,
36
- deliveryModes: ["foreground"],
36
+ takeover: true,
37
37
  axPermission: "unavailable",
38
38
  };
39
39
  }
@@ -211,7 +211,7 @@ export function adaptDesktopSession(NativeDesktopSession) {
211
211
  }
212
212
 
213
213
  #ensureForeground(target, options) {
214
- if (options?.deliveryMode !== "foreground" && (target !== "desktop" || options?.deliveryMode !== undefined)) {
214
+ if (target !== "desktop" && options?.takeover !== true) {
215
215
  throw desktopError(
216
216
  "BackgroundUnavailable",
217
217
  "the installed native addon supports foreground input only",
package/native/index.d.ts CHANGED
@@ -366,6 +366,53 @@ export declare class Shell {
366
366
  liveBackgroundJobCount(): Promise<number>
367
367
  }
368
368
 
369
+ /** One word-completion engine running on its own thread. */
370
+ export declare class TextPredictor {
371
+ /**
372
+ * Spawn the engine thread and start opening the engine; load errors
373
+ * surface from [`TextPredictor::ready`] and every later call.
374
+ *
375
+ * # Errors
376
+ * Returns an error when the engine thread cannot be spawned.
377
+ */
378
+ constructor(options: TextPredictorOptions)
379
+ /**
380
+ * Resolve once the engine has loaded.
381
+ *
382
+ * # Errors
383
+ * Rejects with the engine's load error (missing weights, corrupt state).
384
+ */
385
+ ready(): Promise<void>
386
+ /**
387
+ * Ghost text for `prefix` typed after `before`, or `null`.
388
+ *
389
+ * # Errors
390
+ * Rejects when the engine failed to load.
391
+ */
392
+ complete(before: string, prefix: string): Promise<PredictedWord | null>
393
+ /**
394
+ * Learn from submitted prompts, in submission order.
395
+ *
396
+ * # Errors
397
+ * Rejects when the engine failed to load.
398
+ */
399
+ observe(prompts: Array<string>): Promise<void>
400
+ /**
401
+ * Learn from a suggestion the user accepted (`true`) or typed past.
402
+ *
403
+ * # Errors
404
+ * Rejects when the engine failed to load.
405
+ */
406
+ feedback(before: string, prefix: string, suggestion: string, accepted: boolean): Promise<void>
407
+ /**
408
+ * Flush learned state to the state directory.
409
+ *
410
+ * # Errors
411
+ * Rejects when the engine failed to load or the state cannot be written.
412
+ */
413
+ persist(): Promise<void>
414
+ }
415
+
369
416
  /**
370
417
  * Dedicated writer thread for one terminal fd.
371
418
  *
@@ -633,24 +680,13 @@ export declare class VcsRepo {
633
680
  export declare function __ompInstallTokioRuntime(): void
634
681
 
635
682
  /**
636
- * Version sentinel — exists solely so the JS loader can prove at load time
637
- * that the `.node` file on disk is from the same package release as the
638
- * `index.js` ESM wrapper invoking it.
639
- *
640
- * The `js_name` is bumped by `scripts/release.ts` to match the new
641
- * `Cargo.toml` / `package.json` version on every release. The JS loader
642
- * computes the expected name from `package.json#version` and refuses to use
643
- * a `.node` that doesn't expose it, turning the silent
644
- * `<sym> is not a function` crash from a locked-file update (the canonical
645
- * Windows `bun install -g` failure mode) into a clear load-time error.
683
+ * Release version stamped into this `.node` after linking.
646
684
  *
647
- * Bump policy: `__piNativesV{major}_{minor}_{patch}` — non-alphanumerics in
648
- * the version string are mapped to `_` to keep it a valid JS identifier.
649
- * MUST stay in sync with `VERSION_SENTINEL_EXPORT` in
650
- * `packages/natives/native/index.js` (which derives the name from
651
- * `package.json#version`).
685
+ * `None` for an unstamped build. The JS loader compares it against
686
+ * `package.json#version` so a `.node` from another release fails at load time
687
+ * with an actionable error instead of a later `<sym> is not a function` crash.
652
688
  */
653
- export declare function __piNativesV18_3_1(): void
689
+ export declare function __piNativesBuildVersion(): string | null
654
690
 
655
691
  /**
656
692
  * Reports whether the on-device model can generate, as an `availability`
@@ -1066,7 +1102,11 @@ export interface DesktopCapabilities {
1066
1102
  input: boolean
1067
1103
  ax: boolean
1068
1104
  backgroundWindowInput: boolean
1069
- deliveryModes: Array<string>
1105
+ /**
1106
+ * Whether window input accepts `takeover: true` (briefly activate the
1107
+ * target and post real input).
1108
+ */
1109
+ takeover: boolean
1070
1110
  capturePermission: string
1071
1111
  inputPermission: string
1072
1112
  axPermission: string
@@ -1726,6 +1766,11 @@ export interface GrepOptions {
1726
1766
  path: string
1727
1767
  /** Glob filter for filenames (e.g., "*.ts"). */
1728
1768
  glob?: string
1769
+ /**
1770
+ * Match simple glob patterns at any depth (default: true; `*.ts` ->
1771
+ * `**\/*.ts`). Set false when `glob` is already relative to `path`.
1772
+ */
1773
+ recursive?: boolean
1729
1774
  /** Filter by file type (e.g., "js", "py", "rust"). */
1730
1775
  type?: string
1731
1776
  /** Case-insensitive search. */
@@ -2107,14 +2152,6 @@ export declare function macOSAutocorrectWord(text: string, start: number, length
2107
2152
  */
2108
2153
  export declare function macOSCheckSpelling(text: string): Promise<Array<SpellingRange>>
2109
2154
 
2110
- /**
2111
- * Return macOS dictionary completions for one partial-word range.
2112
- *
2113
- * Returns an empty list when Apple's spelling service is unavailable.
2114
- * On macOS, the lookup runs on the dedicated spelling thread.
2115
- */
2116
- export declare function macOSCompleteWord(text: string, start: number, length: number): Promise<Array<string>>
2117
-
2118
2155
  /** Whether the host can use Apple's native spelling service. */
2119
2156
  export declare function macOSSpellCheckerAvailable(): boolean
2120
2157
 
@@ -2388,7 +2425,11 @@ export interface PointerOptions {
2388
2425
  button?: string
2389
2426
  count?: number
2390
2427
  modifiers?: Array<string>
2391
- deliveryMode?: string
2428
+ /**
2429
+ * Briefly activate the target window and post real input instead of the
2430
+ * default background delivery.
2431
+ */
2432
+ takeover?: boolean
2392
2433
  }
2393
2434
 
2394
2435
  /**
@@ -2415,6 +2456,14 @@ export interface PowerAssertionOptions {
2415
2456
  display?: boolean
2416
2457
  }
2417
2458
 
2459
+ /** Ghost text for the word being typed. */
2460
+ export interface PredictedWord {
2461
+ /** Characters to paint after the typed prefix. */
2462
+ suffix: string
2463
+ /** Engine-calibrated probability that `suffix` is exactly right. */
2464
+ confidence: number
2465
+ }
2466
+
2418
2467
  /** Current state of a process reference. */
2419
2468
  export declare enum ProcessStatus {
2420
2469
  /** The referenced process is still running. */
@@ -3137,6 +3186,18 @@ export interface SummarySegment {
3137
3186
  */
3138
3187
  export declare function supportsLanguage(lang: string): boolean
3139
3188
 
3189
+ /** Options for [`TextPredictor::new`]. */
3190
+ export interface TextPredictorOptions {
3191
+ /** Engine: `ngram`, `smollm`, or `apple`. */
3192
+ method: string
3193
+ /** Private directory for persisted learned state. */
3194
+ stateDir: string
3195
+ /** Directory holding downloaded model weights (`smollm` only). */
3196
+ modelDir?: string
3197
+ /** Show threshold override; omit for the engine's tuned default. */
3198
+ showThreshold?: number
3199
+ }
3200
+
3140
3201
  /**
3141
3202
  * Truncate text to a visible width, preserving ANSI codes.
3142
3203
  *
package/native/index.js CHANGED
@@ -38,6 +38,7 @@ export const PowerAssertion = nativeBindings.PowerAssertion;
38
38
  export const Process = nativeBindings.Process;
39
39
  export const PtySession = nativeBindings.PtySession;
40
40
  export const Shell = nativeBindings.Shell;
41
+ export const TextPredictor = nativeBindings.TextPredictor;
41
42
  export const TtyWriter = nativeBindings.TtyWriter;
42
43
  export const VcsGitRepo = nativeBindings.VcsGitRepo;
43
44
  export const VcsJjWorkspace = nativeBindings.VcsJjWorkspace;
@@ -45,7 +46,7 @@ export const VcsRepo = nativeBindings.VcsRepo;
45
46
 
46
47
  // functions
47
48
  export const __ompInstallTokioRuntime = nativeBindings.__ompInstallTokioRuntime ?? missingNativeExport("__ompInstallTokioRuntime");
48
- export const __piNativesV18_3_1 = nativeBindings.__piNativesV18_3_1;
49
+ export const __piNativesBuildVersion = nativeBindings.__piNativesBuildVersion;
49
50
  export const appleFmAvailability = nativeBindings.appleFmAvailability ?? missingNativeExport("appleFmAvailability");
50
51
  export const appleFmCancel = nativeBindings.appleFmCancel ?? missingNativeExport("appleFmCancel");
51
52
  export const appleFmGenerate = nativeBindings.appleFmGenerate ?? missingNativeExport("appleFmGenerate");
@@ -98,7 +99,6 @@ export const isoStop = nativeBindings.isoStop ?? missingNativeExport("isoStop");
98
99
  export const listWorkspace = nativeBindings.listWorkspace ?? missingNativeExport("listWorkspace");
99
100
  export const macOSAutocorrectWord = nativeBindings.macOSAutocorrectWord ?? missingNativeExport("macOSAutocorrectWord");
100
101
  export const macOSCheckSpelling = nativeBindings.macOSCheckSpelling ?? missingNativeExport("macOSCheckSpelling");
101
- export const macOSCompleteWord = nativeBindings.macOSCompleteWord ?? missingNativeExport("macOSCompleteWord");
102
102
  export const macOSSpellCheckerAvailable = nativeBindings.macOSSpellCheckerAvailable ?? missingNativeExport("macOSSpellCheckerAvailable");
103
103
  export const macOSSpellingGuesses = nativeBindings.macOSSpellingGuesses ?? missingNativeExport("macOSSpellingGuesses");
104
104
  export const matchesKey = nativeBindings.matchesKey ?? missingNativeExport("matchesKey");
@@ -75,7 +75,6 @@ export interface NativeLoaderContext {
75
75
  addonFilenames: string[];
76
76
  addonLabel: string;
77
77
  candidates: string[];
78
- versionSentinelExport: string;
79
78
  isWorkspaceLoad: boolean;
80
79
  nativesDir: string;
81
80
  }
@@ -118,7 +117,6 @@ export function selectCpuVariant(input: SelectCpuVariantInput): SelectCpuVariant
118
117
  export interface ValidateLoadedBindingsContext {
119
118
  isWorkspaceLoad: boolean;
120
119
  packageVersion: string;
121
- versionSentinelExport: string;
122
120
  }
123
121
 
124
122
  export function validateLoadedBindings(
@@ -131,10 +129,8 @@ export function validateLoadedBindings(
131
129
  export interface NativeAddonStatus {
132
130
  /** Absolute path of the loaded `.node`. */
133
131
  path: string;
134
- /** Sentinel the loaded addon carries, or `null` before sentinels existed. */
135
- sentinel: string | null;
136
- /** Sentinel this loader's package version expects. */
137
- expectedSentinel: string;
132
+ /** Release the loaded addon reports (post-link stamp or legacy sentinel), or `null` when unidentified. */
133
+ version: string | null;
138
134
  /** `package.json#version` of the loader that loaded it. */
139
135
  packageVersion: string;
140
136
  /** True when the addon carries a different release than this package. */
@@ -6,7 +6,7 @@ import * as path from "node:path";
6
6
  import * as zlib from "node:zlib";
7
7
  import packageJson from "../package.json" with { type: "json" };
8
8
  import { embeddedAddon } from "./embedded-addon.js";
9
- import { containsVersionSentinel, versionSentinelFor } from "./version-sentinel.js";
9
+ import { bindingsHaveReleaseIdentity, bindingsReleaseVersion, containsVersionStamp } from "./version-sentinel.js";
10
10
 
11
11
  /**
12
12
  * Native addon loader for `@oh-my-pi/pi-natives`.
@@ -15,7 +15,7 @@ import { containsVersionSentinel, versionSentinelFor } from "./version-sentinel.
15
15
  * `pi_natives.<platform>-<arch>*.node` is required, validated, and returned":
16
16
  * platform/variant detection, candidate-path resolution, on-disk staging from
17
17
  * `node_modules` (Windows update safety), embedded-addon extraction (Bun
18
- * standalone binaries), version-sentinel validation, and the aggregated error
18
+ * standalone binaries), release-stamp validation, and the aggregated error
19
19
  * surface for diagnostic-friendly failures.
20
20
  *
21
21
  * `native/index.js` is reduced to one `loadNative()` call plus the generated
@@ -656,28 +656,15 @@ function maybeStageNodeModulesAddon(ctx, errors) {
656
656
  return stagedPath;
657
657
  }
658
658
 
659
-
660
- /** Any release sentinel a `.node` may carry (`__piNativesV{major}_{minor}_{patch}`). */
661
- const VERSION_SENTINEL_ANY_RE = /^__piNativesV[A-Za-z0-9_]+$/;
662
-
663
- /**
664
- * Release version encoded in a sentinel export name.
665
- * @param {string} sentinel
666
- * @returns {string}
667
- */
668
- function sentinelVersion(sentinel) {
669
- return sentinel.slice("__piNativesV".length).replace(/_/g, ".");
670
- }
671
-
672
659
  /**
673
- * Before version sentinels were exported, published native addons still shared
660
+ * Before release identities existed, published native addons still shared
674
661
  * this stable core ABI. Let those on-disk addons bridge a package-version bump
675
- * when they expose the signature; keep every versioned addon and a current
662
+ * when they expose the signature; keep every identified addon and a current
676
663
  * on-disk file paired with resident old exports on the strict path below.
677
664
  */
678
- function isCompatiblePreSentinelNativeAddon(bindings, diskHasExpectedSentinel) {
679
- if (diskHasExpectedSentinel) return false;
680
- if (Object.keys(bindings).some(key => /^__piNativesV[A-Za-z0-9_]+$/.test(key))) return false;
665
+ function isCompatiblePreSentinelNativeAddon(bindings, diskHasExpectedStamp) {
666
+ if (diskHasExpectedStamp) return false;
667
+ if (bindingsHaveReleaseIdentity(bindings)) return false;
681
668
  return (
682
669
  typeof bindings.countTokens === "function" &&
683
670
  typeof bindings.executeShell === "function" &&
@@ -692,53 +679,48 @@ function isCompatiblePreSentinelNativeAddon(bindings, diskHasExpectedSentinel) {
692
679
  export function validateLoadedBindings(ctx, bindings, candidate) {
693
680
  // In workspace dev (running out of `packages/natives/native/` rather than a
694
681
  // `node_modules` install or a compiled bundle) the local `.node` only gains
695
- // the renamed sentinel after `bun --cwd=packages/natives run build`. Skip
682
+ // the new release stamp after `bun --cwd=packages/natives run build`. Skip
696
683
  // validation there so a stale post-pull dev tree boots while the rebuild
697
684
  // completes; install and compiled-binary paths still validate. The mismatch
698
685
  // is not swallowed silently: `native/index.js` exports `missingNativeExport`
699
686
  // for every symbol the stale addon predates, so the first call through one
700
687
  // reports the addon, both releases, and the rebuild command.
701
688
  if (ctx.isWorkspaceLoad) return;
702
- if (typeof bindings[ctx.versionSentinelExport] === "function") return;
689
+ const residentVersion = bindingsReleaseVersion(bindings);
690
+ if (residentVersion === ctx.packageVersion) return;
703
691
 
704
- // The expected sentinel is missing. Distinguish two failure modes by the
705
- // sentinel the bindings DO carry:
692
+ // The bindings report another release (or none). Distinguish two failure
693
+ // modes by what the file on disk carries:
706
694
  // - disk stale: the `.node` on disk predates this loader (its own build);
707
695
  // reinstalling re-syncs the file.
708
696
  // - process stale: an in-place upgrade landed a new release on disk while
709
697
  // this process still holds the previous addon generation resident in the
710
698
  // dynamic-loader's native-module cache. `require` returns those old
711
- // exports, which carry the PRIOR sentinel — disk is already consistent,
699
+ // exports, which report the PRIOR release — disk is already consistent,
712
700
  // so reinstall is a no-op and only restarting the process re-syncs.
713
- const residentSentinel = Object.keys(bindings).find(
714
- key => key !== ctx.versionSentinelExport && VERSION_SENTINEL_ANY_RE.test(key),
715
- );
716
- // A prior sentinel alone cannot distinguish a resident old module from an
717
- // actually stale file: `require` returns the same exports in both cases.
718
- // The restart diagnosis is valid only when the selected file itself carries
719
- // the current sentinel; otherwise a restart would simply reload stale disk.
720
- let diskHasExpectedSentinel = false;
701
+ // Resident exports alone cannot tell these apart: `require` returns the
702
+ // same exports in both cases. The restart diagnosis is valid only when the
703
+ // selected file itself carries the current stamp; otherwise a restart would
704
+ // simply reload stale disk.
705
+ let diskHasExpectedStamp = false;
721
706
  try {
722
- diskHasExpectedSentinel = containsVersionSentinel(fs.readFileSync(candidate), ctx.versionSentinelExport);
707
+ diskHasExpectedStamp = containsVersionStamp(fs.readFileSync(candidate), ctx.packageVersion);
723
708
  } catch {
724
709
  // The successful require above normally guarantees readability. If the
725
710
  // file disappears concurrently, retain the safe reinstall diagnosis.
726
711
  }
727
- if (isCompatiblePreSentinelNativeAddon(bindings, diskHasExpectedSentinel)) return;
728
- if (residentSentinel && diskHasExpectedSentinel) {
729
- const residentVersion = sentinelVersion(residentSentinel);
712
+ if (isCompatiblePreSentinelNativeAddon(bindings, diskHasExpectedStamp)) return;
713
+ if (residentVersion && diskHasExpectedStamp) {
730
714
  throw new Error(
731
- `Loaded ${candidate}, which exposes the @oh-my-pi/pi-natives@${residentVersion} version ` +
732
- `sentinel \`${residentSentinel}\` but not the @${ctx.packageVersion} sentinel ` +
733
- `\`${ctx.versionSentinelExport}\` this loader expects. omp was upgraded to ` +
734
- `${ctx.packageVersion} while this session was running; the ${residentVersion} addon is ` +
735
- "still resident in this process. Disk is already consistent — restart omp to pick up " +
736
- `${ctx.packageVersion} (reinstalling changes nothing).`,
715
+ `Loaded ${candidate}, which reports @oh-my-pi/pi-natives@${residentVersion}, but this loader is ` +
716
+ `@${ctx.packageVersion}. omp was upgraded to ${ctx.packageVersion} while this session was running; ` +
717
+ `the ${residentVersion} addon is still resident in this process. Disk is already consistent — ` +
718
+ `restart omp to pick up ${ctx.packageVersion} (reinstalling changes nothing).`,
737
719
  );
738
720
  }
739
721
  throw new Error(
740
- `Loaded ${candidate} but it does not expose the @oh-my-pi/pi-natives@${ctx.packageVersion} ` +
741
- `version sentinel \`${ctx.versionSentinelExport}\`. The .node file on disk is from a different ` +
722
+ `Loaded ${candidate} but it reports ${residentVersion ? `@oh-my-pi/pi-natives@${residentVersion}` : "no release version"}, ` +
723
+ `not the @${ctx.packageVersion} this loader expects. The .node file on disk is from a different ` +
742
724
  "release than this loader — reinstall to re-sync.",
743
725
  );
744
726
  }
@@ -746,7 +728,7 @@ export function validateLoadedBindings(ctx, bindings, candidate) {
746
728
  /**
747
729
  * Identity of the addon `loadNative()` returned, in the shape the
748
730
  * missing-export diagnostic reports. Null until a load succeeds.
749
- * @type {{ path: string; sentinel: string | null; expectedSentinel: string; packageVersion: string; stale: boolean } | null}
731
+ * @type {{ path: string; version: string | null; packageVersion: string; stale: boolean } | null}
750
732
  */
751
733
  let loadedAddon = null;
752
734
 
@@ -755,22 +737,21 @@ let loadedAddon = null;
755
737
  * release the file came from, and the release this tree expects.
756
738
  * @param {Record<string, unknown>} bindings
757
739
  * @param {string} candidate
758
- * @param {{ packageVersion: string; versionSentinelExport: string }} ctx
740
+ * @param {{ packageVersion: string }} ctx
759
741
  */
760
742
  function describeLoadedAddon(bindings, candidate, ctx) {
761
- const sentinel = Object.keys(bindings).find(key => VERSION_SENTINEL_ANY_RE.test(key)) ?? null;
743
+ const version = bindingsReleaseVersion(bindings);
762
744
  return {
763
745
  path: candidate,
764
- sentinel,
765
- expectedSentinel: ctx.versionSentinelExport,
746
+ version,
766
747
  packageVersion: ctx.packageVersion,
767
- stale: sentinel !== ctx.versionSentinelExport,
748
+ stale: version !== ctx.packageVersion,
768
749
  };
769
750
  }
770
751
 
771
752
  /**
772
753
  * The addon behind this process's `@oh-my-pi/pi-natives` exports.
773
- * @returns {{ path: string; sentinel: string | null; expectedSentinel: string; packageVersion: string; stale: boolean } | null}
754
+ * @returns {{ path: string; version: string | null; packageVersion: string; stale: boolean } | null}
774
755
  */
775
756
  export function nativeAddonStatus() {
776
757
  return loadedAddon;
@@ -779,7 +760,7 @@ export function nativeAddonStatus() {
779
760
  /**
780
761
  * Stand-in for an export the loaded addon does not provide.
781
762
  *
782
- * A workspace tree tolerates a sentinel mismatch on purpose: a checkout that
763
+ * A workspace tree tolerates a release mismatch on purpose: a checkout that
783
764
  * pulled a new release keeps running until `bun run build:native` finishes
784
765
  * (see `validateLoadedBindings`), and PR CI loads release addons under a newer
785
766
  * checkout the same way. Such an addon has no value for any symbol added after
@@ -815,12 +796,12 @@ export function missingNativeExportMessage(symbolName, addon = loadedAddon) {
815
796
  if (!addon.stale) {
816
797
  return `@oh-my-pi/pi-natives export \`${symbolName}\` is missing from ${addon.path}; ${rebuild}.`;
817
798
  }
818
- const loaded = addon.sentinel
819
- ? `the @oh-my-pi/pi-natives@${sentinelVersion(addon.sentinel)} addon`
820
- : "an addon built before version sentinels existed";
799
+ const loaded = addon.version
800
+ ? `the @oh-my-pi/pi-natives@${addon.version} addon`
801
+ : "an addon without a release stamp";
821
802
  return (
822
803
  `@oh-my-pi/pi-natives export \`${symbolName}\` is missing: ${addon.path} is ${loaded}, not ` +
823
- `@${addon.packageVersion} (\`${addon.expectedSentinel}\`) — ${rebuild}.`
804
+ `@${addon.packageVersion} — ${rebuild}.`
824
805
  );
825
806
  }
826
807
 
@@ -927,14 +908,12 @@ export function initLoaderContext(overrides = {}) {
927
908
  userDataDir,
928
909
  });
929
910
 
930
- // Version sentinel emitted by the Rust addon under a `js_name` that encodes
931
- // the package version (`__piNativesV{major}_{minor}_{patch}`).
932
- // `scripts/release.ts` bumps the name in `crates/pi-natives/src/lib.rs` in
933
- // lock-step with the version, so a `.node` from a different release
934
- // physically cannot expose the symbol this loader is looking for. That
935
- // turns the silent `<sym> is not a function` crash from a Windows
936
- // locked-file update into an actionable load-time error.
937
- const versionSentinelExport = versionSentinelFor(packageVersion);
911
+ // Release validation compares `__piNativesBuildVersion()` — the version the
912
+ // build pipeline stamps into the addon after linking
913
+ // (`scripts/stamp-native-version.ts`) — with `package.json#version`, so a
914
+ // `.node` from a different release is rejected at load time instead of
915
+ // surfacing later as a silent `<sym> is not a function` crash from a
916
+ // Windows locked-file update.
938
917
 
939
918
  return {
940
919
  platformTag,
@@ -948,7 +927,6 @@ export function initLoaderContext(overrides = {}) {
948
927
  addonFilenames,
949
928
  addonLabel,
950
929
  candidates,
951
- versionSentinelExport,
952
930
  isWorkspaceLoad,
953
931
  nativesDir,
954
932
  };
@@ -1,5 +1,17 @@
1
- /** Return the native-addon export expected for a package version. */
2
- export function versionSentinelFor(packageVersion: string): string;
1
+ /** Prefix of the post-link stamp slot linked into every addon. */
2
+ export const VERSION_STAMP_MAGIC: string;
3
3
 
4
- /** Check whether addon bytes contain the exact expected version sentinel. */
5
- export function containsVersionSentinel(bytes: Buffer, expected: string): boolean;
4
+ /** Total stamp slot size in bytes: magic, version, NUL padding. */
5
+ export const VERSION_STAMP_SIZE: number;
6
+
7
+ /** Check whether addon bytes carry the stamp for exactly `version`. */
8
+ export function containsVersionStamp(bytes: Uint8Array, version: string): boolean;
9
+
10
+ /** Check whether pre-stamp addon bytes export the legacy `__piNativesV*` sentinel for exactly `version`. */
11
+ export function containsLegacyVersionSentinel(bytes: Uint8Array, version: string): boolean;
12
+
13
+ /** Release a loaded addon reports (stamp, or legacy `__piNativesV*` export), or `null`. */
14
+ export function bindingsReleaseVersion(bindings: Record<string, unknown>): string | null;
15
+
16
+ /** True when the bindings identify their release at all (stamp reader or legacy sentinel). */
17
+ export function bindingsHaveReleaseIdentity(bindings: Record<string, unknown>): boolean;
@@ -1,42 +1,100 @@
1
1
  /**
2
- * Version-sentinel helpers shared by the native loader and the embed pipeline.
2
+ * Addon release-identity helpers shared by the native loader, the embed
3
+ * pipeline, and the post-link stamp tool (`scripts/stamp-native-version.ts`).
3
4
  *
4
- * Kept in its own module so `scripts/embed-native.ts` can reuse the exact-match
5
- * logic without importing `loader-state.js` — which pulls in the generated
5
+ * Kept in its own module so `scripts/embed-native.ts` can reuse them without
6
+ * importing `loader-state.js` — which pulls in the generated
6
7
  * `embedded-addon.js` and its `with { type: "file" }` archive import. That
7
8
  * chain fails to resolve when the archive is missing, which would break
8
9
  * `gen:native:reset` on an inconsistent tree (populated manifest, deleted
9
10
  * archive) before it can restore the checked-in null stub.
11
+ *
12
+ * Current addons carry a fixed-size stamp slot (`VERSION_STAMP_MAGIC` + the
13
+ * release version + NUL padding) written after linking and reported by
14
+ * `__piNativesBuildVersion()`. Addons published before that exported a
15
+ * per-release `__piNativesV{major}_{minor}_{patch}` function instead; those
16
+ * are still recognized so an old module resident across an in-place upgrade
17
+ * is diagnosed correctly.
10
18
  */
11
19
 
20
+ /** Prefix of the stamp slot linked into every addon (`crates/pi-natives/src/lib.rs`). */
21
+ export const VERSION_STAMP_MAGIC = "PI_NATIVES_VERSION_STAMP:";
22
+
23
+ /** Total stamp slot size in bytes: magic, version, NUL padding. */
24
+ export const VERSION_STAMP_SIZE = 64;
25
+
26
+ /** Export names of pre-stamp releases (`__piNativesV18_3_2`). */
27
+ const LEGACY_SENTINEL_RE = /^__piNativesV([A-Za-z0-9_]+)$/;
28
+
12
29
  /**
13
- * Return the version sentinel exported by an addon built for `packageVersion`.
14
- * @param {string} packageVersion
15
- * @returns {string}
30
+ * Check whether addon bytes carry the stamp for exactly `version` (a stamp for
31
+ * `18.1.10` must not satisfy a lookup for `18.1.1`, so the version must be
32
+ * followed by the slot's NUL padding).
33
+ * @param {Uint8Array} bytes
34
+ * @param {string} version
35
+ * @returns {boolean}
16
36
  */
17
- export function versionSentinelFor(packageVersion) {
18
- return `__piNativesV${packageVersion.replace(/[^A-Za-z0-9]/g, "_")}`;
37
+ export function containsVersionStamp(bytes, version) {
38
+ if (version.length === 0) return false;
39
+ const needle = Buffer.from(`${VERSION_STAMP_MAGIC}${version}\0`, "utf8");
40
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).indexOf(needle) !== -1;
19
41
  }
20
42
 
21
43
  /**
22
- * Check for an exact version sentinel rather than a longer sentinel with the
23
- * expected value as its prefix (e.g. `__piNativesV18_1_10` must not satisfy a
24
- * lookup for `__piNativesV18_1_1`).
25
- * @param {Buffer} bytes
26
- * @param {string} expected
44
+ * Check whether pre-stamp addon bytes export the legacy sentinel for exactly
45
+ * `version` (`__piNativesV18_3_2`, not a longer `__piNativesV18_3_20`). The
46
+ * loader accepts such an addon for that release, so embedding may too.
47
+ * @param {Uint8Array} bytes
48
+ * @param {string} version
27
49
  * @returns {boolean}
28
50
  */
29
- export function containsVersionSentinel(bytes, expected) {
30
- if (expected.length === 0) return false;
31
- let offset = 0;
32
- while (offset < bytes.length) {
33
- const index = bytes.indexOf(expected, offset);
34
- if (index === -1) return false;
35
- const next = bytes[index + expected.length];
36
- const isIdentifierByte =
37
- next === 95 || (next >= 48 && next <= 57) || (next >= 65 && next <= 90) || (next >= 97 && next <= 122);
38
- if (!isIdentifierByte) return true;
39
- offset = index + expected.length;
51
+ export function containsLegacyVersionSentinel(bytes, version) {
52
+ if (version.length === 0) return false;
53
+ const haystack = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
54
+ const needle = Buffer.from(`__piNativesV${version.replace(/[^A-Za-z0-9]/g, "_")}`, "utf8");
55
+ for (let at = haystack.indexOf(needle); at !== -1; at = haystack.indexOf(needle, at + 1)) {
56
+ const next = haystack[at + needle.length];
57
+ const continues =
58
+ next !== undefined &&
59
+ ((next >= 0x30 && next <= 0x39) || (next >= 0x41 && next <= 0x5a) || (next >= 0x61 && next <= 0x7a) || next === 0x5f);
60
+ if (!continues) return true;
40
61
  }
41
62
  return false;
42
63
  }
64
+
65
+ /**
66
+ * Release version a loaded addon reports: the post-link stamp for current
67
+ * addons, the legacy sentinel export name for pre-stamp releases, `null` for
68
+ * unstamped builds and addons that predate both.
69
+ * @param {Record<string, unknown>} bindings
70
+ * @returns {string | null}
71
+ */
72
+ export function bindingsReleaseVersion(bindings) {
73
+ const report = bindings.__piNativesBuildVersion;
74
+ if (typeof report === "function") {
75
+ try {
76
+ const version = report();
77
+ return typeof version === "string" && version.length > 0 ? version : null;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+ for (const key of Object.keys(bindings)) {
83
+ const match = LEGACY_SENTINEL_RE.exec(key);
84
+ if (match) return match[1].replace(/_/g, ".");
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * True when the bindings identify their release at all (stamp reader or
91
+ * legacy sentinel), i.e. they are not a pre-sentinel addon.
92
+ * @param {Record<string, unknown>} bindings
93
+ * @returns {boolean}
94
+ */
95
+ export function bindingsHaveReleaseIdentity(bindings) {
96
+ return (
97
+ typeof bindings.__piNativesBuildVersion === "function" ||
98
+ Object.keys(bindings).some(key => LEGACY_SENTINEL_RE.test(key))
99
+ );
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-natives",
3
- "version": "18.3.1",
3
+ "version": "18.3.3",
4
4
  "description": "Native Rust bindings for PDF conversion, audio, WebRTC, grep, clipboard, image processing, syntax highlighting, PTY, and shell operations via N-API",
5
5
  "type": "module",
6
6
  "homepage": "https://omp.sh",
@@ -107,11 +107,11 @@
107
107
  }
108
108
  },
109
109
  "optionalDependencies": {
110
- "@oh-my-pi/pi-natives-linux-x64": "18.3.1",
111
- "@oh-my-pi/pi-natives-linux-arm64": "18.3.1",
112
- "@oh-my-pi/pi-natives-darwin-x64": "18.3.1",
113
- "@oh-my-pi/pi-natives-darwin-arm64": "18.3.1",
114
- "@oh-my-pi/pi-natives-win32-x64": "18.3.1",
115
- "@oh-my-pi/pi-natives-win32-arm64": "18.3.1"
110
+ "@oh-my-pi/pi-natives-linux-x64": "18.3.3",
111
+ "@oh-my-pi/pi-natives-linux-arm64": "18.3.3",
112
+ "@oh-my-pi/pi-natives-darwin-x64": "18.3.3",
113
+ "@oh-my-pi/pi-natives-darwin-arm64": "18.3.3",
114
+ "@oh-my-pi/pi-natives-win32-x64": "18.3.3",
115
+ "@oh-my-pi/pi-natives-win32-arm64": "18.3.3"
116
116
  }
117
117
  }