diff --git a/packages/core/README.md b/packages/core/README.md index f26739e..acdd370 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -324,6 +324,7 @@ const accountsSource = ctx.registerSource('accounts', createAskableCollectionSou getState: () => ({ filters, sort, page, pageSize, totalCount }), getVisibleItems: () => table.getVisibleRows(), getSelectedItems: ({ selection }) => getAccountsByIds(selection), + getItemId: (account) => account.id, getItems: () => accountStore.getAllMatching({ filters, sort }), getSummary: ({ maxItems }) => summarizeAccounts({ filters, sort, maxItems }), maxItems: 50, @@ -371,6 +372,10 @@ named slices such as `summary`, `selected`, `all`, or app-defined modes, while result has more data than the DOM currently renders. Use `createAskablePageSource()` when an extension, fallback bridge, or unannotated page still needs selected text, headings, optional links, and full-page text. +For packet-driven selections, `getItemId` lets Askable map selected ids or +selected item labels from region, circle, square, lasso, or text packets back to +full app-owned collection items. Use `getSelectionItemId` when your selected +item metadata stores the id in an app-specific shape. Failed or timed-out sources are represented with a safe unavailable marker by default; use `sourceErrorMode: 'omit'` or `'throw'` for stricter runtimes. diff --git a/packages/core/src/__tests__/sources.test.ts b/packages/core/src/__tests__/sources.test.ts index 70f441f..a232a8d 100644 --- a/packages/core/src/__tests__/sources.test.ts +++ b/packages/core/src/__tests__/sources.test.ts @@ -160,6 +160,119 @@ describe('source helpers', () => { ctx.destroy(); }); + it('resolves packet-selected ids to full collection items', async () => { + const accounts = [ + { id: 'a1', company: 'Everlane Ops', mrr: 8400, secret: 'token-a' }, + { id: 'b2', company: 'Mercury Labs', mrr: 5200, secret: 'token-b' }, + { id: 'c3', company: 'Northstar Clinic', mrr: 2100, secret: 'token-c' }, + ]; + const ctx = createAskableContext(); + ctx.registerSource('accounts', createAskableCollectionSource({ + getItems: () => accounts, + getItemId: (account) => account.id, + sanitizeItem: ({ secret: _secret, ...safe }) => safe, + })); + + expect(ctx.listSources()[0].modes).toEqual(['selected', 'all']); + + const selected = await ctx.resolveSource('accounts', { + mode: 'selected', + selection: { + capture: { mode: 'lasso', gesture: 'drag' }, + source: { timestamp: '2026-06-05T00:00:00.000Z', route: '/accounts' }, + target: { + metadata: { + selectedIds: ['b2', 'c3'], + }, + }, + }, + }); + + expect(selected.data).toEqual({ + mode: 'selected', + items: [ + { id: 'b2', company: 'Mercury Labs', mrr: 5200 }, + { id: 'c3', company: 'Northstar Clinic', mrr: 2100 }, + ], + totalCount: 2, + returnedCount: 2, + truncated: false, + }); + expect(JSON.stringify(selected)).not.toContain('token-b'); + + ctx.destroy(); + }); + + it('resolves packet-selected item labels to full collection items', async () => { + const accounts = [ + { company: 'Everlane Ops', mrr: 8400 }, + { company: 'Mercury Labs', mrr: 5200 }, + ]; + const ctx = createAskableContext(); + ctx.registerSource('accounts', createAskableCollectionSource({ + getItems: () => accounts, + getItemId: (account) => account.company, + })); + + const selected = await ctx.resolveSource('accounts', { + mode: 'selected', + selection: { + capture: { mode: 'region', gesture: 'drag' }, + source: { timestamp: '2026-06-05T00:00:00.000Z', route: '/accounts' }, + target: { + metadata: { + selectedItems: [{ label: 'Mercury Labs' }], + }, + }, + }, + }); + + expect(selected.data).toMatchObject({ + mode: 'selected', + items: [{ company: 'Mercury Labs', mrr: 5200 }], + returnedCount: 1, + }); + + ctx.destroy(); + }); + + it('supports custom selected item id mapping for app-specific metadata', async () => { + const accounts = [ + { id: 'acct_1', company: 'Everlane Ops' }, + { id: 'acct_2', company: 'Mercury Labs' }, + ]; + const ctx = createAskableContext(); + ctx.registerSource('accounts', createAskableCollectionSource({ + getItems: () => accounts, + getItemId: (account) => account.id, + getSelectionItemId: (selectionItem) => { + if (!selectionItem || typeof selectionItem !== 'object') return undefined; + const meta = (selectionItem as { meta?: { accountId?: unknown } }).meta; + return typeof meta?.accountId === 'string' ? meta.accountId : undefined; + }, + })); + + const selected = await ctx.resolveSource('accounts', { + mode: 'selected', + selection: { + capture: { mode: 'lasso', gesture: 'drag' }, + source: { timestamp: '2026-06-05T00:00:00.000Z', route: '/accounts' }, + target: { + metadata: { + selectedItems: [{ label: 'Mercury Labs', meta: { accountId: 'acct_2' } }], + }, + }, + }, + }); + + expect(selected.data).toMatchObject({ + mode: 'selected', + items: [{ id: 'acct_2', company: 'Mercury Labs' }], + }); + + ctx.destroy(); + }); + it('advertises custom source modes without resolving data', () => { const ctx = createAskableContext(); const resolve = () => ({ ok: true }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d2a005d..97bd472 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -55,6 +55,7 @@ export type { AskableTextSelectionCaptureTheme, } from './selection.js'; export type { + AskableCollectionItemId, AskableCollectionSourceData, AskableCreateCollectionSourceOptions, AskableCreateSourceOptions, diff --git a/packages/core/src/sources.ts b/packages/core/src/sources.ts index 89080e6..8714a47 100644 --- a/packages/core/src/sources.ts +++ b/packages/core/src/sources.ts @@ -48,6 +48,8 @@ export interface AskableCollectionSourceData { truncated?: boolean; } +export type AskableCollectionItemId = string | number; + export interface AskableCreateCollectionSourceOptions { /** Source category. Defaults to "collection". */ kind?: string; @@ -63,6 +65,23 @@ export interface AskableCreateCollectionSourceOptions readonly TItem[] | Promise; /** Items explicitly selected by the user or active app state. */ getSelectedItems?: (request: AskableContextSourceResolveRequest) => readonly TItem[] | Promise; + /** + * Stable item id used to resolve packet-selected ids or labels back to full + * app-owned collection items. This lets selected DOM/region/lasso packets + * include records beyond the currently visible page. + */ + getItemId?: ( + item: TItem, + request: AskableContextSourceResolveRequest, + ) => AskableCollectionItemId | null | undefined; + /** + * Optional mapper for app-specific selected item metadata. By default Askable + * reads primitive ids and common object keys such as `id`, `key`, and `label`. + */ + getSelectionItemId?: ( + selectionItem: unknown, + request: AskableContextSourceResolveRequest, + ) => AskableCollectionItemId | null | undefined; /** Lightweight aggregate summary for prompt budgets. */ getSummary?: (request: AskableContextSourceResolveRequest) => unknown | Promise; /** Fallback for custom modes. */ @@ -181,7 +200,7 @@ function inferCollectionSourceModes( ...(options.getState ? ['state' as const] : []), ...(options.getSummary ? ['summary' as const] : []), ...(options.getVisibleItems ? ['visible' as const] : []), - ...(options.getSelectedItems ? ['selected' as const] : []), + ...(options.getSelectedItems || (options.getItems && options.getItemId) ? ['selected' as const] : []), ...(options.getItems ? ['all' as const] : []), ...(options.advertisedModes ?? []), ]); @@ -236,6 +255,21 @@ async function resolveCustomCollectionMode( return collectionItemsResult(await options.getSelectedItems(request), options, request); } + if (request.mode === 'selected' && options.getItems && options.getItemId) { + const selectedIds = extractSelectedCollectionItemIds(request, options); + if (selectedIds.size > 0) { + const items = await options.getItems(); + return collectionItemsResult( + items.filter((item) => { + const id = options.getItemId?.(item, request); + return id !== null && id !== undefined && selectedIds.has(normalizeCollectionItemId(id)); + }), + options, + request, + ); + } + } + if (request.mode === 'all' && options.getItems) { return collectionItemsResult(await options.getItems(), options, request); } @@ -247,6 +281,85 @@ async function resolveCustomCollectionMode( return undefined; } +function extractSelectedCollectionItemIds( + request: AskableContextSourceResolveRequest, + options: AskableCreateCollectionSourceOptions, +): Set { + const ids = new Set(); + + function add(value: unknown): void { + const id = extractCollectionItemId(value, request, options.getSelectionItemId); + if (id === null || id === undefined) return; + ids.add(normalizeCollectionItemId(id)); + } + + function addMany(value: unknown): void { + if (Array.isArray(value)) { + value.forEach(add); + return; + } + add(value); + } + + function readContainer(value: unknown): void { + if (!value || typeof value !== 'object') { + addMany(value); + return; + } + + const record = value as Record; + for (const key of ['selectedIds', 'selectedItemIds', 'ids', 'itemIds', 'selectedKeys', 'keys']) { + addMany(record[key]); + } + for (const key of ['selectedItems', 'items']) { + const selectedItems = record[key]; + if (Array.isArray(selectedItems)) selectedItems.forEach(add); + } + } + + readContainer(request.selection); + if (isAskablePacketSourceSelection(request.selection)) { + readContainer(request.selection.target?.metadata); + } + + return ids; +} + +function extractCollectionItemId( + value: unknown, + request: AskableContextSourceResolveRequest, + getSelectionItemId?: ( + selectionItem: unknown, + request: AskableContextSourceResolveRequest, + ) => AskableCollectionItemId | null | undefined, +): AskableCollectionItemId | null | undefined { + const custom = getSelectionItemId?.(value, request); + if (custom !== null && custom !== undefined) return custom; + if (typeof value === 'string' || typeof value === 'number') return value; + if (!value || typeof value !== 'object') return undefined; + + const record = value as Record; + for (const key of ['id', 'key', 'label']) { + const candidate = record[key]; + if (typeof candidate === 'string' || typeof candidate === 'number') return candidate; + } + + const meta = record.meta; + if (meta && typeof meta === 'object') { + const metaRecord = meta as Record; + for (const key of ['id', 'key', 'label']) { + const candidate = metaRecord[key]; + if (typeof candidate === 'string' || typeof candidate === 'number') return candidate; + } + } + + return undefined; +} + +function normalizeCollectionItemId(id: AskableCollectionItemId): string { + return String(id); +} + async function collectionItemsResult( items: readonly TItem[], options: AskableCreateCollectionSourceOptions, diff --git a/site/docs/api/core.md b/site/docs/api/core.md index 0984fd1..0ec6067 100644 --- a/site/docs/api/core.md +++ b/site/docs/api/core.md @@ -239,6 +239,7 @@ const handle = ctx.registerSource('accounts', createAskableCollectionSource({ }), getVisibleItems: () => table.getVisibleRows(), getSelectedItems: ({ selection }) => getSelectedAccounts(selection), + getItemId: (account) => account.id, getItems: () => accountStore.getAllMatching({ filters, sort }), getSummary: ({ focus, maxItems }) => summarizeAccounts({ filters, sort, focus, maxItems }), maxItems: 50, @@ -298,9 +299,14 @@ Use its `modes` map when the source can expose named slices without a custom switch statement; `resolve` remains available for advanced behavior and overrides both `modes` and `data`. `createAskableCollectionSource()` adds `getItems`, `getVisibleItems`, -`getSelectedItems`, `getSummary`, `maxItems`, and `sanitizeItem` so paginated or -virtualized collections can expose more than the rows currently mounted in the -DOM without a table-specific API. +`getSelectedItems`, `getItemId`, `getSummary`, `maxItems`, and `sanitizeItem` +so paginated or virtualized collections can expose more than the rows currently +mounted in the DOM without a table-specific API. +When `getItemId` and `getItems` are present, `selected` mode can resolve packet +metadata such as `selectedIds`, `selectedItemIds`, or `selectedItems` from +region, circle, square, or lasso captures back to full collection items. Use +`getSelectionItemId` when selected item metadata stores the id under an +app-specific key. `createAskablePageSource()` snapshots unannotated pages for extension and fallback contexts. It supports `summary`, `selected`, and `all` modes for page title, URL, selected text, headings, optional links, and capped full-page text. diff --git a/site/docs/api/types.md b/site/docs/api/types.md index 8f12aaf..9139212 100644 --- a/site/docs/api/types.md +++ b/site/docs/api/types.md @@ -325,6 +325,8 @@ interface AskableCreateCollectionSourceOptions readonly TItem[] | Promise; getVisibleItems?: () => readonly TItem[] | Promise; getSelectedItems?: (request: AskableContextSourceResolveRequest) => readonly TItem[] | Promise; + getItemId?: (item: TItem, request: AskableContextSourceResolveRequest) => string | number | null | undefined; + getSelectionItemId?: (selectionItem: unknown, request: AskableContextSourceResolveRequest) => string | number | null | undefined; getSummary?: (request: AskableContextSourceResolveRequest) => unknown | Promise; resolve?: (request: AskableContextSourceResolveRequest) => unknown | Promise; maxItems?: number; diff --git a/site/docs/guide/context.md b/site/docs/guide/context.md index 8c771a9..91e15ac 100644 --- a/site/docs/guide/context.md +++ b/site/docs/guide/context.md @@ -68,6 +68,7 @@ ctx.registerSource('accounts', createAskableCollectionSource({ describe: 'Customer accounts matching the active filters', getState: () => ({ filters, sort, page, pageSize, totalCount }), getVisibleItems: () => table.getRowModel().rows.map((row) => row.original), + getItemId: (account) => account.id, getItems: () => accountStore.getAllMatching({ filters, sort }), getSummary: ({ maxItems }) => summarizeAccounts({ filters, sort, maxItems }), sanitizeItem: redactAccountFields, diff --git a/site/docs/guide/serialization.md b/site/docs/guide/serialization.md index 6703765..40063b7 100644 --- a/site/docs/guide/serialization.md +++ b/site/docs/guide/serialization.md @@ -130,6 +130,7 @@ ctx.registerSource('accounts', createAskableCollectionSource({ getState: () => ({ filters, sort, page, pageSize, totalCount }), getVisibleItems: () => table.getVisibleRows(), getSelectedItems: ({ selection }) => getAccountsByIds(selection), + getItemId: (account) => account.id, getItems: () => accountStore.getAllMatching({ filters, sort }), getSummary: ({ maxItems }) => summarizeAccounts({ filters, sort, maxItems }), sanitizeItem: redactAccountFields, diff --git a/site/docs/guide/whats-new.md b/site/docs/guide/whats-new.md index 3ed1364..dacda1c 100644 --- a/site/docs/guide/whats-new.md +++ b/site/docs/guide/whats-new.md @@ -142,6 +142,7 @@ ctx.registerSource('accounts', createAskableCollectionSource({ getState: () => ({ filters, sort, page, pageSize, totalCount }), getVisibleItems: () => table.getVisibleRows(), getSelectedItems: ({ selection }) => getAccountsByIds(selection), + getItemId: (account) => account.id, getItems: () => accountStore.getAllMatching({ filters, sort }), getSummary: ({ maxItems }) => summarizeAccounts({ filters, sort, maxItems }), sanitizeItem: redactAccountFields, diff --git a/site/www/index.html b/site/www/index.html index 8e29119..76af291 100644 --- a/site/www/index.html +++ b/site/www/index.html @@ -60,18 +60,31 @@ /* NAV */ nav { position: sticky; - top: 0; + top: 1.25rem; z-index: 40; - height: 52px; + width: min(calc(100% - 2rem), 1280px); + height: auto; + min-height: 3rem; + margin: 1.25rem auto 0; display: flex; justify-content: space-between; align-items: center; - padding: 0 2rem; - background: rgba(255,255,255,0.88); - backdrop-filter: blur(10px) saturate(1.4); - border-bottom: 1px solid rgba(0,0,0,0.06); + gap: 1rem; + padding: 0; + background: transparent; + border: 0; } .nav-logo { + display: inline-flex; + align-items: center; + gap: .45rem; + min-height: 2.8rem; + padding: 0 .95rem; + border: 1px solid rgba(0,0,0,0.07); + border-radius: var(--radius-pill); + background: rgba(255,255,255,0.9); + box-shadow: 0 12px 34px rgba(15,23,42,0.1); + backdrop-filter: blur(16px) saturate(1.25); font-size: 1.05rem; font-weight: 800; color: var(--ink); @@ -79,13 +92,24 @@ letter-spacing: -.04em; } .nav-logo .spark { color: var(--accent); } - .nav-links { display: flex; align-items: center; gap: .3rem; } + .nav-links { + display: flex; + align-items: center; + gap: .35rem; + min-height: 3rem; + padding: .32rem .38rem; + border: 1px solid rgba(0,0,0,0.07); + border-radius: var(--radius-pill); + background: rgba(255,255,255,0.9); + box-shadow: 0 12px 34px rgba(15,23,42,0.1); + backdrop-filter: blur(16px) saturate(1.25); + } .nav-link { text-decoration: none; - color: var(--ink-3); + color: var(--ink-2); font-size: .84rem; - font-weight: 500; - padding: .38rem .72rem; + font-weight: 600; + padding: .5rem .78rem; border-radius: var(--radius-pill); transition: color .15s, background .15s; } @@ -101,7 +125,7 @@ background: var(--ink); color: #fff; text-decoration: none; - margin-left: .5rem; + margin-left: .15rem; transition: opacity .15s; } .nav-gh:hover { opacity: .82; } @@ -715,7 +739,16 @@ .pkg-grid { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } } @media (max-width: 720px) { - nav { padding: 0 1rem; } + nav { + top: .75rem; + width: min(calc(100% - 1.5rem), 1280px); + min-height: 2.8rem; + margin-top: .75rem; + padding: 0; + } + .nav-logo { min-height: 2.6rem; padding: 0 .8rem; } + .nav-links { min-height: 2.6rem; padding: .24rem; } + .nav-gh { width: 2.05rem; height: 2.05rem; margin-left: 0; } .nav-link { display: none; } .page-shell { padding: 0 1rem 4rem; } .hero { padding-top: 3.5rem; } @@ -1228,57 +1261,53 @@

Plugs into your AI layer.

- -

Expose selected UI context to Claude and ChatGPT.

-

Use hosted Web MCP for public HTTPS endpoints, or the page bridge for trusted browser extensions and local MCP companions.

+ +

Put Askable context on the page, then let MCP clients read it.

+

WebMCP is the browser-local path: a page widget connects to a local MCP server and exposes approved page tools, prompts, and resources. Hosted MCP is the separate HTTPS endpoint path for remote MCP clients.

-
https://your-app.com/mcp
+
page widget ↔ local MCP client
-

Server

-

Adapt the same Askable context your app already uses. Keep auth, rate limits, and tenancy in your host app.

-
const handler = createAskableMcpWebHandler({
-  authorize: (request) =>
-    Boolean(request.headers.get('Authorization')),
-  cors: { origin: ['https://app.example'] },
-  maxRequestBodyBytes: 256 * 1024,
-  telemetry: (event) => metrics.track(event),
-  provider: createAskableMcpContextProvider(ctx, {
-    history: 3,
-    includeViewport: true,
-    sources: ['accounts']
-  })
-});
-
-export const POST = handler;
+

WebMCP page

+

Add a page-side bridge that exposes the current Askable packet, prompt text, and approved app sources as page-owned context.

+
createAskableMcpPageBridge({
+  provider,
+  allowedOrigins: [
+    window.location.origin
+  ]
+});
-

Claude

-

Add the public remote MCP URL in Claude clients or the Anthropic Messages API MCP connector.

+

Local client

+

The user runs a local WebMCP MCP server in their client, creates a token, then connects the page widget with that token.

{
-  "type": "url",
-  "name": "askable-context",
-  "url": "https://your-app.com/mcp"
+  "mcpServers": {
+    "webmcp": {
+      "command": "npx",
+      "args": [
+        "-y",
+        "@jason.today/webmcp@latest",
+        "--mcp"
+      ]
+    }
+  }
 }
-

ChatGPT

-

Create an app from your remote MCP server in ChatGPT developer mode, or pass the URL as a remote MCP server in the Responses API.

-
{
-  "type": "mcp",
-  "server_label": "askable",
-  "server_url": "https://your-app.com/mcp"
-}
+

Page resources

+

Askable can provide the selected element, lassoed region, highlighted text, viewport, or app-owned source as a resource or tool result.

+
resource: "askable://current"
+text: ctx.toPromptContext()
+packet: ctx.toContextPacket()
-

Browser local

-

Add a page bridge for approved extensions or local companions. They receive context from the page, then expose their own local MCP server.

-
createAskableMcpPageBridge({
+            

Hosted MCP

+

For server-side use, expose the same provider through a secure HTTPS MCP route with auth, CORS, request limits, and tenancy checks.

+
createAskableMcpWebHandler({
+  authorize,
   provider,
-  allowedOrigins: [
-    window.location.origin
-  ]
+  maxRequestBodyBytes: 256 * 1024
 });