Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions packages/core/src/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ export class BrowserStorageAdapter implements StorageAdapter {
}

getItem(key: string): string | null {
if (typeof window === 'undefined' || !window.localStorage) return null;
try {
if (typeof window === 'undefined' || !window.localStorage) return null;
const namespaced = window.localStorage.getItem(this.nsKey(key));
if (namespaced !== null) return namespaced;

Expand All @@ -89,7 +89,8 @@ export class BrowserStorageAdapter implements StorageAdapter {

return null;
} catch {
// SecurityError in sandboxed iframes / opaque origins
// SecurityError in sandboxed iframes / opaque origins — including the
// edge case where accessing window.localStorage as a property itself throws.
return null;
}
}
Expand All @@ -101,6 +102,21 @@ export class BrowserStorageAdapter implements StorageAdapter {
// so the error surfaces through the configured onError callback.
window.localStorage.setItem(this.nsKey(key), value);
}

/**
* Remove a previously persisted value from localStorage.
* Silently no-ops when localStorage is unavailable (SSR, sandboxed
* iframes, incognito with storage blocked) or when a `SecurityError` is
* thrown. The key is automatically namespaced before the removal.
*/
removeItem(key: string): void {
try {
if (typeof window === 'undefined' || !window.localStorage) return;
window.localStorage.removeItem(this.nsKey(key));
} catch {
// SecurityError in sandboxed iframes / opaque origins
}
}
}

/* ------------------------------------------------------------------ */
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/engine/intent-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type {
import type { IntentEventMap } from '../types/events.js';
import { EventEmitter } from './event-emitter.js';
import { normalizeRouteState } from '../utils/route-normalizer.js';
import { getConfidence } from './signal-engine.js';

/** Maximum trajectory window kept for signal evaluation. */
const TRAJECTORY_WINDOW = 20;
Expand Down Expand Up @@ -376,7 +377,7 @@ export class IntentEngine {
expectedBaselineLogLikelihood: trajectoryResult.baselineLogLikelihood,
zScore: trajectoryResult.zScore,
sampleSize,
confidence: sampleSize < 10 ? 'low' : sampleSize < 30 ? 'medium' : 'high',
confidence: getConfidence(sampleSize),
});
}
}
Expand Down
24 changes: 14 additions & 10 deletions packages/core/src/engine/persistence-strategies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ export interface PersistStrategy {
close(): void;
}

/** Returns `true` when `err` is a storage quota exhaustion error. */
function isQuotaError(err: unknown): boolean {
return (
err instanceof Error &&
(err.name === 'QuotaExceededError' || err.message.toLowerCase().includes('quota'))
);
}

export abstract class BasePersistStrategy implements PersistStrategy {
protected lastPersistedAt = -Infinity;
protected throttleTimer: TimerHandle | null = null;
Expand Down Expand Up @@ -134,14 +142,12 @@ export class SyncPersistStrategy extends BasePersistStrategy {
this.lastPersistedAt = this.ctx.getTimer().now();
this.ctx.setEngineHealth('healthy');
} catch (err) {
const isQuota =
err instanceof Error &&
(err.name === 'QuotaExceededError' || err.message.toLowerCase().includes('quota'));
if (isQuota) {
const quota = isQuotaError(err);
if (quota) {
this.ctx.setEngineHealth('quota_exceeded');
}
this.ctx.reportError(
isQuota ? 'QUOTA_EXCEEDED' : 'STORAGE_WRITE',
quota ? 'QUOTA_EXCEEDED' : 'STORAGE_WRITE',
err instanceof Error ? err.message : String(err),
err,
);
Expand Down Expand Up @@ -202,14 +208,12 @@ export class AsyncPersistStrategy extends BasePersistStrategy {
this.ctx.markDirty();
this.asyncWriteFailCount += 1;

const isQuota =
err instanceof Error &&
(err.name === 'QuotaExceededError' || err.message.toLowerCase().includes('quota'));
if (isQuota) {
const quota = isQuotaError(err);
if (quota) {
this.ctx.setEngineHealth('quota_exceeded');
}
this.ctx.reportError(
isQuota ? 'QUOTA_EXCEEDED' : 'STORAGE_WRITE',
quota ? 'QUOTA_EXCEEDED' : 'STORAGE_WRITE',
err instanceof Error ? err.message : String(err),
err,
);
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/engine/signal-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import type {

export { EventEmitter };

function getConfidence(sampleSize: number): 'low' | 'medium' | 'high' {
export function getConfidence(sampleSize: number): 'low' | 'medium' | 'high' {
if (sampleSize < 10) return 'low';
if (sampleSize < 30) return 'medium';
return 'high';
Expand Down
60 changes: 15 additions & 45 deletions packages/core/src/plugins/web/LocalStorageAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,13 @@
*/

import type { IPersistenceAdapter } from '../../types/microkernel.js';
import { BrowserStorageAdapter } from '../../adapters.js';

/** Default namespace prefix applied to every localStorage key. */
const DEFAULT_NAMESPACE = 'passiveintent:';

export class LocalStorageAdapter implements IPersistenceAdapter {
private readonly namespace: string;
private readonly storage: BrowserStorageAdapter;

/**
* @param namespace Prefix prepended to every key before it is read from or
Expand All @@ -65,62 +66,36 @@ export class LocalStorageAdapter implements IPersistenceAdapter {
* (not recommended when multiple instances share an origin).
*/
constructor(namespace: string = DEFAULT_NAMESPACE) {
this.namespace = namespace;
}

/** Compute the namespaced key used for all localStorage operations. */
private nsKey(key: string): string {
return `${this.namespace}${key}`;
this.storage = new BrowserStorageAdapter(namespace);
}

/**
* Load a value from localStorage.
* Returns `null` when the key is absent, when localStorage is unavailable
* (SSR, incognito with storage blocked, sandboxed iframe), or when a
* `SecurityError` is thrown.
*
* Namespace prefixing, SSR guarding, and legacy key migration are all
* handled by the underlying `BrowserStorageAdapter`.
*/
load(key: string): string | null {
try {
if (typeof window === 'undefined' || !window.localStorage) return null;

const namespaced = window.localStorage.getItem(this.nsKey(key));
if (namespaced !== null) return namespaced;

// Legacy migration: when using the default namespace, check the old
// unprefixed key so existing installs are not silently wiped on upgrade.
if (this.namespace === DEFAULT_NAMESPACE) {
const legacy = window.localStorage.getItem(key);
if (legacy !== null) {
// Migrate to the namespaced key and remove the legacy entry.
window.localStorage.setItem(this.nsKey(key), legacy);
window.localStorage.removeItem(key);
return legacy;
}
}

return null;
} catch {
// SecurityError accessing window.localStorage on sandboxed/opaque origins,
// or SecurityError / other errors from getItem.
return null;
}
return this.storage.getItem(key);
}

/**
* Save a value to localStorage.
* Silently no-ops when localStorage is unavailable.
*
* Unlike `BrowserStorageAdapter.setItem`, this method also catches and
* **swallows** `QuotaExceededError` so that a full storage partition does
* not surface an uncaught exception. Higher-layer error handling
* (IntentEngine's `onError` callback) is the right place to observe this
* failure; the caller (`IntentEngine._persist()`) wraps this call in its
* own try/catch and routes any thrown error through `onError`.
* Wraps `BrowserStorageAdapter.setItem` in a try/catch so that
* `QuotaExceededError` and `SecurityError` are swallowed here rather than
* propagating to the caller. Higher-layer error handling (IntentEngine's
* `onError` callback) is the right place to observe these failures; the
* caller (`IntentEngine._persist()`) already wraps this call in its own
* try/catch and routes any thrown error through `onError`.
Comment on lines +91 to +94

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The updated JSDoc for save() says higher-layer error handling via IntentEngine._persist()/onError is the right place to observe quota/security failures, but this method intentionally swallows those errors in its own try/catch. As written, callers generally won’t observe these failures via onError because nothing is thrown. Please adjust the comment to reflect the actual behavior/contract (swallowed errors are not observable upstream).

Suggested change
* propagating to the caller. Higher-layer error handling (IntentEngine's
* `onError` callback) is the right place to observe these failures; the
* caller (`IntentEngine._persist()`) already wraps this call in its own
* try/catch and routes any thrown error through `onError`.
* propagating to the caller. As a result, these storage write failures
* are intentionally not observable upstream via `IntentEngine._persist()`
* or an `onError` callback, because nothing is rethrown from this method.

Copilot uses AI. Check for mistakes.
*/
save(key: string, value: string): void {
try {
if (typeof window === 'undefined' || !window.localStorage) return;
window.localStorage.setItem(this.nsKey(key), value);
this.storage.setItem(key, value);
} catch {
// SecurityError (sandboxed/opaque origin) or QuotaExceededError — swallowed.
}
Expand All @@ -131,11 +106,6 @@ export class LocalStorageAdapter implements IPersistenceAdapter {
* Silently no-ops when localStorage is unavailable.
*/
delete(key: string): void {
try {
if (typeof window === 'undefined' || !window.localStorage) return;
window.localStorage.removeItem(this.nsKey(key));
} catch {
// SecurityError or other storage errors — swallowed.
}
this.storage.removeItem(key);
}
}
95 changes: 95 additions & 0 deletions packages/core/tests/compatibility-matrix.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,108 @@ test('BrowserStorageAdapter gracefully degrades when window/localStorage are una
const adapter = new BrowserStorageAdapter();
assert.equal(adapter.getItem('missing'), null);
assert.doesNotThrow(() => adapter.setItem('k', 'v'));
assert.doesNotThrow(() => adapter.removeItem('k'));
} finally {
if (originalWindow !== undefined) {
globalThis.window = originalWindow;
}
}
});

test('BrowserStorageAdapter: removeItem() removes a namespaced key from localStorage', () => {
const store = new Map();
globalThis.window = {
localStorage: {
getItem: (k) => store.get(k) ?? null,
Comment on lines +64 to +68

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test mutates globalThis.window and then deletes it in finally, but it doesn’t preserve/restore any pre-existing globalThis.window. The test above in this file saves originalWindow and restores it, which avoids cross-test contamination when running under environments that already define window (e.g. jsdom). Consider using the same save/restore pattern here.

Copilot uses AI. Check for mistakes.
setItem: (k, v) => store.set(k, v),
removeItem: (k) => store.delete(k),
},
};
try {
const adapter = new BrowserStorageAdapter();
adapter.setItem('my-key', 'my-value');
assert.equal(adapter.getItem('my-key'), 'my-value');
adapter.removeItem('my-key');
assert.equal(adapter.getItem('my-key'), null, 'value must be absent after removeItem');
// Confirm the namespaced key (not the bare key) was removed.
assert.equal(store.has('passiveintent:my-key'), false);
assert.equal(store.has('my-key'), false);
} finally {
delete globalThis.window;
}
});

test('BrowserStorageAdapter: getItem() migrates legacy unprefixed key to namespaced key on first read', () => {
const store = new Map();
// Simulate an old SDK installation that wrote the key without a namespace prefix.
store.set('intent-key', 'legacy-value');
globalThis.window = {
localStorage: {
getItem: (k) => store.get(k) ?? null,
Comment on lines +87 to +93

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test deletes globalThis.window on cleanup without restoring any prior value. For better test isolation (and consistency with the first test in this file), capture const originalWindow = globalThis.window and restore it in finally when it was defined.

Copilot uses AI. Check for mistakes.
setItem: (k, v) => store.set(k, v),
removeItem: (k) => store.delete(k),
},
};
try {
const adapter = new BrowserStorageAdapter();
// First read must fall back to the unprefixed key and return the value.
assert.equal(adapter.getItem('intent-key'), 'legacy-value');
// The value must now be stored under the namespaced key.
assert.equal(store.get('passiveintent:intent-key'), 'legacy-value', 'value must be migrated to namespaced key');
// The legacy unprefixed key must be removed.
assert.equal(store.has('intent-key'), false, 'legacy key must be deleted after migration');
// Subsequent reads must hit the namespaced key directly.
assert.equal(adapter.getItem('intent-key'), 'legacy-value');
} finally {
delete globalThis.window;
}
});

test('BrowserStorageAdapter: getItem() does NOT migrate legacy key for custom namespaces', () => {
const store = new Map();
store.set('intent-key', 'legacy-value');
globalThis.window = {
localStorage: {
getItem: (k) => store.get(k) ?? null,
Comment on lines +113 to +118

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test isolation: this test overwrites globalThis.window and then deletes it, which can break subsequent tests if window was already defined. Please preserve originalWindow and restore it in finally (as done in the earlier degradation test).

Copilot uses AI. Check for mistakes.
setItem: (k, v) => store.set(k, v),
removeItem: (k) => store.delete(k),
},
};
try {
const adapter = new BrowserStorageAdapter('my-mfe:');
// Custom-namespace adapter must not touch the unprefixed key.
assert.equal(adapter.getItem('intent-key'), null);
assert.equal(store.has('intent-key'), true, 'legacy key must remain untouched');
} finally {
delete globalThis.window;
}
});

test('BrowserStorageAdapter: custom namespace prefixes keys independently from the default namespace', () => {
const store = new Map();
globalThis.window = {
localStorage: {
getItem: (k) => store.get(k) ?? null,
Comment on lines +133 to +137

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same globalThis.window cleanup issue as earlier: this test deletes window rather than restoring any previous value. Saving/restoring originalWindow will keep the test file safe to run under jsdom-like environments.

Copilot uses AI. Check for mistakes.
setItem: (k, v) => store.set(k, v),
removeItem: (k) => store.delete(k),
},
};
try {
const def = new BrowserStorageAdapter();
const mfe = new BrowserStorageAdapter('checkout:');
def.setItem('state', 'global');
mfe.setItem('state', 'checkout');
// Each adapter must only see its own namespaced value.
assert.equal(def.getItem('state'), 'global');
assert.equal(mfe.getItem('state'), 'checkout');
// Underlying keys must be stored with the correct prefixes.
assert.equal(store.get('passiveintent:state'), 'global');
assert.equal(store.get('checkout:state'), 'checkout');
} finally {
delete globalThis.window;
}
});

test('BrowserTimerAdapter works with platform timers and monotonic fallback', () => {
const timer = new BrowserTimerAdapter();
let fired = false;
Expand Down
Loading
Loading