@pdpp/mcp-server 0.14.0 → 0.15.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/README.md CHANGED
@@ -3,6 +3,9 @@
3
3
  Local stdio and hosted Streamable HTTP [Model Context Protocol](https://modelcontextprotocol.io/)
4
4
  adapter for grant-scoped access to a [PDPP](https://pdpp.vivid.fish) resource server.
5
5
 
6
+ For the current hosted-client evidence-ladder smoke, see
7
+ [`docs/research/mcp-read-evidence-live-smoke-2026-06-24.md`](../../docs/research/mcp-read-evidence-live-smoke-2026-06-24.md).
8
+
6
9
  The adapter is a thin client of the PDPP resource server (RS). It does not run connectors,
7
10
  issue grants, or replicate any RS authorization logic. Every data-bearing tool call is a
8
11
  forwarded request to an existing `/v1/*` endpoint, authenticated with the scoped client
@@ -65,8 +68,19 @@ The adapter writes only MCP protocol messages to stdout. Diagnostics go to stder
65
68
  | `aggregate` | `GET /v1/streams/{stream}/aggregate` |
66
69
  | `search` | `GET /v1/search` |
67
70
  | `fetch` | `GET /v1/streams/{stream}/records/{record_id}` |
71
+ | `read_record_field` | `GET /v1/streams/{stream}/records/{record_id}/field-window` |
72
+
73
+ `read_record_field` is separate from `fetch` intentionally. `fetch` returns a
74
+ record/document, optionally projected with `fields`; `read_record_field` returns
75
+ one bounded text window with truncation and match metadata. Keeping the bounded
76
+ read as its own tool prevents ordinary evidence inspection from escalating to a
77
+ full record fetch, resource link, or file/materialized artifact.
78
+
79
+ Resource templates:
68
80
 
69
- Plus one resource template: `pdpp://stream/{name}` → `GET /v1/streams/{name}`.
81
+ - `pdpp://stream/{name}` → `GET /v1/streams/{name}`.
82
+ - `pdpp://record/{handle}` → one grant-scoped record read.
83
+ - `pdpp://field-window/{handle}` → one grant-scoped bounded field window.
70
84
 
71
85
  `search` preserves the RS envelope in `structuredContent.data` and also returns
72
86
  ChatGPT-compatible `structuredContent.results[]` entries with `id`, `title`, `url`,
@@ -91,6 +105,26 @@ every text-like field (`text`, `content`, `body`, `summary`), the document
91
105
  document body; source handles such as stream, `connection_id`, and
92
106
  `connector_key` remain in `metadata`.
93
107
 
108
+ ### Content ladder for large fields
109
+
110
+ The adapter exposes long-field navigation in layers:
111
+
112
+ 1. `search`, `query_records`, and `fetch` keep compact model-visible text.
113
+ 2. `structuredContent.content_ladder` carries record resource URIs and `read_record_field` arguments when the adapter can derive a concrete `connection_id`, stream, record id, and top-level text field path.
114
+ 3. `read_record_field` reads a bounded text window by `offset_chars`, returned `cursor`, or first-match `q` plus optional `before_chars` / `after_chars`.
115
+ 4. Resource-aware clients can follow `resource_link` blocks through `resources/read`; the same scoped PDPP client token is still required. Handles are continuation state, not bearer authorization.
116
+
117
+ The MCP SDK version used here is `@modelcontextprotocol/sdk` 1.29.0. The implementation uses `resource_link` content blocks and `resources/read` resource templates from that SDK. The package tests cover three client behavior classes: content-only clients, structured-content-aware clients, and resource-aware clients. Live hosted-client behavior is not claimed here unless separately smoke-tested.
118
+
119
+ Compatibility notes:
120
+
121
+ | Client class | Supported path |
122
+ | --- | --- |
123
+ | Content-only clients, including hosts that hide `structuredContent` | Read compact `content[]` summaries, then call `fetch` or `read_record_field` with visible ids/cursors. |
124
+ | Structured-content-aware clients, including common agent harnesses | Use `structuredContent.results`, `structuredContent.data`, and `content_ladder` without parsing prose. |
125
+ | Resource-aware clients, including MCP clients that expose `resources/read` | Follow `resource_link` URIs such as `pdpp://field-window/{handle}`. |
126
+ | Clients whose renderer hides resources or wrapper metadata | Use the normal tools; no data is available only through an opaque resource marker. |
127
+
94
128
  The `schema` tool includes concise parseable text with stream
95
129
  names, `connection_id`, `connector_key`, display labels, and schema field-capability
96
130
  essentials so MCP clients whose models read only `content[]` can still choose streams,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pdpp/mcp-server",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Local stdio MCP adapter for grant-scoped PDPP reads and event-subscription management.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,6 +23,7 @@
23
23
  "dependencies": {
24
24
  "@modelcontextprotocol/sdk": "^1.29.0",
25
25
  "@pdpp/cli": "workspace:*",
26
+ "@pdpp/read-core": "workspace:^",
26
27
  "zod": "^3.25.76"
27
28
  },
28
29
  "keywords": [
package/src/index.js CHANGED
@@ -138,4 +138,4 @@ export {
138
138
  PDPP_MCP_TOOL_NAMES,
139
139
  } from './server.js';
140
140
  export { RsClient } from './rs-client.js';
141
- export { buildTools, buildStreamResourceTemplate, InvalidResourceUriError } from './tools.js';
141
+ export { buildTools, buildResourceTemplates, buildStreamResourceTemplate, InvalidResourceUriError } from './tools.js';
package/src/server.js CHANGED
@@ -3,7 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
4
4
 
5
5
  import {
6
- buildStreamResourceTemplate,
6
+ buildResourceTemplates,
7
7
  buildTools,
8
8
  PDPP_MCP_TOOL_NAMES,
9
9
  } from './tools.js';
@@ -18,7 +18,8 @@ export const DEFAULT_SERVER_VERSION = '0.0.0';
18
18
  // here; tool descriptions stay concise and routing-specific.
19
19
  export const PDPP_MCP_INSTRUCTIONS =
20
20
  'PDPP tools are grant-scoped. Start with `schema`, then call `schema(stream)` after choosing a stream; add `connection_id` when a stream name appears under multiple sources or before full schema. Use `connection_id` from schema results or `available_connections` errors to disambiguate sources. Filters must be typed objects, not bracket strings. Page and narrow with `limit`, `cursor`, and `fields`; prefer `aggregate` or lexical `search` for exact terms. ' +
21
- 'The configured bearer limits every result; do not use owner or control-plane tokens for normal MCP access. Schema advertises valid fields, filter operators, expand relations, sort/count support, connection identities, and connector keys. Persist `connection_id`, not `grant_id`, across reconnects. Search result ids are self-contained `fetch` handles; pass them to `fetch` unchanged. ' +
21
+ 'The configured bearer limits every result; do not use owner or control-plane tokens for normal MCP access. Schema advertises valid fields, filter operators, expand relations, sort/count support, connection identities, and connector keys. Persist `connection_id`, not `grant_id`, across reconnects. Search result ids are self-contained handles; pass them to `fetch` for projected records or to `read_record_field` for bounded field windows. ' +
22
+ 'When a preview is not enough, follow `structuredContent.content_ladder`: call `read_record_field` with the supplied arguments. Resource-aware hosts may also read hidden/returned resource URIs, but generic resource reads are not required for ordinary text evidence. ' +
22
23
  '`content[]` is the reliable model-visible guide and includes next cursors/bookmarks when present; `structuredContent` is a host-dependent machine envelope, not the only place to find next-step handles.';
23
24
 
24
25
  /**
@@ -74,19 +75,20 @@ export function createPdppMcpServer({
74
75
  );
75
76
  }
76
77
 
77
- const streamTemplate = buildStreamResourceTemplate({ rs, providerUrl });
78
- server.registerResource(
79
- streamTemplate.name,
80
- new ResourceTemplate(streamTemplate.uriTemplate, { list: undefined }),
81
- {
82
- title: streamTemplate.title,
83
- description: streamTemplate.description,
84
- mimeType: streamTemplate.mimeType,
85
- },
86
- async (uri, variables) => {
87
- return await streamTemplate.read(uri.href ?? String(uri), variables);
88
- }
89
- );
78
+ for (const template of buildResourceTemplates({ rs, providerUrl })) {
79
+ server.registerResource(
80
+ template.name,
81
+ new ResourceTemplate(template.uriTemplate, { list: undefined }),
82
+ {
83
+ title: template.title,
84
+ description: template.description,
85
+ mimeType: template.mimeType,
86
+ },
87
+ async (uri, variables) => {
88
+ return await template.read(uri.href ?? String(uri), variables);
89
+ }
90
+ );
91
+ }
90
92
 
91
93
  return { server, rs };
92
94
  }
package/src/tools.js CHANGED
@@ -1,3 +1,7 @@
1
+ import {
2
+ buildRecordContentLadder as buildSharedRecordContentLadder,
3
+ summarizeRecordEvidence,
4
+ } from '@pdpp/read-core';
1
5
  import { z } from 'zod';
2
6
 
3
7
  const READ_ONLY_ANNOTATIONS = {
@@ -13,6 +17,7 @@ export const PDPP_MCP_TOOL_NAMES = Object.freeze([
13
17
  'aggregate',
14
18
  'search',
15
19
  'fetch',
20
+ 'read_record_field',
16
21
  ]);
17
22
 
18
23
  function selectNormalTools(tools) {
@@ -213,6 +218,14 @@ class ConflictingConnectionIdError extends Error {
213
218
  }
214
219
  }
215
220
 
221
+ class InvalidReadRecordFieldSelectorError extends Error {
222
+ constructor(message) {
223
+ super(message);
224
+ this.name = 'InvalidReadRecordFieldSelectorError';
225
+ this.code = 'invalid_field_window_selector';
226
+ }
227
+ }
228
+
216
229
  // Translate a typed filter object into `[bracketKey, value]` query entries the
217
230
  // RsClient appends verbatim (`filter[field]=value`, `filter[field][op]=value`).
218
231
  function filterObjectToBracketEntries(filter) {
@@ -315,6 +328,7 @@ const READ_OUTPUT_SCHEMA_SHAPE = {
315
328
  .describe(
316
329
  'Canonical RS response body. Follows the public read envelope advertised by `GET /v1/schema` plus operation-specific extensions; source metadata uses canonical `connector_key` and concrete `connection_id` values when present.',
317
330
  ),
331
+ content_ladder: z.unknown().optional(),
318
332
  provider_url: z.string().describe('RS base URL the MCP server was configured with.'),
319
333
  request_id: z.string().nullable().describe('RS x-request-id when present.'),
320
334
  };
@@ -340,6 +354,43 @@ const FETCH_OUTPUT_SCHEMA_SHAPE = {
340
354
  text: z.string(),
341
355
  url: z.string(),
342
356
  metadata: z.record(z.string(), z.unknown()),
357
+ content_ladder: z.unknown().optional(),
358
+ };
359
+
360
+ const READ_RECORD_FIELD_OUTPUT_SCHEMA_SHAPE = {
361
+ record: z
362
+ .object({
363
+ id: z.string(),
364
+ connection_id: z.string().nullable(),
365
+ stream: z.string(),
366
+ record_id: z.string(),
367
+ })
368
+ .passthrough(),
369
+ field: z
370
+ .object({
371
+ path: z.string(),
372
+ type: z.string().optional(),
373
+ text_like: z.boolean(),
374
+ })
375
+ .passthrough(),
376
+ window: z
377
+ .object({
378
+ text: z.string(),
379
+ start_chars: z.number().int().min(0),
380
+ end_chars: z.number().int().min(0),
381
+ limit_chars: z.number().int().min(1),
382
+ complete: z.boolean(),
383
+ })
384
+ .passthrough(),
385
+ resource: z
386
+ .object({
387
+ uri: z.string(),
388
+ mime_type: z.string(),
389
+ })
390
+ .passthrough()
391
+ .optional(),
392
+ provider_url: z.string(),
393
+ request_id: z.string().nullable(),
343
394
  };
344
395
 
345
396
  const DISCOVERY_STREAM_SUMMARY_LIMIT = 50;
@@ -486,9 +537,11 @@ export function buildTools({ rs, providerUrl }) {
486
537
  const response = await rs.getJson(`/v1/streams/${encodeURIComponent(stream)}/records`, {
487
538
  query,
488
539
  });
489
- return toToolResult(response, providerUrl, `records from stream "${stream}"`, {
490
- previewRecords: true,
491
- });
540
+ return toToolResult(response, providerUrl, `records from stream "${stream}"`, {
541
+ previewRecords: true,
542
+ contentLadderStream: stream,
543
+ contentLadderConnectionId: args?.connection_id,
544
+ });
492
545
  },
493
546
  },
494
547
  {
@@ -581,7 +634,7 @@ export function buildTools({ rs, providerUrl }) {
581
634
  args.filter,
582
635
  );
583
636
  const response = await rs.getJson(path, { query });
584
- return toSearchToolResult(response, providerUrl, { limit: args?.limit });
637
+ return toSearchToolResult(response, providerUrl, { limit: args?.limit, q: args?.q });
585
638
  },
586
639
  },
587
640
  {
@@ -628,6 +681,51 @@ export function buildTools({ rs, providerUrl }) {
628
681
  return toFetchToolResult(response, providerUrl, args.id);
629
682
  },
630
683
  },
684
+ {
685
+ name: 'read_record_field',
686
+ title: 'Read PDPP record field window',
687
+ description: 'Read bounded field text. Page by offset, cursor, or q. Read-only.',
688
+ annotations: READ_ONLY_ANNOTATIONS,
689
+ inputSchema: z
690
+ .object({
691
+ id: z.string().min(1).optional(),
692
+ connection_id: z.string().min(1).optional(),
693
+ stream: z.string().min(1).optional(),
694
+ record_id: z.string().min(1).optional(),
695
+ field_path: z.string().min(1),
696
+ cursor: z
697
+ .string()
698
+ .min(1)
699
+ .optional(),
700
+ offset_chars: z.number().int().min(0).optional(),
701
+ limit_chars: z
702
+ .number()
703
+ .int()
704
+ .min(1)
705
+ .max(16384)
706
+ .optional(),
707
+ q: z.string().min(1).optional(),
708
+ before_chars: z.number().int().min(0).max(8192).optional(),
709
+ after_chars: z.number().int().min(0).max(8192).optional(),
710
+ })
711
+ .strict(),
712
+ outputSchema: z.object(READ_RECORD_FIELD_OUTPUT_SCHEMA_SHAPE),
713
+ handler: async (args) => {
714
+ const ref = resolveReadRecordFieldRef(args);
715
+ const fieldPath = requireSafeName(args?.field_path, 'field_path');
716
+ const query = readRecordFieldQuery(args, ref.connectionId, fieldPath);
717
+ const response = await rs.getJson(
718
+ `/v1/streams/${encodeURIComponent(ref.stream)}/records/${encodeURIComponent(ref.recordId)}/field-window`,
719
+ { query }
720
+ );
721
+ return toReadRecordFieldToolResult(response, providerUrl, {
722
+ connectionId: ref.connectionId,
723
+ stream: ref.stream,
724
+ recordId: ref.recordId,
725
+ fieldPath,
726
+ });
727
+ },
728
+ },
631
729
  ];
632
730
 
633
731
  return selectNormalTools(tools);
@@ -639,6 +737,151 @@ function searchPathForMode(mode) {
639
737
  return '/v1/search';
640
738
  }
641
739
 
740
+ function resolveReadRecordFieldRef(args) {
741
+ const hasId = typeof args?.id === 'string' && args.id.length > 0;
742
+ const hasExplicit = args?.connection_id !== undefined || args?.stream !== undefined || args?.record_id !== undefined;
743
+ if (hasId && hasExplicit) {
744
+ throw new InvalidReadRecordFieldSelectorError(
745
+ '`id` is exclusive with explicit `connection_id`, `stream`, and `record_id`; pass one record identity form'
746
+ );
747
+ }
748
+ if (hasId) {
749
+ const ref = parseRecordResultId(args.id);
750
+ if (!ref.connectionId) {
751
+ throw new InvalidReadRecordFieldSelectorError(
752
+ '`id` for read_record_field must include connection_id (`connection_id/stream:record_id`) or use explicit connection_id + stream + record_id'
753
+ );
754
+ }
755
+ return ref;
756
+ }
757
+ if (!args?.connection_id || !args?.stream || !args?.record_id) {
758
+ throw new InvalidReadRecordFieldSelectorError(
759
+ 'read_record_field requires either `id` + `field_path` or `connection_id` + `stream` + `record_id` + `field_path`'
760
+ );
761
+ }
762
+ return {
763
+ connectionId: requireSafeName(args.connection_id, 'connection_id'),
764
+ stream: requireSafeName(args.stream, 'stream'),
765
+ recordId: requireSafeName(args.record_id, 'record_id'),
766
+ };
767
+ }
768
+
769
+ function readRecordFieldQuery(args, connectionId, fieldPath) {
770
+ if (args?.cursor !== undefined && args?.offset_chars !== undefined) {
771
+ throw new InvalidReadRecordFieldSelectorError('`cursor` is exclusive with `offset_chars`');
772
+ }
773
+ if (args?.q !== undefined && (args?.cursor !== undefined || args?.offset_chars !== undefined)) {
774
+ throw new InvalidReadRecordFieldSelectorError('`q` is exclusive with `cursor` and `offset_chars`');
775
+ }
776
+ if ((args?.before_chars !== undefined || args?.after_chars !== undefined) && args?.q === undefined) {
777
+ throw new InvalidReadRecordFieldSelectorError('`before_chars` and `after_chars` require `q`');
778
+ }
779
+ const query = {
780
+ connection_id: connectionId,
781
+ field: fieldPath,
782
+ };
783
+ if (args?.cursor !== undefined) {
784
+ query.offset_chars = parseFieldWindowCursor(args.cursor);
785
+ } else if (args?.offset_chars !== undefined) {
786
+ query.offset_chars = args.offset_chars;
787
+ }
788
+ if (args?.limit_chars !== undefined) {
789
+ query.limit_chars = args.limit_chars;
790
+ }
791
+ if (args?.q !== undefined) {
792
+ query.q = args.q;
793
+ }
794
+ if (args?.before_chars !== undefined) {
795
+ query.before_chars = args.before_chars;
796
+ }
797
+ if (args?.after_chars !== undefined) {
798
+ query.after_chars = args.after_chars;
799
+ }
800
+ return query;
801
+ }
802
+
803
+ function parseFieldWindowCursor(cursor) {
804
+ if (typeof cursor !== 'string' || !/^\d+$/.test(cursor)) {
805
+ throw new InvalidReadRecordFieldSelectorError('field-window cursor must be a non-negative integer offset returned by read_record_field');
806
+ }
807
+ return Number.parseInt(cursor, 10);
808
+ }
809
+
810
+ export function buildResourceTemplates({ rs, providerUrl }) {
811
+ return [
812
+ buildStreamResourceTemplate({ rs, providerUrl }),
813
+ buildRecordResourceTemplate({ rs, providerUrl }),
814
+ buildRecordFieldResourceTemplate({ rs, providerUrl }),
815
+ ];
816
+ }
817
+
818
+ function buildRecordResourceTemplate({ rs, providerUrl }) {
819
+ return {
820
+ uriTemplate: 'pdpp://record/{handle}',
821
+ name: 'pdpp-record',
822
+ title: 'PDPP record',
823
+ description: 'Returns one grant-scoped PDPP record through the resource server. Read-only.',
824
+ mimeType: 'application/json',
825
+ read: async (uri, variables) => {
826
+ const ref = resolveRecordResourceRef(uri, variables);
827
+ const query = {};
828
+ if (ref.connectionId) query.connection_id = ref.connectionId;
829
+ const response = await rs.getJson(
830
+ `/v1/streams/${encodeURIComponent(ref.stream)}/records/${encodeURIComponent(ref.recordId)}`,
831
+ { query }
832
+ );
833
+ if (response.ok) {
834
+ return {
835
+ contents: [
836
+ {
837
+ uri,
838
+ mimeType: 'application/json',
839
+ text: JSON.stringify(response.body, null, 2),
840
+ },
841
+ ],
842
+ };
843
+ }
844
+ return resourceErrorContents(uri, response, providerUrl);
845
+ },
846
+ };
847
+ }
848
+
849
+ function buildRecordFieldResourceTemplate({ rs, providerUrl }) {
850
+ return {
851
+ uriTemplate: 'pdpp://field-window/{handle}',
852
+ name: 'pdpp-field-window',
853
+ title: 'PDPP record field window',
854
+ description: 'Returns one bounded text window from an authorized PDPP record field. Read-only.',
855
+ mimeType: 'text/plain',
856
+ read: async (uri, variables) => {
857
+ const ref = resolveFieldWindowResourceRef(uri, variables);
858
+ const query = readRecordFieldQuery(ref, ref.connectionId, ref.field_path);
859
+ const response = await rs.getJson(
860
+ `/v1/streams/${encodeURIComponent(ref.stream)}/records/${encodeURIComponent(ref.recordId)}/field-window`,
861
+ { query }
862
+ );
863
+ if (!response.ok) {
864
+ return resourceErrorContents(uri, response, providerUrl);
865
+ }
866
+ const result = toReadRecordFieldToolResult(response, providerUrl, {
867
+ connectionId: ref.connectionId,
868
+ stream: ref.stream,
869
+ recordId: ref.recordId,
870
+ fieldPath: ref.field_path,
871
+ });
872
+ return {
873
+ contents: [
874
+ {
875
+ uri,
876
+ mimeType: 'text/plain',
877
+ text: result.content[0]?.text ?? '',
878
+ },
879
+ ],
880
+ };
881
+ },
882
+ };
883
+ }
884
+
642
885
  export function buildStreamResourceTemplate({ rs, providerUrl }) {
643
886
  return {
644
887
  uriTemplate: 'pdpp://stream/{name}',
@@ -675,6 +918,57 @@ export function buildStreamResourceTemplate({ rs, providerUrl }) {
675
918
  };
676
919
  }
677
920
 
921
+ function resourceErrorContents(uri, response, providerUrl) {
922
+ const error = response.error ?? { type: 'rs_error', code: 'unknown', message: 'Unknown RS error' };
923
+ return {
924
+ contents: [
925
+ {
926
+ uri,
927
+ mimeType: 'application/json',
928
+ text: JSON.stringify({ error, provider_url: providerUrl, http_status: response.status }, null, 2),
929
+ },
930
+ ],
931
+ };
932
+ }
933
+
934
+ function resolveRecordResourceRef(uri, variables) {
935
+ const payload = decodeResourceHandle(resolveResourceHandle(uri, variables, 'record'), 'record');
936
+ return {
937
+ connectionId: requireSafeName(payload.connection_id, 'connection_id'),
938
+ stream: requireSafeName(payload.stream, 'stream'),
939
+ recordId: requireSafeName(payload.record_id, 'record_id'),
940
+ };
941
+ }
942
+
943
+ function resolveFieldWindowResourceRef(uri, variables) {
944
+ const payload = decodeResourceHandle(resolveResourceHandle(uri, variables, 'field-window'), 'field-window');
945
+ const ref = {
946
+ connectionId: requireSafeName(payload.connection_id, 'connection_id'),
947
+ stream: requireSafeName(payload.stream, 'stream'),
948
+ recordId: requireSafeName(payload.record_id, 'record_id'),
949
+ field_path: requireSafeName(payload.field_path, 'field_path'),
950
+ };
951
+ if (payload.cursor !== undefined) ref.cursor = String(payload.cursor);
952
+ if (payload.offset_chars !== undefined) ref.offset_chars = payload.offset_chars;
953
+ if (payload.limit_chars !== undefined) ref.limit_chars = payload.limit_chars;
954
+ if (payload.q !== undefined) ref.q = String(payload.q);
955
+ if (payload.before_chars !== undefined) ref.before_chars = payload.before_chars;
956
+ if (payload.after_chars !== undefined) ref.after_chars = payload.after_chars;
957
+ return ref;
958
+ }
959
+
960
+ function resolveResourceHandle(uri, variables, kind) {
961
+ const rawFromVariables = variables?.handle;
962
+ if (typeof rawFromVariables === 'string' && rawFromVariables.length > 0) {
963
+ return decodeIfEncoded(rawFromVariables);
964
+ }
965
+ const match = new RegExp(`^pdpp://${kind}/([^/]+)$`).exec(uri);
966
+ if (!match) {
967
+ throw new InvalidResourceUriError(`Resource URI ${uri} does not match pdpp://${kind}/{handle}.`);
968
+ }
969
+ return decodeURIComponent(match[1]);
970
+ }
971
+
678
972
  function resolveStreamName(uri, variables) {
679
973
  const rawFromVariables = variables?.name;
680
974
  if (typeof rawFromVariables === 'string' && rawFromVariables.length > 0) {
@@ -695,6 +989,30 @@ function decodeIfEncoded(value) {
695
989
  }
696
990
  }
697
991
 
992
+ function encodeResourceUri(kind, payload) {
993
+ return `pdpp://${kind}/${encodeResourceHandle({ v: 1, kind, ...payload })}`;
994
+ }
995
+
996
+ function encodeResourceHandle(payload) {
997
+ return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
998
+ }
999
+
1000
+ function decodeResourceHandle(handle, expectedKind) {
1001
+ let payload;
1002
+ try {
1003
+ payload = JSON.parse(Buffer.from(handle, 'base64url').toString('utf8'));
1004
+ } catch {
1005
+ throw new InvalidResourceUriError(`Resource handle for ${expectedKind} is malformed.`);
1006
+ }
1007
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
1008
+ throw new InvalidResourceUriError(`Resource handle for ${expectedKind} is malformed.`);
1009
+ }
1010
+ if (payload.v !== 1 || payload.kind !== expectedKind) {
1011
+ throw new InvalidResourceUriError(`Resource handle for ${expectedKind} has the wrong kind or version.`);
1012
+ }
1013
+ return payload;
1014
+ }
1015
+
698
1016
  export class InvalidResourceUriError extends Error {
699
1017
  constructor(message) {
700
1018
  super(message);
@@ -744,6 +1062,7 @@ function pickQuery(args, supportedKeys) {
744
1062
  function toToolResult(response, providerUrl, label = 'response', options = {}) {
745
1063
  if (response.ok) {
746
1064
  const body = response.body;
1065
+ const contentLadder = buildResponseContentLadder(body, options);
747
1066
  return {
748
1067
  content: [
749
1068
  {
@@ -751,7 +1070,12 @@ function toToolResult(response, providerUrl, label = 'response', options = {}) {
751
1070
  text: summarizeBody(body, label, options),
752
1071
  },
753
1072
  ],
754
- structuredContent: { data: body, provider_url: providerUrl, request_id: response.requestId },
1073
+ structuredContent: {
1074
+ data: body,
1075
+ ...(contentLadder ? { content_ladder: contentLadder } : {}),
1076
+ provider_url: providerUrl,
1077
+ request_id: response.requestId,
1078
+ },
755
1079
  };
756
1080
  }
757
1081
  return errorToolResult(response, providerUrl);
@@ -1001,13 +1325,14 @@ function toSearchToolResult(response, providerUrl, options = {}) {
1001
1325
  if (!response.ok) {
1002
1326
  return errorToolResult(response, providerUrl);
1003
1327
  }
1004
- const allResults = normalizeSearchResults(response.body);
1328
+ const allResults = normalizeSearchResults(response.body, { q: options.q });
1005
1329
  const limit = requestedSearchLimit(options.limit);
1006
1330
  const results = allResults.slice(0, limit);
1007
1331
  const summaryBody = allResults.length > results.length
1008
1332
  ? { ...response.body, has_more: true }
1009
1333
  : response.body;
1010
1334
  const data = compactSearchEnvelope(summaryBody, { resultCount: results.length });
1335
+ const contentLadder = buildSearchContentLadder(results);
1011
1336
  return {
1012
1337
  content: [
1013
1338
  {
@@ -1018,17 +1343,266 @@ function toSearchToolResult(response, providerUrl, options = {}) {
1018
1343
  structuredContent: {
1019
1344
  data,
1020
1345
  results,
1346
+ ...(contentLadder ? { content_ladder: contentLadder } : {}),
1021
1347
  provider_url: providerUrl,
1022
1348
  request_id: response.requestId,
1023
1349
  },
1024
1350
  };
1025
1351
  }
1026
1352
 
1353
+ function toReadRecordFieldToolResult(response, providerUrl, identity) {
1354
+ if (!response.ok) {
1355
+ return errorToolResult(response, providerUrl);
1356
+ }
1357
+ const body = objectValue(response.body) || {};
1358
+ const field = objectValue(body.field) || {};
1359
+ const rawWindow = objectValue(body.window) || {};
1360
+ const stream = firstString(body.stream, identity.stream);
1361
+ const recordId = firstString(body.record_id, identity.recordId);
1362
+ const connectionId = firstString(body.connection_id, body.connector_instance_id, identity.connectionId) || null;
1363
+ const record = {
1364
+ id: connectionId ? `${connectionId}/${stream}:${recordId}` : `${stream}:${recordId}`,
1365
+ connection_id: connectionId,
1366
+ stream,
1367
+ record_id: recordId,
1368
+ };
1369
+ const window = {
1370
+ ...rawWindow,
1371
+ next_cursor: rawWindow.next_offset_chars === null || rawWindow.next_offset_chars === undefined ? null : String(rawWindow.next_offset_chars),
1372
+ previous_cursor:
1373
+ rawWindow.previous_offset_chars === null || rawWindow.previous_offset_chars === undefined
1374
+ ? null
1375
+ : String(rawWindow.previous_offset_chars),
1376
+ };
1377
+ const fieldInfo = {
1378
+ path: firstString(field.path, identity.fieldPath),
1379
+ type: firstString(field.type, rawWindow.type),
1380
+ text_like: true,
1381
+ };
1382
+ const resourceUri = encodeResourceUri('field-window', {
1383
+ connection_id: connectionId,
1384
+ stream,
1385
+ record_id: recordId,
1386
+ field_path: fieldInfo.path,
1387
+ offset_chars: window.start_chars,
1388
+ limit_chars: window.limit_chars,
1389
+ });
1390
+ const resource = {
1391
+ uri: resourceUri,
1392
+ mime_type: 'text/plain',
1393
+ };
1394
+ const header = [
1395
+ `record=${record.id}`,
1396
+ `field=${fieldInfo.path}`,
1397
+ `chars=${formatScalar(window.start_chars)}..${formatScalar(window.end_chars)}`,
1398
+ `total_chars=${formatScalar(window.total_chars)}`,
1399
+ `complete=${window.complete === true ? 'true' : 'false'}`,
1400
+ window.next_cursor ? `next_cursor=${window.next_cursor}` : null,
1401
+ window.previous_cursor ? `previous_cursor=${window.previous_cursor}` : null,
1402
+ ]
1403
+ .filter(Boolean)
1404
+ .join(' ');
1405
+ const text = typeof window.text === 'string' ? window.text : '';
1406
+ return {
1407
+ content: [
1408
+ {
1409
+ type: 'text',
1410
+ text: `${header}\n${text}`,
1411
+ },
1412
+ ],
1413
+ structuredContent: {
1414
+ record,
1415
+ field: fieldInfo,
1416
+ window,
1417
+ provider_url: providerUrl,
1418
+ request_id: response.requestId ?? null,
1419
+ },
1420
+ _meta: {
1421
+ resource,
1422
+ },
1423
+ };
1424
+ }
1425
+
1426
+ const CONTENT_LADDER_RECORD_LIMIT = 5;
1427
+ const CONTENT_LADDER_FIELD_LIMIT = 5;
1428
+ const CONTENT_LADDER_WINDOW_LIMIT_CHARS = 4096;
1429
+ const CONTENT_LADDER_BINARY_FIELD_LIMIT = 5;
1430
+ const BINARY_INLINE_STRING_CHAR_LIMIT = 256;
1431
+ const CONTENT_LADDER_OMIT_FIELD_KEYS = new Set([
1432
+ 'id',
1433
+ 'record_id',
1434
+ 'recordId',
1435
+ 'record_key',
1436
+ 'recordKey',
1437
+ 'stream',
1438
+ 'stream_name',
1439
+ 'streamName',
1440
+ 'connection_id',
1441
+ 'connector_instance_id',
1442
+ 'connector_key',
1443
+ 'connector_id',
1444
+ 'display_name',
1445
+ 'emitted_at',
1446
+ 'updated_at',
1447
+ 'created_at',
1448
+ ]);
1449
+
1450
+ function buildResponseContentLadder(body, options = {}) {
1451
+ const records = extractRecordRows(body)
1452
+ .map((record) =>
1453
+ buildRecordContentLadder(record, {
1454
+ stream: options.contentLadderStream,
1455
+ connectionId: options.contentLadderConnectionId,
1456
+ })
1457
+ )
1458
+ .filter(Boolean)
1459
+ .slice(0, CONTENT_LADDER_RECORD_LIMIT);
1460
+ if (records.length === 0) return null;
1461
+ return {
1462
+ kind: 'record_set',
1463
+ read_tool: 'read_record_field',
1464
+ records,
1465
+ };
1466
+ }
1467
+
1468
+ function buildSearchContentLadder(results) {
1469
+ const records = results
1470
+ .map((result) => {
1471
+ const record = buildRecordContentLadder(result, {
1472
+ stream: result.stream,
1473
+ recordId: result.record_key,
1474
+ connectionId: result.connection_id,
1475
+ });
1476
+ if (!record || !Array.isArray(result.match_windows) || result.match_windows.length === 0) return record;
1477
+ const evidenceExcerpts = searchEvidenceExcerpts(result.match_windows, record.id);
1478
+ return {
1479
+ ...record,
1480
+ ...(evidenceExcerpts.length > 0 ? { evidence_excerpts: evidenceExcerpts } : {}),
1481
+ field_windows: mergeSearchMatchWindows(record.field_windows, result.match_windows, record.id),
1482
+ };
1483
+ })
1484
+ .filter(Boolean)
1485
+ .slice(0, CONTENT_LADDER_RECORD_LIMIT);
1486
+ if (records.length === 0) return null;
1487
+ return {
1488
+ kind: 'search_results',
1489
+ read_tool: 'read_record_field',
1490
+ records,
1491
+ };
1492
+ }
1493
+
1494
+ function mergeSearchMatchWindows(existing, matchWindows, recordId) {
1495
+ const rendered = matchWindows.map((window) => {
1496
+ const previewText = searchEvidencePreviewText(window);
1497
+ const read = searchMatchWindowRead(window, recordId, window.field_path);
1498
+ return {
1499
+ field_path: window.field_path,
1500
+ text_like: true,
1501
+ ...(previewText ? { preview_text: previewText } : {}),
1502
+ preview_status: window.complete === true ? 'complete' : 'truncated',
1503
+ size_chars: typeof window.text === 'string' ? window.text.length : undefined,
1504
+ ...(read ? { read } : {}),
1505
+ };
1506
+ });
1507
+ const seen = new Set(rendered.map((window) => window.field_path));
1508
+ return [...rendered, ...(Array.isArray(existing) ? existing.filter((window) => !seen.has(window.field_path)) : [])];
1509
+ }
1510
+
1511
+ function searchEvidenceExcerpts(matchWindows, recordId) {
1512
+ if (!Array.isArray(matchWindows)) return [];
1513
+ return matchWindows
1514
+ .map((window) => {
1515
+ const previewText = searchEvidencePreviewText(window);
1516
+ if (!previewText) return null;
1517
+ const read = searchMatchWindowRead(window, recordId, window.field_path);
1518
+ return {
1519
+ field_path: window.field_path,
1520
+ preview_text: previewText,
1521
+ preview_status: window.complete === true ? 'complete' : 'truncated',
1522
+ ...(read ? { read } : {}),
1523
+ };
1524
+ })
1525
+ .filter(Boolean);
1526
+ }
1527
+
1528
+ function searchEvidencePreviewText(window) {
1529
+ if (typeof window?.preview_text === 'string') return window.preview_text;
1530
+ return typeof window?.text === 'string' ? truncateText(window.text, SEARCH_TEXT_SNIPPET_CHAR_LIMIT) : null;
1531
+ }
1532
+
1533
+ function searchMatchWindowRead(window, recordId, fieldPath) {
1534
+ const read = objectValue(window?.read);
1535
+ if (!read) return null;
1536
+ const args = searchMatchWindowReadArgs(read.args, recordId, fieldPath);
1537
+ return {
1538
+ tool: read.tool ?? 'read_record_field',
1539
+ args,
1540
+ };
1541
+ }
1542
+
1543
+ function searchMatchWindowReadArgs(args, recordId, fieldPath) {
1544
+ const rawArgs = objectValue(args);
1545
+ if (firstString(rawArgs.id, rawArgs.record_uri)) return rawArgs;
1546
+ const connectionId = firstString(rawArgs.connection_id, rawArgs.connector_instance_id);
1547
+ const stream = firstString(rawArgs.stream);
1548
+ const rawRecordId = firstString(rawArgs.record_id, rawArgs.recordId, rawArgs.record_key, rawArgs.recordKey);
1549
+ const id = connectionId && stream && rawRecordId ? selfContainedResultId(`${stream}:${rawRecordId}`, connectionId) : recordId;
1550
+ return {
1551
+ id,
1552
+ field_path: firstString(rawArgs.field_path, rawArgs.fieldPath, rawArgs.path) ?? fieldPath,
1553
+ ...definedObject({
1554
+ q: rawArgs.q,
1555
+ cursor: rawArgs.cursor,
1556
+ offset_chars: rawArgs.offset_chars,
1557
+ limit_chars: rawArgs.limit_chars,
1558
+ before_chars: rawArgs.before_chars,
1559
+ after_chars: rawArgs.after_chars,
1560
+ }),
1561
+ };
1562
+ }
1563
+
1564
+ function definedObject(values) {
1565
+ return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined && value !== null && value !== ''));
1566
+ }
1567
+
1568
+ function hideModelVisibleFieldWindowResources(record) {
1569
+ if (!record || !Array.isArray(record.field_windows)) return record;
1570
+ return {
1571
+ ...record,
1572
+ field_windows: record.field_windows.map(({ resource_uri: _resourceUri, resourceUri: _resourceUriCamel, ...field }) => field),
1573
+ };
1574
+ }
1575
+
1576
+ function buildFetchContentLadder(recordBody, document) {
1577
+ const record = buildRecordContentLadder(recordBody, {
1578
+ id: document.id,
1579
+ connectionId: document.metadata?.connection_id,
1580
+ });
1581
+ if (!record) return null;
1582
+ return {
1583
+ kind: 'record',
1584
+ read_tool: 'read_record_field',
1585
+ ...record,
1586
+ };
1587
+ }
1588
+
1589
+ function buildRecordContentLadder(record, fallback = {}) {
1590
+ const ladder = buildSharedRecordContentLadder(record, {
1591
+ fallback,
1592
+ encodeResourceUri,
1593
+ fieldLimit: CONTENT_LADDER_FIELD_LIMIT,
1594
+ binaryLimit: CONTENT_LADDER_BINARY_FIELD_LIMIT,
1595
+ windowLimitChars: CONTENT_LADDER_WINDOW_LIMIT_CHARS,
1596
+ });
1597
+ return hideModelVisibleFieldWindowResources(ladder);
1598
+ }
1599
+
1027
1600
  function toFetchToolResult(response, providerUrl, requestedId) {
1028
1601
  if (!response.ok) {
1029
1602
  return errorToolResult(response, providerUrl);
1030
1603
  }
1031
1604
  const document = normalizeFetchedDocument(response.body, requestedId, providerUrl);
1605
+ const contentLadder = buildFetchContentLadder(response.body, document);
1032
1606
  const text = JSON.stringify(document);
1033
1607
  return {
1034
1608
  content: [
@@ -1037,7 +1611,10 @@ function toFetchToolResult(response, providerUrl, requestedId) {
1037
1611
  text,
1038
1612
  },
1039
1613
  ],
1040
- structuredContent: document,
1614
+ structuredContent: {
1615
+ ...document,
1616
+ ...(contentLadder ? { content_ladder: contentLadder } : {}),
1617
+ },
1041
1618
  };
1042
1619
  }
1043
1620
 
@@ -1121,7 +1698,7 @@ function summarizeBody(body, label, options = {}) {
1121
1698
  return summarizeStreamsDiscovery(body, label);
1122
1699
  }
1123
1700
  if (options.previewRecords) {
1124
- return summarizeRecordEnvelope(body, label);
1701
+ return summarizeRecordEvidence(body, label);
1125
1702
  }
1126
1703
  if (Array.isArray(body)) {
1127
1704
  return `${label}: ${body.length} item(s). See structuredContent.data for the canonical envelope.`;
@@ -1172,7 +1749,7 @@ function summarizeRecordEnvelope(body, label) {
1172
1749
  truncated = true;
1173
1750
  break;
1174
1751
  }
1175
- const rendered = `${prefix}${truncateText(stableInlineJson(record), budget)}`;
1752
+ const rendered = `${prefix}${truncateText(stableInlineJson(sanitizeRecordForPreview(record)), budget)}`;
1176
1753
  lines.push(rendered);
1177
1754
  used += rendered.length + 1;
1178
1755
  }
@@ -1642,6 +2219,22 @@ function stableInlineJson(value) {
1642
2219
  }
1643
2220
  }
1644
2221
 
2222
+ function sanitizeRecordForPreview(record) {
2223
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return record;
2224
+ const out = {};
2225
+ for (const [key, value] of Object.entries(record)) {
2226
+ const blob = blobRefMetadata(value);
2227
+ if (blob) {
2228
+ out[key] = { object: 'binary_field', preview_status: 'binary-only', ...blob };
2229
+ } else if (isLargeBase64Field(key, value)) {
2230
+ out[key] = { object: 'binary_field', preview_status: 'binary-only', encoding: 'base64', size_chars: value.length };
2231
+ } else {
2232
+ out[key] = value;
2233
+ }
2234
+ }
2235
+ return out;
2236
+ }
2237
+
1645
2238
  function truncateText(value, limit) {
1646
2239
  const safeLimit = Math.max(0, limit);
1647
2240
  if (value.length <= safeLimit) return value;
@@ -1650,7 +2243,7 @@ function truncateText(value, limit) {
1650
2243
  }
1651
2244
 
1652
2245
  const SEARCH_TEXT_PREVIEW_LIMIT = 3;
1653
- const SEARCH_TEXT_SNIPPET_CHAR_LIMIT = 140;
2246
+ const SEARCH_TEXT_SNIPPET_CHAR_LIMIT = 96;
1654
2247
  const SEARCH_RESULT_SNIPPET_CHAR_LIMIT = 320;
1655
2248
  // A truncated id is a dead fetch handle, so the preview id bound must
1656
2249
  // comfortably exceed realistic `{connection_id}/{stream}:{record_id}` handles;
@@ -1665,11 +2258,47 @@ function summarizeSearch(body, results) {
1665
2258
  const sourceMixText = formatSearchSourceMix(body);
1666
2259
  const recallText = formatSearchRecallWarning(body);
1667
2260
  const previews = results.slice(0, SEARCH_TEXT_PREVIEW_LIMIT).map(formatSearchPreviewLine);
1668
- const previewText = previews.length > 0 ? ` Top results:\n${previews.join('\n')}` : '';
2261
+ const matchPreviews = results
2262
+ .slice(0, SEARCH_TEXT_PREVIEW_LIMIT)
2263
+ .flatMap((result, index) => formatSearchMatchWindowLines(result, index));
2264
+ const matchPreviewText =
2265
+ matchPreviews.length > 0 ? `\nEvidence excerpts:\n${matchPreviews.join('\n')}` : '';
2266
+ const previewText = previews.length > 0 ? `\nTop results:\n${previews.join('\n')}` : '';
1669
2267
  const fetchHint = previews.length > 0
1670
2268
  ? '\nFetch a hit with `fetch` using the shown id as-is; ids are self-contained. Pass connection_id only when shown separately.'
1671
2269
  : '';
1672
- return `search: ${results.length} hit(s).${hasMore}${cursorText}${firstFetchText}${sourceMixText}${recallText}${previewText}${fetchHint} Search envelope metadata: structuredContent.data; flattened results: structuredContent.results.`;
2270
+ return `search: ${results.length} hit(s).${hasMore}${cursorText}${firstFetchText}${sourceMixText}${recallText}${matchPreviewText}${previewText}${fetchHint} Search envelope metadata: structuredContent.data; flattened results: structuredContent.results.`;
2271
+ }
2272
+
2273
+ function formatSearchMatchWindowLines(result, index) {
2274
+ const windows = Array.isArray(result.match_windows) ? result.match_windows : [];
2275
+ return windows
2276
+ .slice(0, 2)
2277
+ .filter((window) => typeof window.text === 'string' && window.text.length > 0)
2278
+ .map((window) => {
2279
+ const fieldPath = firstString(window.field_path, window.field?.path);
2280
+ const read = objectValue(window.read);
2281
+ const readArgs = objectValue(read?.args);
2282
+ const complete = window.complete === true ? 'complete=true' : 'complete=false';
2283
+ const cursor = firstString(window.next_cursor) ? ` next_cursor=${formatInlineValue(window.next_cursor)}` : '';
2284
+ const offset = Number.isInteger(readArgs?.offset_chars) ? ` offset_chars=${readArgs.offset_chars}` : '';
2285
+ const limit = Number.isInteger(readArgs?.limit_chars) ? ` limit_chars=${readArgs.limit_chars}` : '';
2286
+ const readHint = read?.tool || readArgs ? ` read=${formatInlineValue(read.tool ?? 'read_record_field')}${offset}${limit}` : '';
2287
+ const visibleId = firstString(readArgs?.id, result.id);
2288
+ const previewText =
2289
+ typeof window.preview_text === 'string' ? window.preview_text : truncateText(window.text, SEARCH_TEXT_SNIPPET_CHAR_LIMIT);
2290
+ return `${index + 1}. result=${index + 1} field_path=${formatFieldName(fieldPath ?? 'unknown')} snippet=${formatScalar(
2291
+ previewText
2292
+ )} id=${formatInlineValue(truncateText(visibleId ?? '', SEARCH_TEXT_ID_CHAR_LIMIT))} ${complete}${cursor}${readHint}`;
2293
+ });
2294
+ }
2295
+
2296
+ function formatSearchReadArgs(args) {
2297
+ if (!args) return '';
2298
+ return Object.entries(args)
2299
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
2300
+ .map(([key, value]) => `${formatFieldName(key)}=${formatInlineValue(truncateText(String(value), 120))}`)
2301
+ .join(',');
1673
2302
  }
1674
2303
 
1675
2304
  // Mirror — never reinterpret — the RS recall disclosure. The warning is driven
@@ -1757,7 +2386,7 @@ function envelopeCount(body) {
1757
2386
  return null;
1758
2387
  }
1759
2388
 
1760
- function normalizeSearchResults(body) {
2389
+ function normalizeSearchResults(body, options = {}) {
1761
2390
  const candidates = searchCandidatesFromBody(body);
1762
2391
  return candidates.map((hit, index) => {
1763
2392
  const source = objectValue(hit?.source) || {};
@@ -1771,6 +2400,8 @@ function normalizeSearchResults(body) {
1771
2400
  const displayName = firstString(hit?.display_name, source.display_name);
1772
2401
  const connectorKey = firstString(hit?.connector_key, hit?.connector_id, source.connector_key, source.connector_id);
1773
2402
  const snippet = snippetForSearchHit(hit);
2403
+ const matchWindows = normalizeSearchMatchWindowReadHints(normalizeSearchMatchWindows(hit, { q: options.q }), id);
2404
+ const evidenceExcerpts = searchEvidenceExcerpts(matchWindows, id);
1774
2405
  const normalized = {
1775
2406
  id,
1776
2407
  title: titleForSearchHit(hit, id, { stream, recordKey, connectionId, displayName, connectorKey }),
@@ -1782,10 +2413,69 @@ function normalizeSearchResults(body) {
1782
2413
  if (displayName) normalized.display_name = displayName;
1783
2414
  if (connectorKey) normalized.connector_key = connectorKey;
1784
2415
  if (snippet) normalized.snippet = truncateText(snippet, SEARCH_RESULT_SNIPPET_CHAR_LIMIT);
2416
+ if (matchWindows.length > 0) normalized.match_windows = matchWindows;
2417
+ if (evidenceExcerpts.length > 0) normalized.evidence_excerpts = evidenceExcerpts;
1785
2418
  return normalized;
1786
2419
  });
1787
2420
  }
1788
2421
 
2422
+ function normalizeSearchMatchWindows(hit, options = {}) {
2423
+ const candidates = [
2424
+ hit?.match_windows,
2425
+ hit?.field_windows,
2426
+ hit?.matches,
2427
+ hit?.data?.match_windows,
2428
+ hit?.data?.field_windows,
2429
+ ].find((value) => Array.isArray(value));
2430
+ if (!Array.isArray(candidates)) return [];
2431
+ return candidates
2432
+ .map((window) => normalizeSearchMatchWindow(window, options))
2433
+ .filter(Boolean)
2434
+ .slice(0, 3);
2435
+ }
2436
+
2437
+ function normalizeSearchMatchWindowReadHints(matchWindows, recordId) {
2438
+ return matchWindows.map((window) => {
2439
+ const read = searchMatchWindowRead(window, recordId, window.field_path);
2440
+ return read ? { ...window, read } : window;
2441
+ });
2442
+ }
2443
+
2444
+ function normalizeSearchMatchWindow(window, options = {}) {
2445
+ const value = objectValue(window);
2446
+ if (!value) return null;
2447
+ const text = firstString(value.text, value.preview, value.snippet, value.window?.text);
2448
+ const fieldPath = firstString(value.field_path, value.fieldPath, value.path, value.field?.path);
2449
+ if (!text || !fieldPath) return null;
2450
+ const read = objectValue(value.read);
2451
+ const normalizedText = centeredSearchText(text, options.q, SEARCH_RESULT_SNIPPET_CHAR_LIMIT);
2452
+ const previewText = centeredSearchText(text, options.q, SEARCH_TEXT_SNIPPET_CHAR_LIMIT);
2453
+ return {
2454
+ field_path: fieldPath,
2455
+ text: normalizedText,
2456
+ preview_text: previewText,
2457
+ complete: value.complete === true,
2458
+ ...(firstString(value.next_cursor, value.window?.next_cursor) ? { next_cursor: firstString(value.next_cursor, value.window?.next_cursor) } : {}),
2459
+ ...(read ? { read } : {}),
2460
+ };
2461
+ }
2462
+
2463
+ function centeredSearchText(text, query, limit) {
2464
+ if (typeof text !== 'string' || text.length <= limit) return text;
2465
+ const q = typeof query === 'string' ? query.trim() : '';
2466
+ if (!q) return truncateText(text, limit);
2467
+ const index = text.toLowerCase().indexOf(q.toLowerCase());
2468
+ if (index < 0) return truncateText(text, limit);
2469
+
2470
+ const available = Math.max(q.length, limit - 6);
2471
+ let start = Math.max(0, index - Math.floor((available - q.length) / 2));
2472
+ let end = Math.min(text.length, start + available);
2473
+ start = Math.max(0, end - available);
2474
+ const prefix = start > 0 ? '...' : '';
2475
+ const suffix = end < text.length ? '...' : '';
2476
+ return `${prefix}${text.slice(start, end)}${suffix}`;
2477
+ }
2478
+
1789
2479
  function searchCandidatesFromBody(body) {
1790
2480
  if (!body || typeof body !== 'object') return [];
1791
2481
  if (Array.isArray(body.results)) return body.results;
@@ -1894,6 +2584,9 @@ function parseRecordResultId(id) {
1894
2584
  if (typeof id !== 'string' || id.length === 0) {
1895
2585
  throw new Error('id is required');
1896
2586
  }
2587
+ if (id.startsWith('pdpp://record/')) {
2588
+ return parseRecordResultId(decodeURIComponent(id.slice('pdpp://record/'.length)));
2589
+ }
1897
2590
  const connectionSeparator = id.indexOf('/');
1898
2591
  if (connectionSeparator === -1) {
1899
2592
  return { ...parseStreamRecordId(id), connectionId: null };