pi-lean-dimension 0.3.0 → 0.3.2

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.
@@ -0,0 +1,464 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // §5 Public API — types
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export interface ToolsetSpec {
11
+ /** Stable id, e.g. "portal.web". Used in persist keys and event payloads. */
12
+ id: string;
13
+ /** Human-readable name for the group. Optional — presenters fall back to id. */
14
+ label?: string;
15
+ /** One-line description of what enabling the group does. Optional — presenters omit when absent. */
16
+ description?: string;
17
+ /** Tool names this toolset governs. */
18
+ names: Set<string>;
19
+ /** Primary persistence key the toolset writes, e.g. "toolset-state:portal.web". */
20
+ persistKey: string;
21
+ /** Fresh-session fallback when no branch entry exists. */
22
+ defaultEnabled?: boolean;
23
+ /** Dependency: ids of toolsets that must be enabled for this one. */
24
+ requires?: string[];
25
+ /** When true, a group toggle additionally emits one `changed` event per member tool. Default false. */
26
+ emitMemberEvents?: boolean;
27
+ }
28
+
29
+ export interface Toolset {
30
+ enable(pi: ExtensionAPI): void;
31
+ disable(pi: ExtensionAPI): void;
32
+ isEnabled(pi: ExtensionAPI): boolean;
33
+ }
34
+
35
+ export interface ToolsetChangedEvent {
36
+ /** Toolset id (e.g. "portal.web"). Always set. */
37
+ id: string;
38
+ enabled: boolean;
39
+ /** Present only when emitMemberEvents is on and this is a per-member fanout event. */
40
+ member?: string;
41
+ }
42
+
43
+ export type DefaultResolutionMode = "exclusion" | "inclusion";
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // §6 Change notification — event names
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export const TOOLSET_EVENTS = {
50
+ changed: "toolset:changed",
51
+ restored: "toolset:restored",
52
+ } as const;
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Registry on globalThis (§6.1)
56
+ // ---------------------------------------------------------------------------
57
+
58
+ const REGISTRY_KEY = "__piToolMaskingRegistry";
59
+ const RESTORE_EVENT_KEY = "__piToolMaskingLastRestoreEvent";
60
+
61
+ export interface RegistryEntry {
62
+ spec: ToolsetSpec;
63
+ toolset: Toolset;
64
+ }
65
+
66
+ type Registry = Map<string, RegistryEntry>;
67
+
68
+ function getRegistry(): Registry {
69
+ if (
70
+ !(REGISTRY_KEY in globalThis) ||
71
+ !((globalThis as any)[REGISTRY_KEY] instanceof Map)
72
+ ) {
73
+ (globalThis as any)[REGISTRY_KEY] = new Map();
74
+ }
75
+ return (globalThis as any)[REGISTRY_KEY] as Registry;
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Shared in-memory state (library-level, not per-consumer)
80
+ // ---------------------------------------------------------------------------
81
+
82
+ const MODULE_KEY = "__piToolMaskingModuleState";
83
+ const MODE_PERSIST_KEY = "toolset-resolution-mode";
84
+
85
+ interface ModuleState {
86
+ defaultResolutionMode: DefaultResolutionMode;
87
+ }
88
+
89
+ function getModuleState(): ModuleState {
90
+ if (!(MODULE_KEY in globalThis)) {
91
+ (globalThis as any)[MODULE_KEY] = {
92
+ defaultResolutionMode: "exclusion" as DefaultResolutionMode,
93
+ };
94
+ }
95
+ return (globalThis as any)[MODULE_KEY] as ModuleState;
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // deepEqual for spec comparison (§6.1 idempotent re-registration)
100
+ // ---------------------------------------------------------------------------
101
+
102
+ function deepEqual(a: unknown, b: unknown): boolean {
103
+ if (Object.is(a, b)) return true;
104
+ if (a instanceof Set && b instanceof Set) {
105
+ if (a.size !== b.size) return false;
106
+ for (const v of a) if (!b.has(v)) return false;
107
+ return true;
108
+ }
109
+ if (
110
+ typeof a !== "object" ||
111
+ typeof b !== "object" ||
112
+ a === null ||
113
+ b === null
114
+ )
115
+ return false;
116
+ if (Array.isArray(a) && Array.isArray(b)) {
117
+ if (a.length !== b.length) return false;
118
+ return a.every((v, i) => deepEqual(v, b[i]));
119
+ }
120
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
121
+ const definedKeys = (o: object) =>
122
+ Reflect.ownKeys(o).filter((k) => (o as any)[k] !== undefined);
123
+ const keysA = definedKeys(a);
124
+ const keysB = definedKeys(b);
125
+ if (keysA.length !== keysB.length) return false;
126
+ return keysA.every((k) => deepEqual((a as any)[k], (b as any)[k]));
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Ensure session_start / session_tree restore handler is registered
131
+ // (dedup at runtime by event-object identity, not at registration time)
132
+ // ---------------------------------------------------------------------------
133
+
134
+ function ensureRestoreHandler(pi: ExtensionAPI): void {
135
+ // Dedup by event-object identity (§6). The runner passes the same event
136
+ // reference to every extension's handler in one emit() call, so the first
137
+ // handler wins and the rest skip. Each /reload constructs a fresh event
138
+ // object, so restore re-runs with the fresh pi.
139
+ const doRestore = (event: unknown, ctx: ExtensionContext): void => {
140
+ if ((globalThis as any)[RESTORE_EVENT_KEY] === event) return;
141
+ (globalThis as any)[RESTORE_EVENT_KEY] = event;
142
+
143
+ const registry = getRegistry();
144
+
145
+ // Re-read durable resolution mode before per-toolset fallback (§4.5).
146
+ // setDefaultResolutionMode persists this bit; a fresh process defaults
147
+ // to "exclusion" until the persisted entry is replayed here. Mode
148
+ // entries are from a prior session (not written during this restore), so
149
+ // a single read here is sufficient.
150
+ const modeEntries = ctx.sessionManager
151
+ .getBranch()
152
+ .filter((b: any) => b.customType === MODE_PERSIST_KEY && b.data?.mode);
153
+ if (modeEntries.length > 0) {
154
+ getModuleState().defaultResolutionMode = (
155
+ modeEntries[modeEntries.length - 1] as any
156
+ ).data.mode;
157
+ }
158
+ const mode = getModuleState().defaultResolutionMode;
159
+
160
+ // ponytail: restore applies each toolset's entry independently and does
161
+ // NOT re-run the requires cascade. Safe because §7.1 guarantees persisted
162
+ // state is always consistent — the live-toggling cascade (§4.4) makes an
163
+ // incoherent persisted combo unreachable. Re-adding cascade here would
164
+ // double-toggle and break restore independence.
165
+ //
166
+ // Re-read the branch per toolset (not once before the loop): a companion
167
+ // mirror (§10.1) fires synchronously inside `_applyRestoreToolset` and may
168
+ // `appendEntry` for a toolset later in iteration order (e.g. portal.web's
169
+ // default-false restore makes search.web disable itself). Snapshotting the
170
+ // branch once would hide that write from the later toolset, so it would
171
+ // fall back to its packaged default and desync from the companion — the
172
+ // §6 "search's own restore reads the branch and finds the entry the mirror
173
+ // just wrote" guarantee.
174
+ for (const [, entry] of registry) {
175
+ const { spec } = entry;
176
+
177
+ // Find persisted entry for this toolset (last-writer-wins).
178
+ // Fresh read per toolset so companion-mirror writes during this
179
+ // pass are visible to later toolsets.
180
+ const branchNow = ctx.sessionManager.getBranch();
181
+ const persistEntries = branchNow.filter(
182
+ (b: any) => b.customType === spec.persistKey && b.data != null,
183
+ );
184
+
185
+ if (persistEntries.length > 0) {
186
+ const lastEntry = persistEntries[persistEntries.length - 1];
187
+ const enabled = (lastEntry as any).data?.enabled;
188
+ if (typeof enabled === "boolean") {
189
+ _applyRestoreToolset(spec, pi, enabled, true);
190
+ }
191
+ } else {
192
+ // No entry — resolve default based on mode (§4.5)
193
+ const fallback = spec.defaultEnabled ?? true;
194
+ const enabled = mode === "inclusion" ? false : fallback;
195
+ _applyRestoreToolset(spec, pi, enabled, false);
196
+ }
197
+ }
198
+ };
199
+
200
+ pi.on("session_start", doRestore);
201
+ pi.on("session_tree", doRestore);
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Event emission helper (group + optional member fanout)
206
+ // ---------------------------------------------------------------------------
207
+
208
+ function _emitToolsetEvents(
209
+ spec: ToolsetSpec,
210
+ pi: ExtensionAPI,
211
+ eventType: string,
212
+ enabled: boolean,
213
+ ): void {
214
+ pi.events.emit(eventType, { id: spec.id, enabled });
215
+
216
+ if (spec.emitMemberEvents) {
217
+ for (const name of spec.names) {
218
+ // Only emit for names that are actually registered tools (§6)
219
+ if (!pi.getAllTools().some((t) => t.name === name)) continue;
220
+ pi.events.emit(eventType, {
221
+ id: spec.id,
222
+ enabled,
223
+ member: name,
224
+ });
225
+ }
226
+ }
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Helper: enable a single toolset (writes entry + emits if state changed)
231
+ // ---------------------------------------------------------------------------
232
+
233
+ function _applyEnable(spec: ToolsetSpec, pi: ExtensionAPI): void {
234
+ const current = new Set(pi.getActiveTools());
235
+ const registeredNames = [...spec.names].filter((n) =>
236
+ pi.getAllTools().some((t) => t.name === n),
237
+ );
238
+
239
+ if (registeredNames.every((n) => current.has(n))) return;
240
+
241
+ const next = [...new Set([...current, ...registeredNames])];
242
+ pi.setActiveTools(next);
243
+ pi.appendEntry(spec.persistKey, { enabled: true });
244
+ _emitToolsetEvents(spec, pi, TOOLSET_EVENTS.changed, true);
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Helper: disable a single toolset (writes entry + emits if state changed)
249
+ // ---------------------------------------------------------------------------
250
+
251
+ function _applyDisable(spec: ToolsetSpec, pi: ExtensionAPI): void {
252
+ const current = pi.getActiveTools();
253
+ const filtered = current.filter((n) => !spec.names.has(n));
254
+
255
+ if (filtered.length === current.length) return;
256
+
257
+ pi.setActiveTools(filtered);
258
+ pi.appendEntry(spec.persistKey, { enabled: false });
259
+ _emitToolsetEvents(spec, pi, TOOLSET_EVENTS.changed, false);
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Restore-specific apply: applies state without persisting, always emits
264
+ // isPersistedEntry=true → restored event, false → changed event
265
+ // ---------------------------------------------------------------------------
266
+
267
+ function _applyRestoreToolset(
268
+ spec: ToolsetSpec,
269
+ pi: ExtensionAPI,
270
+ enabled: boolean,
271
+ isPersistedEntry: boolean,
272
+ ): void {
273
+ const registeredNames = [...spec.names].filter((n) =>
274
+ pi.getAllTools().some((t) => t.name === n),
275
+ );
276
+
277
+ if (enabled) {
278
+ const current = new Set(pi.getActiveTools());
279
+ const next = [...new Set([...current, ...registeredNames])];
280
+ pi.setActiveTools(next);
281
+ } else {
282
+ const current = pi.getActiveTools();
283
+ // Use spec.names.has(n) (not registeredNames) to match _applyDisable —
284
+ // an unregistered spec member active in the list must be removed on
285
+ // restore just like a manual disable would.
286
+ const filtered = current.filter((n) => !spec.names.has(n));
287
+ pi.setActiveTools(filtered);
288
+ }
289
+
290
+ // Always emit regardless of state (always-emit invariant, §6)
291
+ const eventType = isPersistedEntry
292
+ ? TOOLSET_EVENTS.restored
293
+ : TOOLSET_EVENTS.changed;
294
+ _emitToolsetEvents(spec, pi, eventType, enabled);
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // Enable cascade + cycle detection (§4.4, §9)
299
+ // ---------------------------------------------------------------------------
300
+
301
+ function _enableToolset(
302
+ registry: Registry,
303
+ spec: ToolsetSpec,
304
+ pi: ExtensionAPI,
305
+ path: string[],
306
+ ): void {
307
+ if (path.includes(spec.id)) {
308
+ throw new Error(
309
+ `[pi-tool-masking] Cycle detected: ${[...path, spec.id].join(" \u2192 ")}`,
310
+ );
311
+ }
312
+
313
+ path.push(spec.id);
314
+
315
+ // Always cascade to dependencies first
316
+ if (spec.requires) {
317
+ for (const depId of spec.requires) {
318
+ const dep = registry.get(depId);
319
+ if (!dep) continue; // forward reference — not yet registered
320
+ _enableToolset(registry, dep.spec, pi, path);
321
+ }
322
+ }
323
+
324
+ // Then enable self (no-op if already fully enabled)
325
+ _applyEnable(spec, pi);
326
+
327
+ path.pop();
328
+ }
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // Disable reverse-cascade (§4.4, §9)
332
+ // ---------------------------------------------------------------------------
333
+
334
+ function _disableDependents(
335
+ registry: Registry,
336
+ disabledId: string,
337
+ pi: ExtensionAPI,
338
+ path: string[],
339
+ ): void {
340
+ for (const [id, entry] of registry) {
341
+ if (!entry.spec.requires?.includes(disabledId)) continue;
342
+
343
+ if (path.includes(id)) {
344
+ throw new Error(
345
+ `[pi-tool-masking] Cycle detected on disable: ${[...path, id].join(" \u2192 ")}`,
346
+ );
347
+ }
348
+
349
+ // Disable this dependent
350
+ _applyDisable(entry.spec, pi);
351
+
352
+ // Recurse to its dependents
353
+ path.push(id);
354
+ _disableDependents(registry, id, pi, path);
355
+ path.pop();
356
+ }
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // ToolsetImpl — concrete Toolset returned by defineToolset
361
+ // ---------------------------------------------------------------------------
362
+
363
+ class ToolsetImpl implements Toolset {
364
+ constructor(private readonly spec: ToolsetSpec) {}
365
+
366
+ enable(pi: ExtensionAPI): void {
367
+ const registry = getRegistry();
368
+ const path: string[] = [];
369
+ _enableToolset(registry, this.spec, pi, path);
370
+ }
371
+
372
+ disable(pi: ExtensionAPI): void {
373
+ const registry = getRegistry();
374
+ const path: string[] = [this.spec.id];
375
+
376
+ // Disable self
377
+ _applyDisable(this.spec, pi);
378
+
379
+ // Cascade to dependents (toolsets whose requires contains this one's id)
380
+ _disableDependents(registry, this.spec.id, pi, path);
381
+ }
382
+
383
+ isEnabled(pi: ExtensionAPI): boolean {
384
+ const active = new Set(pi.getActiveTools());
385
+ return [...this.spec.names].some((n) => active.has(n));
386
+ }
387
+ }
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // §5 Public API — functions
391
+ // ---------------------------------------------------------------------------
392
+
393
+ export function defineToolset(pi: ExtensionAPI, spec: ToolsetSpec): Toolset {
394
+ if (!spec.id || spec.id.trim() === "") {
395
+ throw new Error("[pi-tool-masking] spec.id must be a non-empty string");
396
+ }
397
+ if (!spec.persistKey || spec.persistKey.trim() === "") {
398
+ throw new Error(
399
+ "[pi-tool-masking] spec.persistKey must be a non-empty string",
400
+ );
401
+ }
402
+
403
+ const registry = getRegistry();
404
+ const existing = registry.get(spec.id);
405
+
406
+ if (existing) {
407
+ if (deepEqual(existing.spec, spec)) {
408
+ // Idempotent re-registration — return existing toolset.
409
+ // Still register restore handler with current pi (/reload safety).
410
+ ensureRestoreHandler(pi);
411
+ return existing.toolset;
412
+ }
413
+ // Same id, different spec — warn and replace (reload after edit)
414
+ console.warn(
415
+ `[pi-tool-masking] Toolset "${spec.id}" re-registered with a changed spec; replacing (reload after edit).`,
416
+ );
417
+ // fall through to replace
418
+ }
419
+
420
+ // Check persistKey collision across all entries (skip self for replace case)
421
+ for (const [id, entry] of registry) {
422
+ if (id !== spec.id && entry.spec.persistKey === spec.persistKey) {
423
+ throw new Error(
424
+ `[pi-tool-masking] persistKey collision: "${spec.persistKey}" is already used by toolset "${id}"`,
425
+ );
426
+ }
427
+ }
428
+
429
+ const toolset = new ToolsetImpl(spec);
430
+ registry.set(spec.id, { spec, toolset });
431
+
432
+ ensureRestoreHandler(pi);
433
+
434
+ return toolset;
435
+ }
436
+
437
+ export function setDefaultResolutionMode(
438
+ pi: ExtensionAPI,
439
+ mode: DefaultResolutionMode,
440
+ ): void {
441
+ if (mode !== "exclusion" && mode !== "inclusion") {
442
+ throw new Error(
443
+ `[pi-tool-masking] Invalid defaultResolutionMode: "${mode}". Must be "exclusion" or "inclusion".`,
444
+ );
445
+ }
446
+ getModuleState().defaultResolutionMode = mode;
447
+ pi.appendEntry(MODE_PERSIST_KEY, { mode });
448
+ }
449
+
450
+ export function getDefaultResolutionMode(): DefaultResolutionMode {
451
+ return getModuleState().defaultResolutionMode;
452
+ }
453
+
454
+ /**
455
+ * Enumerate every registered toolset in the global registry.
456
+ * Returns a read-only snapshot — callers cannot mutate the live registry
457
+ * through the returned array. Each entry carries the full spec and the
458
+ * Toolset handle (enable / disable / isEnabled).
459
+ *
460
+ * No `pi` argument needed — enumeration is a pure registry read.
461
+ */
462
+ export function getRegisteredToolsets(): readonly RegistryEntry[] {
463
+ return [...getRegistry().values()];
464
+ }
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "pi-tool-masking",
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Core library for grouping pi tools into toggleable toolsets with persistent state and cross-extension events.",
7
+ "keywords": ["tool-masking", "pi-extension"],
8
+ "license": "AGPL-3.0-or-later",
9
+ "author": "Corey Ryan Hanson",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/coreyryanhanson/pi-tool-masking.git"
13
+ },
14
+ "scripts": {
15
+ "test": "vitest run",
16
+ "test:watch": "vitest",
17
+ "version:patch": "npm version patch --no-git-tag-version",
18
+ "version:minor": "npm version minor --no-git-tag-version",
19
+ "version:major": "npm version major --no-git-tag-version",
20
+ "prepublishOnly": "npm test && npx tsc --noEmit",
21
+ "publish": "npm publish --access public",
22
+ "publish:dry": "npm publish --access public --dry-run",
23
+ "release:patch": "node scripts/release.mjs patch",
24
+ "release:minor": "node scripts/release.mjs minor",
25
+ "release:major": "node scripts/release.mjs major"
26
+ },
27
+ "main": "index.ts",
28
+ "exports": {
29
+ ".": "./index.ts"
30
+ },
31
+ "files": [
32
+ "index.ts"
33
+ ],
34
+ "devDependencies": {
35
+ "@earendil-works/pi-coding-agent": "^0.78.0",
36
+ "@types/node": "^22.0.0",
37
+ "vitest": "^4.0.0"
38
+ }
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lean-dimension",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Pi extension. The full Pi web-tools suite in one install — interactive browsing (pi-lean-portal) plus SearXNG search (pi-lean-search), unified under a /web toggle that removes the tools from context when switched off.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -32,8 +32,8 @@
32
32
  ]
33
33
  },
34
34
  "dependencies": {
35
- "pi-lean-portal": "^0.3.0",
36
- "pi-lean-search": "^0.3.0"
35
+ "pi-lean-portal": "^0.3.2",
36
+ "pi-lean-search": "^0.3.2"
37
37
  },
38
38
  "bundledDependencies": [
39
39
  "pi-lean-portal",