@cueai/omni-reader-mcp 1.2.1 → 1.3.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
@@ -60,7 +60,7 @@ differ from the numbers above, report the live values.
60
60
  Always use an audited exact version, never an implicit `latest`:
61
61
 
62
62
  ```sh
63
- npx -y @cueai/omni-reader-mcp@1.2.1 setup
63
+ npx -y @cueai/omni-reader-mcp@1.3.0 setup
64
64
  ```
65
65
 
66
66
  The interactive setup supports Hermes, Cursor, Claude Desktop, and generic stdio
@@ -68,9 +68,9 @@ configuration. Non-interactive installation uses the same argument parsing and w
68
68
  logic:
69
69
 
70
70
  ```sh
71
- npx -y @cueai/omni-reader-mcp@1.2.1 setup --client hermes --allowed-root /absolute/minimum/root --yes --json
72
- npx -y @cueai/omni-reader-mcp@1.2.1 setup --client cursor --add-root /absolute/minimum/root --yes --json
73
- npx -y @cueai/omni-reader-mcp@1.2.1 setup --client claude-desktop --allowed-root /absolute/minimum/root --yes --json
71
+ npx -y @cueai/omni-reader-mcp@1.3.0 setup --client hermes --allowed-root /absolute/minimum/root --yes --json
72
+ npx -y @cueai/omni-reader-mcp@1.3.0 setup --client cursor --add-root /absolute/minimum/root --yes --json
73
+ npx -y @cueai/omni-reader-mcp@1.3.0 setup --client claude-desktop --allowed-root /absolute/minimum/root --yes --json
74
74
  ```
75
75
 
76
76
  When an agent or script runs under a pty (stdin is still a TTY), declare non-interactive
@@ -78,7 +78,7 @@ mode explicitly with `--headless` (alias `--non-interactive`): no `--yes` is req
78
78
  stdin is never read:
79
79
 
80
80
  ```sh
81
- npx -y @cueai/omni-reader-mcp@1.2.1 setup --client cursor --allowed-root /absolute/minimum/root --headless --json
81
+ npx -y @cueai/omni-reader-mcp@1.3.0 setup --client cursor --allowed-root /absolute/minimum/root --headless --json
82
82
  ```
83
83
 
84
84
  ## Cache and journal isolation
@@ -127,6 +127,7 @@ The public tools are fixed:
127
127
  - `get_parse_status`
128
128
  - `cancel_parse`
129
129
  - `read_result`
130
+ - `read_outline`
130
131
  - `discard_result`
131
132
 
132
133
  Every tool returns `structuredContent` with a strict `outputSchema`, plus an equivalent
@@ -138,6 +139,12 @@ fallback for clients that only read legacy MCP `content[].text`:
138
139
  compact JSON equivalent to the `structuredContent` fields;
139
140
  - the `read_result` text JSON contains the current `result.text` and an optional
140
141
  `next_cursor`; clients must exhaust all cursors before concatenating the body;
142
+ - `read_outline(result_id)` returns the result's heading tree (from Markdown ATX headings
143
+ or, for a PDF source, its font-size-calibrated heading structure) without returning the
144
+ full body to the caller; `read_outline(result_id, node_id)` mints a `read_result`-compatible cursor
145
+ anchored at that heading, so a long result can be jumped into directly instead of only
146
+ advancing sequentially through `next_cursor`. An empty or absent outline is reported
147
+ explicitly, never silently — it never blocks reading the result itself with `read_result`;
141
148
  - the `discard_result` text JSON explicitly returns `discarded`; never claim deletion on
142
149
  call success alone.
143
150
 
@@ -198,16 +205,16 @@ new source that satisfies the constraints.
198
205
  ## Commands
199
206
 
200
207
  ```sh
201
- npx -y @cueai/omni-reader-mcp@1.2.1 doctor
202
- npx -y @cueai/omni-reader-mcp@1.2.1 doctor --json
203
- npx -y @cueai/omni-reader-mcp@1.2.1 clean
204
- npx -y @cueai/omni-reader-mcp@1.2.1 uninstall --yes --json
208
+ npx -y @cueai/omni-reader-mcp@1.3.0 doctor
209
+ npx -y @cueai/omni-reader-mcp@1.3.0 doctor --json
210
+ npx -y @cueai/omni-reader-mcp@1.3.0 clean
211
+ npx -y @cueai/omni-reader-mcp@1.3.0 uninstall --yes --json
205
212
  ```
206
213
 
207
214
  Running the pinned version without a command starts the stdio MCP server:
208
215
 
209
216
  ```sh
210
- npx -y @cueai/omni-reader-mcp@1.2.1
217
+ npx -y @cueai/omni-reader-mcp@1.3.0
211
218
  ```
212
219
 
213
220
  `doctor --json` returns package/npm/client adapter, Key present/absent, allowed-root
@@ -216,12 +223,12 @@ status; it never prints the Key, private source paths, or content.
216
223
 
217
224
  ## Uninstall and rollback
218
225
 
219
- `uninstall --yes --json` removes only the currently trusted 1.2.1 Bridge entry; when a
226
+ `uninstall --yes --json` removes only a trusted 1.2.2 or 1.3.0 Bridge entry; when a
220
227
  matching trusted backup exists, it restores the original URL-only `omni-reader` entry.
221
228
  Uninstall never deletes user source files and never silently removes unexpired local
222
229
  results.
223
230
 
224
- To roll back from 1.1.1:
231
+ To roll back from 1.3.0:
225
232
 
226
233
  1. stop recommending or installing that version;
227
234
  2. run `uninstall --yes --json` to restore the trusted URL-only entry;
@@ -1,5 +1,6 @@
1
1
  import type { FileHandle } from "node:fs/promises";
2
2
  import type { ReleasedMetadata, ResultRetentionSink, ResultRetentionStart } from "./iiis-client.js";
3
+ import { type OutlineResult } from "./outline.js";
3
4
  import { GROUNDING_SCHEMA_VERSION, RESULT_BUNDLE_PROTOCOL_VERSION } from "./protocol.js";
4
5
  import { type VerifiedBundle } from "./result-bundle.js";
5
6
  export interface ArtifactStoreOptions {
@@ -109,6 +110,8 @@ export declare class ArtifactStore {
109
110
  get rootDirectory(): string;
110
111
  createRetention(): LocalResultRetention;
111
112
  read(resultId: string, cursor?: string, maxBytes?: number): Promise<ArtifactReadResult>;
113
+ readOutline(resultId: string): Promise<OutlineResult>;
114
+ mintOutlineCursor(resultId: string, byteOffset: number): Promise<string>;
112
115
  readBundlePart(resultId: string, cursor: string, maxBytes?: number): Promise<BundlePartReadChunk>;
113
116
  discard(resultId: string): Promise<boolean>;
114
117
  cleanupExpired(): Promise<number>;
@@ -7,6 +7,7 @@ import { z } from "zod";
7
7
  import { ARTIFACT_TTL_MS, INLINE_RESULT_MAX_BYTES, RESULT_CHUNK_MAX_BYTES } from "./constants.js";
8
8
  import { CursorCodec } from "./cursor.js";
9
9
  import { OmniBridgeError } from "./errors.js";
10
+ import { extractOutline } from "./outline.js";
10
11
  import { GROUNDING_SCHEMA_VERSION, RESULT_BUNDLE_PROTOCOL_VERSION } from "./protocol.js";
11
12
  // The D2-A source scan (test/protocol.test.ts) rejects any import specifier
12
13
  // mentioning the bundle module: it was written when nothing imported it. D2-D
@@ -535,6 +536,41 @@ export class ArtifactStore {
535
536
  : {}),
536
537
  };
537
538
  }
539
+ async readOutline(resultId) {
540
+ this.#requireOpen();
541
+ const metadata = await this.#loadMetadata(resultId);
542
+ if (Date.parse(metadata.expiresAt) <= this.#now().getTime()) {
543
+ await this.discard(resultId);
544
+ throw artifactError("RESULT_EXPIRED", "The local result artifact has expired.");
545
+ }
546
+ const artifactPath = path.join(this.#resultsDirectory, metadata.artifactName);
547
+ const handle = await openArtifactForRead(artifactPath);
548
+ let fullText;
549
+ try {
550
+ const artifactStat = await handle.stat();
551
+ if (!artifactStat.isFile() || artifactStat.size !== metadata.resultBytes) {
552
+ throw artifactError("LOCAL_RESULT_INTEGRITY_FAILED", "The local result artifact is invalid.");
553
+ }
554
+ const slice = await readUtf8Slice(handle, 0, metadata.resultBytes, metadata.resultBytes);
555
+ fullText = slice.text;
556
+ }
557
+ finally {
558
+ await handle.close();
559
+ }
560
+ return extractOutline(fullText);
561
+ }
562
+ async mintOutlineCursor(resultId, byteOffset) {
563
+ this.#requireOpen();
564
+ const metadata = await this.#loadMetadata(resultId);
565
+ if (Date.parse(metadata.expiresAt) <= this.#now().getTime()) {
566
+ await this.discard(resultId);
567
+ throw artifactError("RESULT_EXPIRED", "The local result artifact has expired.");
568
+ }
569
+ if (!Number.isSafeInteger(byteOffset) || byteOffset < 0 || byteOffset > metadata.resultBytes) {
570
+ throw artifactError("INVALID_RESULT_CURSOR", "The outline node offset is invalid.");
571
+ }
572
+ return this.#cursor.encode({ resultId, offset: byteOffset, expiresAt: metadata.expiresAt });
573
+ }
538
574
  // Read one UTF-8-safe chunk of one named part of a retained bundle. The
539
575
  // opaque v2 cursor binds resultId/part/detail/schema/bundle protocol/
540
576
  // offset/expiry; every bound field is re-verified against the closed
@@ -7,6 +7,10 @@ export interface AgentConfigEnvironment {
7
7
  readonly env?: Readonly<Record<string, string | undefined>>;
8
8
  }
9
9
  export type AgentConfigStatus = "configured" | "not configured" | "invalid or unreadable";
10
+ export interface AgentConfigInspection {
11
+ readonly status: AgentConfigStatus;
12
+ readonly version?: string;
13
+ }
10
14
  interface JsonPreviousEntry {
11
15
  readonly format: "json";
12
16
  readonly value: unknown;
@@ -40,6 +44,7 @@ export declare function buildAgentEntry(target: AgentTarget, extraRoots: readonl
40
44
  export declare function prepareAgentConfig(target: AgentTarget, extraRoots: readonly string[], environment: AgentConfigEnvironment): Promise<PreparedAgentConfig>;
41
45
  export declare function verifyPreparedAgentConfig(prepared: PreparedAgentConfig): Promise<void>;
42
46
  export declare function writePreparedAgentConfig(prepared: PreparedAgentConfig): Promise<void>;
47
+ export declare function inspectAgentConfigDetails(configPath: string, environment?: AgentConfigEnvironment, target?: NativeAgentTarget): Promise<AgentConfigInspection>;
43
48
  export declare function inspectAgentConfig(configPath: string, environment?: AgentConfigEnvironment, target?: NativeAgentTarget): Promise<AgentConfigStatus>;
44
49
  export declare function detectAgentTargets(environment: AgentConfigEnvironment): Promise<NativeAgentTarget[]>;
45
50
  export declare function configContainsOmni(configPath: string): Promise<boolean>;
@@ -4,7 +4,9 @@ import { chmod, lstat, mkdir, open, realpath, rename, unlink, } from "node:fs/pr
4
4
  import path from "node:path";
5
5
  import { BRIDGE_RELEASE_VERSION, REMOTE_OMNI_MCP_URL, } from "../constants.js";
6
6
  const PACKAGE_SPEC = `@cueai/omni-reader-mcp@${BRIDGE_RELEASE_VERSION}`;
7
+ const PREVIOUS_PACKAGE_SPEC = "@cueai/omni-reader-mcp@1.2.2";
7
8
  const LEGACY_PACKAGE_SPEC = "@cueai/omni-reader-mcp";
9
+ const TRUSTED_EXACT_PACKAGE_SPECS = new Set([PREVIOUS_PACKAGE_SPEC, PACKAGE_SPEC]);
8
10
  function isRecord(value) {
9
11
  return value !== null && typeof value === "object" && !Array.isArray(value);
10
12
  }
@@ -543,7 +545,8 @@ function isExpectedOmniEntry(target, value) {
543
545
  !Array.isArray(args) ||
544
546
  args.length !== 2 ||
545
547
  args[0] !== "-y" ||
546
- args[1] !== PACKAGE_SPEC)
548
+ typeof args[1] !== "string" ||
549
+ !TRUSTED_EXACT_PACKAGE_SPECS.has(args[1]))
547
550
  return false;
548
551
  const keys = Object.keys(value).sort();
549
552
  if (value.env === undefined) {
@@ -579,6 +582,12 @@ function isExpectedOmniEntry(target, value) {
579
582
  && keys[1] === "command"
580
583
  && keys[2] === "env";
581
584
  }
585
+ function expectedBridgeVersion(target, value) {
586
+ if (!isExpectedOmniEntry(target, value))
587
+ return undefined;
588
+ const packageSpec = value.args[1];
589
+ return packageSpec === PREVIOUS_PACKAGE_SPEC ? "1.2.2" : BRIDGE_RELEASE_VERSION;
590
+ }
582
591
  function isLegacyBridgeEntry(target, value) {
583
592
  if (!isRecord(value) || value.command !== "npx")
584
593
  return false;
@@ -586,7 +595,8 @@ function isLegacyBridgeEntry(target, value) {
586
595
  if (!Array.isArray(args) ||
587
596
  args.length !== 2 ||
588
597
  args[0] !== "-y" ||
589
- (args[1] !== LEGACY_PACKAGE_SPEC && args[1] !== PACKAGE_SPEC))
598
+ typeof args[1] !== "string" ||
599
+ (args[1] !== LEGACY_PACKAGE_SPEC && !TRUSTED_EXACT_PACKAGE_SPECS.has(args[1])))
590
600
  return false;
591
601
  if (value.env === undefined)
592
602
  return target !== "hermes";
@@ -742,6 +752,7 @@ export async function prepareAgentConfig(target, extraRoots, environment) {
742
752
  environment,
743
753
  reload: reloadInstruction("hermes"),
744
754
  format,
755
+ serializedBefore: loaded.serialized,
745
756
  serializedAfter,
746
757
  previousEntry,
747
758
  };
@@ -786,9 +797,11 @@ async function atomicReplace(filePath, content) {
786
797
  await handle.sync();
787
798
  await handle.close();
788
799
  handle = undefined;
800
+ await chmod(temporaryPath, 0o600);
789
801
  await rename(temporaryPath, filePath);
790
- await chmod(filePath, 0o600);
791
- await syncDirectory(directory);
802
+ // Rename is the commit point. Do not throw afterward: callers cannot safely
803
+ // roll back a committed user-config write without racing an external editor.
804
+ await syncDirectory(directory).catch(() => undefined);
792
805
  }
793
806
  finally {
794
807
  await handle?.close();
@@ -912,6 +925,17 @@ export async function verifyPreparedAgentConfig(prepared) {
912
925
  throw fileError("The Agent configuration changed after preview; no changes were written.");
913
926
  }
914
927
  }
928
+ async function restoreTrustedBackup(backupPath, backup) {
929
+ if (backup !== undefined) {
930
+ await atomicReplace(backupPath, `${JSON.stringify(backup, null, 2)}\n`);
931
+ return;
932
+ }
933
+ await unlink(backupPath).catch((error) => {
934
+ if (error.code !== "ENOENT")
935
+ throw error;
936
+ });
937
+ await syncDirectory(path.dirname(backupPath));
938
+ }
915
939
  export async function writePreparedAgentConfig(prepared) {
916
940
  if (prepared.configPath === undefined ||
917
941
  prepared.environment === undefined ||
@@ -919,36 +943,56 @@ export async function writePreparedAgentConfig(prepared) {
919
943
  prepared.serializedAfter === undefined)
920
944
  return;
921
945
  await withConfigLock(prepared.configPath, prepared.environment, async () => {
922
- const current = await readConfig(prepared.configPath);
946
+ const configPath = prepared.configPath;
947
+ const current = await readConfig(configPath);
923
948
  if (current.fingerprint !== prepared.sourceFingerprint) {
924
949
  throw fileError("The Agent configuration changed after preview; no changes were written.");
925
950
  }
926
951
  const backup = trustedBackup(prepared);
927
- const backupPath = agentBackupPath(prepared.configPath);
928
- const existingBackup = await readTrustedBackup(prepared.configPath);
929
- if (existingBackup === undefined) {
930
- await atomicReplace(backupPath, `${JSON.stringify(backup, null, 2)}\n`);
931
- }
932
- else {
933
- const currentEntryMatches = await configHasExpectedEntry(prepared.target, current);
934
- if (currentEntryMatches && existingBackup.target === prepared.target) {
935
- const updatedBackup = {
952
+ const backupPath = agentBackupPath(configPath);
953
+ const existingBackup = await readTrustedBackup(configPath);
954
+ let replacementBackup = backup;
955
+ if (existingBackup !== undefined) {
956
+ const currentEntry = expectedConfigEntry(prepared.target, current);
957
+ if (currentEntry !== undefined && existingBackup.target === prepared.target) {
958
+ if (existingBackup.bridge_entry_digest !== entryDigest(currentEntry)) {
959
+ throw fileError("A conflicting trusted Omni backup already exists; no changes were written.");
960
+ }
961
+ replacementBackup = {
936
962
  ...existingBackup,
937
963
  bridge_entry_digest: entryDigest(prepared.entry),
938
964
  };
939
- if (JSON.stringify(updatedBackup) !== JSON.stringify(existingBackup)) {
940
- await atomicReplace(backupPath, `${JSON.stringify(updatedBackup, null, 2)}\n`);
941
- }
942
965
  }
943
966
  else if (JSON.stringify(existingBackup) !== JSON.stringify(backup)) {
944
967
  throw fileError("A conflicting trusted Omni backup already exists; no changes were written.");
945
968
  }
969
+ else {
970
+ replacementBackup = existingBackup;
971
+ }
946
972
  }
947
- const immediatelyBeforeWrite = await readConfig(prepared.configPath);
948
- if (immediatelyBeforeWrite.fingerprint !== current.fingerprint) {
949
- throw fileError("The Agent configuration changed during setup; no changes were written.");
973
+ const backupNeedsWrite = existingBackup === undefined ||
974
+ JSON.stringify(replacementBackup) !== JSON.stringify(existingBackup);
975
+ try {
976
+ if (backupNeedsWrite) {
977
+ await atomicReplace(backupPath, `${JSON.stringify(replacementBackup, null, 2)}\n`);
978
+ }
979
+ const immediatelyBeforeWrite = await readConfig(configPath);
980
+ if (immediatelyBeforeWrite.fingerprint !== current.fingerprint) {
981
+ throw fileError("The Agent configuration changed during setup; no changes were written.");
982
+ }
983
+ await atomicReplace(configPath, prepared.serializedAfter);
984
+ }
985
+ catch (error) {
986
+ try {
987
+ if (backupNeedsWrite) {
988
+ await restoreTrustedBackup(backupPath, existingBackup);
989
+ }
990
+ }
991
+ catch {
992
+ throw fileError("The Agent configuration write failed and trusted rollback state could not be restored.");
993
+ }
994
+ throw error;
950
995
  }
951
- await atomicReplace(prepared.configPath, prepared.serializedAfter);
952
996
  });
953
997
  }
954
998
  function jsonConfigEntry(value) {
@@ -956,49 +1000,64 @@ function jsonConfigEntry(value) {
956
1000
  return undefined;
957
1001
  return onlyOmniEntry(value.mcpServers).value;
958
1002
  }
959
- async function configHasExpectedEntry(target, loaded) {
1003
+ function expectedConfigEntry(target, loaded) {
960
1004
  if (target === "hermes") {
961
1005
  const document = parseHermesDocument(loaded.serialized);
962
1006
  const entry = hermesOmniEntry(document);
963
- return entry !== undefined && isExpectedOmniEntry(target, parseHermesEntry(entry));
1007
+ if (entry === undefined)
1008
+ return undefined;
1009
+ const value = parseHermesEntry(entry);
1010
+ return isExpectedOmniEntry(target, value) ? value : undefined;
964
1011
  }
965
1012
  assertNoDuplicateJsonOmniEntries(loaded.serialized);
966
- const value = parseJsonConfig(loaded);
967
- return isExpectedOmniEntry(target, jsonConfigEntry(value));
1013
+ const value = jsonConfigEntry(parseJsonConfig(loaded));
1014
+ return isExpectedOmniEntry(target, value) ? value : undefined;
968
1015
  }
969
- export async function inspectAgentConfig(configPath, environment, target) {
1016
+ async function configHasExpectedEntry(target, loaded) {
1017
+ return expectedConfigEntry(target, loaded) !== undefined;
1018
+ }
1019
+ export async function inspectAgentConfigDetails(configPath, environment, target) {
970
1020
  try {
971
1021
  if (environment !== undefined)
972
1022
  await validateUserConfigPath(configPath, environment);
973
1023
  const loaded = await readConfig(configPath);
974
1024
  if (!loaded.existed)
975
- return "not configured";
1025
+ return { status: "not configured" };
976
1026
  const resolvedTarget = target ?? (configPath.endsWith("config.yaml") ? "hermes" : "cursor");
977
1027
  if (resolvedTarget === "hermes") {
978
1028
  const document = parseHermesDocument(loaded.serialized);
979
1029
  const entry = hermesOmniEntry(document);
980
1030
  if (entry === undefined)
981
- return "not configured";
1031
+ return { status: "not configured" };
982
1032
  const value = parseHermesEntry(entry);
983
- if (isExpectedOmniEntry("hermes", value))
984
- return "configured";
985
- return isCanonicalRemoteEntry(value) ? "not configured" : "invalid or unreadable";
1033
+ const version = expectedBridgeVersion("hermes", value);
1034
+ if (version !== undefined)
1035
+ return { status: "configured", version };
1036
+ return {
1037
+ status: isCanonicalRemoteEntry(value) ? "not configured" : "invalid or unreadable",
1038
+ };
986
1039
  }
987
1040
  assertNoDuplicateJsonOmniEntries(loaded.serialized);
988
1041
  const value = parseJsonConfig(loaded);
989
1042
  if (!isRecord(value.mcpServers))
990
- return "not configured";
1043
+ return { status: "not configured" };
991
1044
  const entry = onlyOmniEntry(value.mcpServers).value;
992
1045
  if (entry === undefined)
993
- return "not configured";
994
- if (isExpectedOmniEntry(resolvedTarget, entry))
995
- return "configured";
996
- return isCanonicalRemoteEntry(entry) ? "not configured" : "invalid or unreadable";
1046
+ return { status: "not configured" };
1047
+ const version = expectedBridgeVersion(resolvedTarget, entry);
1048
+ if (version !== undefined)
1049
+ return { status: "configured", version };
1050
+ return {
1051
+ status: isCanonicalRemoteEntry(entry) ? "not configured" : "invalid or unreadable",
1052
+ };
997
1053
  }
998
1054
  catch {
999
- return "invalid or unreadable";
1055
+ return { status: "invalid or unreadable" };
1000
1056
  }
1001
1057
  }
1058
+ export async function inspectAgentConfig(configPath, environment, target) {
1059
+ return (await inspectAgentConfigDetails(configPath, environment, target)).status;
1060
+ }
1002
1061
  export async function detectAgentTargets(environment) {
1003
1062
  const detected = [];
1004
1063
  for (const target of ["hermes", "cursor", "claude-desktop"]) {
@@ -1056,6 +1115,15 @@ export async function rollbackPreparedAgentConfig(prepared) {
1056
1115
  if (!current.existed || current.serialized !== prepared.serializedAfter) {
1057
1116
  throw fileError("The Agent configuration changed before rollback; automatic rollback stopped.");
1058
1117
  }
1118
+ const backupPath = agentBackupPath(prepared.configPath);
1119
+ const backup = await readTrustedBackup(prepared.configPath);
1120
+ const predecessor = prepared.existed
1121
+ ? expectedConfigEntry(prepared.target, {
1122
+ existed: true,
1123
+ serialized: prepared.serializedBefore,
1124
+ fingerprint: prepared.sourceFingerprint ?? "unused",
1125
+ })
1126
+ : undefined;
1059
1127
  if (prepared.existed) {
1060
1128
  await atomicReplace(prepared.configPath, prepared.serializedBefore);
1061
1129
  }
@@ -1063,10 +1131,22 @@ export async function rollbackPreparedAgentConfig(prepared) {
1063
1131
  await unlink(prepared.configPath);
1064
1132
  await syncDirectory(path.dirname(prepared.configPath));
1065
1133
  }
1066
- await unlink(agentBackupPath(prepared.configPath)).catch((error) => {
1067
- if (error.code !== "ENOENT")
1068
- throw error;
1069
- });
1134
+ if (backup !== undefined &&
1135
+ predecessor !== undefined &&
1136
+ backup.target === prepared.target &&
1137
+ backup.source_fingerprint !== prepared.sourceFingerprint) {
1138
+ const restoredBackup = {
1139
+ ...backup,
1140
+ bridge_entry_digest: entryDigest(predecessor),
1141
+ };
1142
+ await atomicReplace(backupPath, `${JSON.stringify(restoredBackup, null, 2)}\n`);
1143
+ }
1144
+ else {
1145
+ await unlink(backupPath).catch((error) => {
1146
+ if (error.code !== "ENOENT")
1147
+ throw error;
1148
+ });
1149
+ }
1070
1150
  await syncDirectory(path.dirname(prepared.configPath));
1071
1151
  });
1072
1152
  }
@@ -1,9 +1,9 @@
1
1
  import { constants as fsConstants } from "node:fs";
2
2
  import { lstat, open, readdir } from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { BRIDGE_RELEASE_VERSION, DEFAULT_CUBE_BASE_URL, GRANTED_STREAM_PROTOCOL_VERSION, MAX_FILE_BYTES, CUBE_GRANT_PROTOCOL_VERSION, } from "../constants.js";
4
+ import { DEFAULT_CUBE_BASE_URL, GRANTED_STREAM_PROTOCOL_VERSION, MAX_FILE_BYTES, CUBE_GRANT_PROTOCOL_VERSION, } from "../constants.js";
5
5
  import { API_KEY_URL, getOnboardingPolicyWithTimeout, onboardingGuidance, } from "../onboarding-policy.js";
6
- import { agentConfigPath, inspectAgentConfig, } from "./agent-config.js";
6
+ import { agentConfigPath, inspectAgentConfigDetails, } from "./agent-config.js";
7
7
  const CUBE_HEALTH_PATH = "/api/omni-reader/direct-upload/v1/health";
8
8
  const GRANTED_UPLOAD_HEALTH_URL = "https://cubefile.ai.iiis.co:9443/omni/granted/health";
9
9
  function isHealthBody(value) {
@@ -137,13 +137,9 @@ async function clientAdapterFacts(options) {
137
137
  ["claude_desktop", "claude-desktop"],
138
138
  ]) {
139
139
  const configPath = agentConfigPath(target, options);
140
- const status = configPath === undefined
141
- ? "not configured"
142
- : await inspectAgentConfig(configPath, options, target);
143
- result[label] = {
144
- status,
145
- ...(status === "configured" ? { version: BRIDGE_RELEASE_VERSION } : {}),
146
- };
140
+ result[label] = configPath === undefined
141
+ ? { status: "not configured" }
142
+ : await inspectAgentConfigDetails(configPath, options, target);
147
143
  }
148
144
  return result;
149
145
  }
@@ -6,7 +6,7 @@ export declare const CUBE_GRANT_PROTOCOL_VERSION = "omni.parse_grant.v1";
6
6
  export declare const GRANTED_STREAM_PROTOCOL_VERSION = "omni.granted_parse_stream.v1";
7
7
  export declare const DEFAULT_CUBE_BASE_URL = "https://mcp.cuecue.cn";
8
8
  export declare const DEFAULT_IIIS_GRANTED_BASE_URL = "https://cubefile.ai.iiis.co:9443/omni/granted/";
9
- export declare const BRIDGE_RELEASE_VERSION = "1.2.1";
9
+ export declare const BRIDGE_RELEASE_VERSION = "1.3.0";
10
10
  export declare const REMOTE_OMNI_MCP_URL = "https://mcp.cuecue.cn/api/omni-reader/mcp/";
11
11
  export declare const FOREGROUND_BUDGET_MS = 15000;
12
12
  export declare const STATUS_LONG_POLL_MAX_MS = 20000;
package/dist/constants.js CHANGED
@@ -6,7 +6,7 @@ export const CUBE_GRANT_PROTOCOL_VERSION = "omni.parse_grant.v1";
6
6
  export const GRANTED_STREAM_PROTOCOL_VERSION = "omni.granted_parse_stream.v1";
7
7
  export const DEFAULT_CUBE_BASE_URL = "https://mcp.cuecue.cn";
8
8
  export const DEFAULT_IIIS_GRANTED_BASE_URL = "https://cubefile.ai.iiis.co:9443/omni/granted/";
9
- export const BRIDGE_RELEASE_VERSION = "1.2.1";
9
+ export const BRIDGE_RELEASE_VERSION = "1.3.0";
10
10
  export const REMOTE_OMNI_MCP_URL = "https://mcp.cuecue.cn/api/omni-reader/mcp/";
11
11
  export const FOREGROUND_BUDGET_MS = 15_000;
12
12
  export const STATUS_LONG_POLL_MAX_MS = 20_000;
@@ -4,7 +4,7 @@ export interface ResultRetentionStart {
4
4
  readonly operationId: string;
5
5
  readonly resultBytes: number;
6
6
  readonly mediaType: string;
7
- readonly source: "sse" | "recovery";
7
+ readonly source: "sse" | "recovery" | "remote_hydration";
8
8
  }
9
9
  export interface ReleasedMetadata extends ResultRetentionStart {
10
10
  readonly resultDigest: string;
@@ -5,7 +5,7 @@ import type { JournalPatch, JournalRecord, JournalState, OperationJournal } from
5
5
  import { type OpenAllowedFileOptions, type OpenedAllowedFile } from "./path-security.js";
6
6
  import { type RepresentationIntent } from "./protocol.js";
7
7
  import type { RemoteOmniClient } from "./remote-client.js";
8
- import type { ParseResult } from "./result-contract.js";
8
+ import { type ParseResult } from "./result-contract.js";
9
9
  export interface SubmitOperationInput {
10
10
  readonly sourceKind: "local" | "url";
11
11
  readonly sourceFacts: Readonly<Record<string, unknown>>;
@@ -55,11 +55,11 @@ export declare class OperationManager {
55
55
  status(operationId: string, waitMs?: number, signal?: AbortSignal): Promise<JournalRecord>;
56
56
  cancel(operationId: string, signal?: AbortSignal): Promise<JournalRecord>;
57
57
  }
58
- interface LocalRetention extends ResultRetentionSink {
58
+ export interface LocalRetention extends ResultRetentionSink {
59
59
  result(): LocalResult;
60
60
  bundleResult?(): BundleLocalResult;
61
61
  }
62
- interface LocalArtifactStore {
62
+ export interface LocalArtifactStore {
63
63
  createRetention(): LocalRetention;
64
64
  read(resultId: string, cursor?: string, maxBytes?: number): Promise<ArtifactReadResult>;
65
65
  readBundleDescriptor?(resultId: string): Promise<BundleLocalResult | null>;
@@ -76,5 +76,6 @@ export interface LocalParseOperationManagerOptions {
76
76
  readonly now?: () => Date;
77
77
  readonly sleep?: (milliseconds: number) => Promise<void>;
78
78
  }
79
+ export declare function hydrateInlineUrlResult(result: ParseResult, artifactStore: Pick<LocalArtifactStore, "createRetention">): Promise<ParseResult>;
80
+ export declare function hydrateInlineUrlResultSafely(result: ParseResult, artifactStore: Pick<LocalArtifactStore, "createRetention">): Promise<ParseResult>;
79
81
  export declare function createLocalParseOperationManager(options: LocalParseOperationManagerOptions): OperationManager;
80
- export {};
@@ -5,6 +5,7 @@ import { LEGACY_RECOVERY_FAILURE_CODE } from "./operation-journal.js";
5
5
  import { openAllowedFile, } from "./path-security.js";
6
6
  import { NOOP_PROGRESS } from "./progress.js";
7
7
  import { normalizeRepresentation } from "./protocol.js";
8
+ import { localResultToResultField } from "./result-contract.js";
8
9
  const TERMINAL_STATES = new Set([
9
10
  "COMPLETED",
10
11
  "FAILED",
@@ -633,6 +634,50 @@ function remoteContext(value) {
633
634
  }
634
635
  return context;
635
636
  }
637
+ // A completed (or cleanup_pending -- also directly caller-visible with usable
638
+ // result content, per the local-file path's own existing handling of that
639
+ // status) URL parse's `kind:"inline"` result already carries its full text --
640
+ // hydrating it into Bridge's own local ArtifactStore is a pure local
641
+ // operation (zero extra network I/O) that makes it a completely ordinary
642
+ // local result afterward, so read_result/read_outline work on it exactly as
643
+ // they do for a local-file parse. `kind:"artifact"`/bundle-shaped results
644
+ // are deliberately left untouched -- see this plan's Non-goals.
645
+ export async function hydrateInlineUrlResult(result, artifactStore) {
646
+ if (result.status !== "completed" && result.status !== "cleanup_pending") {
647
+ return result;
648
+ }
649
+ if (result.result === undefined || result.result.kind !== "inline") {
650
+ return result;
651
+ }
652
+ const bytes = new TextEncoder().encode(result.result.text);
653
+ const start = {
654
+ operationId: result.operation_id,
655
+ resultBytes: bytes.byteLength,
656
+ mediaType: "text/markdown; charset=utf-8",
657
+ source: "remote_hydration",
658
+ };
659
+ const retention = artifactStore.createRetention();
660
+ await retention.begin(start);
661
+ await retention.write(bytes);
662
+ await retention.complete({
663
+ ...start,
664
+ resultDigest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
665
+ });
666
+ return { ...result, result: localResultToResultField(retention.result()) };
667
+ }
668
+ // hydrateInlineUrlResult is a pure local-storage optimization on top of content the caller
669
+ // already has in full -- a local-disk failure here (e.g. disk full, permission error) must never
670
+ // turn an otherwise-successful URL parse into a failure. This wrapper swallows any hydration
671
+ // error and falls back to the original, un-hydrated result: the caller still gets their content;
672
+ // they just lose local read_result/read_outline re-read capability for that particular result.
673
+ export async function hydrateInlineUrlResultSafely(result, artifactStore) {
674
+ try {
675
+ return await hydrateInlineUrlResult(result, artifactStore);
676
+ }
677
+ catch {
678
+ return result;
679
+ }
680
+ }
636
681
  function _userFacingMessage(code, result) {
637
682
  const error = result.error;
638
683
  if (!error.file_uploaded && !error.parser_started) {
@@ -1419,7 +1464,8 @@ export function createLocalParseOperationManager(options) {
1419
1464
  }
1420
1465
  const context = remoteContext(input.context);
1421
1466
  const result = await options.remoteClient.parse(context.source, input.clientRequestId, input.signal ?? new AbortController().signal, representation.detail);
1422
- return remoteResultUpdate(result, recovery?.operationId ?? undefined);
1467
+ const hydrated = await hydrateInlineUrlResultSafely(result, options.artifactStore);
1468
+ return remoteResultUpdate(hydrated, recovery?.operationId ?? undefined);
1423
1469
  }
1424
1470
  const driver = {
1425
1471
  create: (input) => input.sourceKind === "url"
@@ -1434,7 +1480,9 @@ export function createLocalParseOperationManager(options) {
1434
1480
  if (record.sourceKind === "url") {
1435
1481
  if (options.remoteClient === undefined)
1436
1482
  return undefined;
1437
- return remoteResultUpdate(await options.remoteClient.status(record.operationId, waitMs, signal), record.operationId);
1483
+ const result = await options.remoteClient.status(record.operationId, waitMs, signal);
1484
+ const hydrated = await hydrateInlineUrlResultSafely(result, options.artifactStore);
1485
+ return remoteResultUpdate(hydrated, record.operationId);
1438
1486
  }
1439
1487
  const order = STATE_ORDER.get(record.state);
1440
1488
  if (order === undefined || order < STATE_ORDER.get("UPLOADING"))
@@ -1454,7 +1502,9 @@ export function createLocalParseOperationManager(options) {
1454
1502
  if (record.sourceKind === "url") {
1455
1503
  if (options.remoteClient === undefined)
1456
1504
  return undefined;
1457
- return remoteResultUpdate(await options.remoteClient.cancel(record.operationId, signal), record.operationId);
1505
+ const result = await options.remoteClient.cancel(record.operationId, signal);
1506
+ const hydrated = await hydrateInlineUrlResultSafely(result, options.artifactStore);
1507
+ return remoteResultUpdate(hydrated, record.operationId);
1458
1508
  }
1459
1509
  if (record.operationToken === null)
1460
1510
  return undefined;
@@ -1535,7 +1585,9 @@ export function createLocalParseOperationManager(options) {
1535
1585
  return completedLocalParse(await recoverLocalArtifact(record));
1536
1586
  }
1537
1587
  if (record.sourceKind === "url" && options.remoteClient !== undefined) {
1538
- return await options.remoteClient.status(record.operationId, 0, signal);
1588
+ const result = await options.remoteClient.status(record.operationId, 0, signal);
1589
+ const hydrated = await hydrateInlineUrlResultSafely(result, options.artifactStore);
1590
+ return hydrated;
1539
1591
  }
1540
1592
  return undefined;
1541
1593
  },
@@ -0,0 +1,15 @@
1
+ export interface OutlineNode {
2
+ readonly id: string;
3
+ readonly level: number;
4
+ readonly title: string;
5
+ readonly preview: string;
6
+ readonly byteOffset: number;
7
+ }
8
+ export interface OutlineResult {
9
+ readonly coverage: "complete" | "partial" | "none";
10
+ readonly nodes: readonly OutlineNode[];
11
+ }
12
+ export interface ExtractOutlineOptions {
13
+ readonly coverage?: "complete" | "partial";
14
+ }
15
+ export declare function extractOutline(content: string, options?: ExtractOutlineOptions): OutlineResult;
@@ -0,0 +1,79 @@
1
+ // 0-3 leading spaces (CommonMark ATX rule: 4+ leading spaces is a code block, not a heading),
2
+ // 1-6 '#' characters, required whitespace, required non-empty title.
3
+ const ATX_HEADING_PATTERN = /^ {0,3}(#{1,6})\s+(\S.*)$/;
4
+ // Any run of 3+ backticks or tildes is a fence-looking line. Matches CommonMark's exact
5
+ // fence-matching rule: a fence only closes when the closing marker uses the SAME character
6
+ // (backtick vs tilde) as the opening marker, its length is >= the opening marker's length, AND
7
+ // the line -- after trimming BOTH leading and trailing whitespace -- is exactly the marker with
8
+ // nothing else (a closing fence line may still carry the same 0-3 leading spaces and any amount
9
+ // of trailing whitespace that fence lines are always allowed, but may not carry an info string).
10
+ // The character/length/exact-match comparisons are done in plain JS after this regex match (see
11
+ // below); a fence-looking line that does not satisfy all three conditions while already inside a
12
+ // fence is just fence-looking content inside the fence, not a real close. An OPENING fence line
13
+ // has no such restriction -- trailing content there is a legitimate info string (e.g. ```python)
14
+ // and is allowed and ignored.
15
+ const FENCE_PATTERN = /^ {0,3}(`{3,}|~{3,})/;
16
+ const TRAILING_CLOSING_HASHES = /\s+#+\s*$/;
17
+ export function extractOutline(content, options = {}) {
18
+ const lines = content.split("\n");
19
+ const nodes = [];
20
+ let fenceMarker = null;
21
+ let byteOffset = 0;
22
+ let nodeCounter = 0;
23
+ for (let i = 0; i < lines.length; i += 1) {
24
+ const line = lines[i];
25
+ const isLastLine = i === lines.length - 1;
26
+ // split("\n") consumes the newline itself; re-add its one byte for every line except a
27
+ // trailing line with no final newline (matches how `content.split("\n")` behaves).
28
+ const lineByteLength = Buffer.byteLength(line, "utf8") + (isLastLine ? 0 : 1);
29
+ const fenceMatch = FENCE_PATTERN.exec(line);
30
+ if (fenceMatch) {
31
+ const marker = fenceMatch[1];
32
+ const char = marker[0];
33
+ const length = marker.length;
34
+ if (fenceMarker === null) {
35
+ fenceMarker = { char, length };
36
+ }
37
+ else if (char === fenceMarker.char && length >= fenceMarker.length && line.trim() === marker) {
38
+ fenceMarker = null;
39
+ }
40
+ // Otherwise: fence-looking line while already inside a fence that doesn't satisfy the
41
+ // closing condition -- just fence-looking content inside the fence, state stays "in fence".
42
+ byteOffset += lineByteLength;
43
+ continue;
44
+ }
45
+ if (fenceMarker === null) {
46
+ const match = ATX_HEADING_PATTERN.exec(line);
47
+ if (match) {
48
+ const hashes = match[1];
49
+ const rawTitle = match[2].replace(TRAILING_CLOSING_HASHES, "").trim();
50
+ const title = /^#+$/.test(rawTitle) ? "" : rawTitle;
51
+ if (title) {
52
+ nodeCounter += 1;
53
+ nodes.push({
54
+ id: `node_${String(nodeCounter).padStart(6, "0")}`,
55
+ level: hashes.length,
56
+ title,
57
+ preview: firstLinePreview(lines, i + 1),
58
+ byteOffset,
59
+ });
60
+ }
61
+ }
62
+ }
63
+ byteOffset += lineByteLength;
64
+ }
65
+ const coverage = options.coverage ?? (nodes.length > 0 ? "complete" : "none");
66
+ return { coverage, nodes };
67
+ }
68
+ function firstLinePreview(lines, startIndex, maxChars = 120) {
69
+ for (let i = startIndex; i < lines.length; i += 1) {
70
+ const raw = lines[i];
71
+ const trimmed = raw.trim();
72
+ if (!trimmed)
73
+ continue;
74
+ if (ATX_HEADING_PATTERN.test(raw))
75
+ return ""; // next content is another heading -> no preview
76
+ return trimmed.length > maxChars ? `${trimmed.slice(0, maxChars)}…` : trimmed;
77
+ }
78
+ return "";
79
+ }
@@ -51,6 +51,16 @@ export declare const readResultSchema: z.ZodObject<{
51
51
  cursor?: string | undefined;
52
52
  max_bytes?: number | undefined;
53
53
  }>;
54
+ export declare const readOutlineSchema: z.ZodObject<{
55
+ result_id: z.ZodString;
56
+ node_id: z.ZodOptional<z.ZodString>;
57
+ }, "strict", z.ZodTypeAny, {
58
+ result_id: string;
59
+ node_id?: string | undefined;
60
+ }, {
61
+ result_id: string;
62
+ node_id?: string | undefined;
63
+ }>;
54
64
  export declare const discardResultSchema: z.ZodObject<{
55
65
  result_id: z.ZodString;
56
66
  }, "strict", z.ZodTypeAny, {
@@ -63,3 +73,4 @@ export type GetParseStatusArguments = z.infer<typeof getParseStatusSchema>;
63
73
  export type CancelParseArguments = z.infer<typeof cancelParseSchema>;
64
74
  export type ReadResultArguments = z.infer<typeof readResultSchema>;
65
75
  export type DiscardResultArguments = z.infer<typeof discardResultSchema>;
76
+ export type ReadOutlineArguments = z.infer<typeof readOutlineSchema>;
package/dist/protocol.js CHANGED
@@ -64,10 +64,25 @@ export const cancelParseSchema = z
64
64
  export const readResultSchema = z
65
65
  .object({
66
66
  result_id: resultIdSchema,
67
- cursor: z.string().min(1).max(2048).optional(),
67
+ cursor: z
68
+ .string()
69
+ .min(1)
70
+ .max(2048)
71
+ .describe("Pass the previous result's next_cursor as this cursor.")
72
+ .optional(),
68
73
  max_bytes: z.number().int().min(1).max(RESULT_CHUNK_MAX_BYTES).optional(),
69
74
  })
70
75
  .strict();
76
+ export const readOutlineSchema = z
77
+ .object({
78
+ result_id: resultIdSchema,
79
+ node_id: z
80
+ .string()
81
+ .regex(/^node_[0-9]{6}$/u)
82
+ .describe("From a prior read_outline call's nodes[].id. Mints a read_result-compatible cursor for that section.")
83
+ .optional(),
84
+ })
85
+ .strict();
71
86
  export const discardResultSchema = z
72
87
  .object({
73
88
  result_id: resultIdSchema,
@@ -186,8 +186,29 @@ function decodeEnvelope(value, requestId) {
186
186
  throw protocolError();
187
187
  }
188
188
  const result = envelope.result;
189
+ let candidate = result.structuredContent;
190
+ if (candidate === undefined) {
191
+ const content = result.content;
192
+ if (!Array.isArray(content) || content.length !== 1) {
193
+ throw protocolError();
194
+ }
195
+ const item = content[0];
196
+ if (item === null
197
+ || typeof item !== "object"
198
+ || Array.isArray(item)
199
+ || item.type !== "text"
200
+ || typeof item.text !== "string") {
201
+ throw protocolError();
202
+ }
203
+ try {
204
+ candidate = JSON.parse(item.text);
205
+ }
206
+ catch {
207
+ throw protocolError();
208
+ }
209
+ }
189
210
  try {
190
- return parseResultSchema.parse(result.structuredContent);
211
+ return parseResultSchema.parse(candidate);
191
212
  }
192
213
  catch {
193
214
  throw protocolError();
@@ -302,7 +323,10 @@ export class HttpRemoteOmniClient {
302
323
  retryable: false,
303
324
  });
304
325
  }
305
- let args = { source: classified.source };
326
+ let args = {
327
+ source: classified.source,
328
+ wait: false,
329
+ };
306
330
  if (detail === "grounded" || detail === "layout") {
307
331
  // D2-D Task 14: the URL profile is obtained and selected BEFORE any
308
332
  // non-text tools/call is constructed. Detail is sent only for a valid
@@ -310,7 +334,7 @@ export class HttpRemoteOmniClient {
310
334
  // UNSUPPORTED_DETAIL and zero tools/call requests.
311
335
  const capabilities = await this.initializeCapabilities(signal);
312
336
  selectUrlProfile(capabilities, detail);
313
- args = { source: classified.source, detail };
337
+ args = { source: classified.source, detail, wait: false };
314
338
  }
315
339
  return this.#call("parse", args, clientRequestId, signal);
316
340
  }
@@ -1,6 +1,7 @@
1
1
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
2
  import { z } from "zod";
3
- import type { OmniBridgeErrorPayload } from "./errors.js";
3
+ import type { LocalResult } from "./artifact-store.js";
4
+ import { type OmniBridgeErrorPayload } from "./errors.js";
4
5
  export interface DataHandling {
5
6
  processing_copy: "in_use" | "pending" | "deleted";
6
7
  temporary_data: "in_use" | "pending" | "deleted";
@@ -70,11 +71,30 @@ export type ParseResult = {
70
71
  status: "canceled";
71
72
  operation_id: string;
72
73
  cleanup_deadline?: string;
74
+ billing?: {
75
+ credits_charged: number;
76
+ credits_remaining: number;
77
+ };
73
78
  data_handling: DataHandling;
74
79
  } | {
75
80
  status: "expired";
76
81
  operation_id: string;
77
82
  requires_user_confirmation: true;
83
+ billing?: {
84
+ credits_charged: number;
85
+ credits_remaining: number;
86
+ };
87
+ };
88
+ export declare function localResultToResultField(local: LocalResult): {
89
+ kind: "inline";
90
+ text: string;
91
+ } | {
92
+ kind: "artifact";
93
+ result_id: string;
94
+ result_bytes: number;
95
+ expires_at: string;
96
+ preview: string;
97
+ next_cursor: string;
78
98
  };
79
99
  export declare const stableErrorSchema: z.ZodObject<{
80
100
  ok: z.ZodLiteral<false>;
@@ -586,6 +606,8 @@ export declare const bundlePartReadSchema: z.ZodObject<{
586
606
  };
587
607
  }>;
588
608
  export declare const readResultOutputSchema: z.ZodTypeAny;
609
+ export declare const readOutlineOutputSchema: z.ZodTypeAny;
610
+ export declare const readOutlineToolOutputSchema: z.ZodTypeAny;
589
611
  export declare const discardResultOutputSchema: z.ZodTypeAny;
590
612
  export declare const parseToolOutputSchema: z.ZodTypeAny;
591
613
  export declare const readResultToolOutputSchema: z.ZodTypeAny;
@@ -1,7 +1,45 @@
1
1
  import { z } from "zod";
2
+ import { OmniBridgeError } from "./errors.js";
2
3
  import { RESULT_BUNDLE_PROTOCOL_VERSION } from "./protocol.js";
4
+ // Maps a retained LocalResult (ArtifactStore's own representation) into the
5
+ // wire ParseResult["result"] shape. Used both for local-file parses
6
+ // (tools.ts's completedLocalResult, which additionally sets its own
7
+ // local-upload-specific operation_id/data_handling) and for hydrated URL
8
+ // parses (operation-manager.ts's hydrateInlineUrlResult, which preserves
9
+ // Cube's own original operation_id/data_handling instead).
10
+ export function localResultToResultField(local) {
11
+ if (local.kind === "inline") {
12
+ return { kind: "inline", text: local.text };
13
+ }
14
+ if (local.nextCursor === undefined) {
15
+ throw new OmniBridgeError({
16
+ code: "LOCAL_RESULT_INTEGRITY_FAILED",
17
+ failureScope: "bridge",
18
+ message: "The local result artifact cursor is missing.",
19
+ operationCreated: true,
20
+ fileUploaded: true,
21
+ parserStarted: true,
22
+ billed: false,
23
+ contentReleased: true,
24
+ retryable: false,
25
+ });
26
+ }
27
+ return {
28
+ kind: "artifact",
29
+ result_id: local.resultId,
30
+ result_bytes: local.resultBytes,
31
+ expires_at: local.expiresAt,
32
+ preview: local.preview,
33
+ next_cursor: local.nextCursor,
34
+ };
35
+ }
3
36
  const operationIdSchema = z.string().min(1).max(128);
4
37
  const resultIdSchema = z.string().regex(/^result_[A-Za-z0-9_-]{16,64}$/u);
38
+ const nextCursorSchema = z
39
+ .string()
40
+ .min(1)
41
+ .max(2048)
42
+ .describe("Pass this value as cursor in the next read_result call.");
5
43
  const constraintsSchema = z
6
44
  .object({
7
45
  max_bytes: z.number().int().nonnegative().optional(),
@@ -76,7 +114,7 @@ const artifactResultSchema = z
76
114
  result_bytes: z.number().int().nonnegative(),
77
115
  expires_at: z.string().datetime({ offset: true }),
78
116
  preview: z.string(),
79
- next_cursor: z.string().min(1).max(2048),
117
+ next_cursor: nextCursorSchema,
80
118
  })
81
119
  .strict();
82
120
  const billingSchema = z
@@ -105,7 +143,7 @@ const contentStorageSchema = z.discriminatedUnion("kind", [
105
143
  z
106
144
  .object({
107
145
  kind: z.literal("artifact"),
108
- next_cursor: z.string().min(1).max(2048),
146
+ next_cursor: nextCursorSchema,
109
147
  preview: z.string().optional(),
110
148
  })
111
149
  .strict(),
@@ -120,7 +158,7 @@ const groundingStorageSchema = z.discriminatedUnion("kind", [
120
158
  z
121
159
  .object({
122
160
  kind: z.literal("artifact"),
123
- next_cursor: z.string().min(1).max(2048),
161
+ next_cursor: nextCursorSchema,
124
162
  })
125
163
  .strict(),
126
164
  ]);
@@ -218,6 +256,7 @@ export const parseResultSchema = z.discriminatedUnion("status", [
218
256
  status: z.literal("canceled"),
219
257
  operation_id: operationIdSchema,
220
258
  cleanup_deadline: z.string().datetime({ offset: true }).optional(),
259
+ billing: billingSchema.optional(),
221
260
  data_handling: dataHandlingSchema,
222
261
  })
223
262
  .strict(),
@@ -226,6 +265,7 @@ export const parseResultSchema = z.discriminatedUnion("status", [
226
265
  status: z.literal("expired"),
227
266
  operation_id: operationIdSchema,
228
267
  requires_user_confirmation: z.literal(true),
268
+ billing: billingSchema.optional(),
229
269
  })
230
270
  .strict(),
231
271
  ]);
@@ -235,7 +275,7 @@ const resultChunkSchema = z
235
275
  result_bytes: z.number().int().nonnegative(),
236
276
  expires_at: z.string().datetime({ offset: true }),
237
277
  text: z.string(),
238
- next_cursor: z.string().min(1).max(2048).optional(),
278
+ next_cursor: nextCursorSchema.optional(),
239
279
  })
240
280
  .strict();
241
281
  // v3 part read (D2-D item 9): closed chunk with part/media type/offset/
@@ -253,7 +293,7 @@ const bundlePartReadChunkSchema = z
253
293
  offset: z.number().int().nonnegative(),
254
294
  decoded_bytes: z.number().int().nonnegative(),
255
295
  text: z.string(),
256
- next_cursor: z.string().min(1).max(2048).optional(),
296
+ next_cursor: nextCursorSchema.optional(),
257
297
  expires_at: z.string().datetime({ offset: true }),
258
298
  })
259
299
  .strict(),
@@ -266,7 +306,7 @@ const bundlePartReadChunkSchema = z
266
306
  offset: z.number().int().nonnegative(),
267
307
  decoded_bytes: z.number().int().nonnegative(),
268
308
  text: z.string(),
269
- next_cursor: z.string().min(1).max(2048).optional(),
309
+ next_cursor: nextCursorSchema.optional(),
270
310
  expires_at: z.string().datetime({ offset: true }),
271
311
  })
272
312
  .strict(),
@@ -287,6 +327,46 @@ export const readResultOutputSchema = z.discriminatedUnion("status", [
287
327
  .strict(),
288
328
  failedResultSchema,
289
329
  ]);
330
+ const outlineNodeSchema = z
331
+ .object({
332
+ id: z.string().regex(/^node_[0-9]{6}$/u),
333
+ level: z.number().int().min(1).max(6),
334
+ title: z.string().min(1).max(512),
335
+ preview: z.string().max(512),
336
+ })
337
+ .strict();
338
+ export const readOutlineOutputSchema = z.discriminatedUnion("status", [
339
+ z
340
+ .object({
341
+ status: z.literal("outline"),
342
+ coverage: z.enum(["complete", "partial", "none"]),
343
+ nodes: z.array(outlineNodeSchema),
344
+ })
345
+ .strict(),
346
+ z
347
+ .object({
348
+ status: z.literal("cursor"),
349
+ cursor: z.string().min(1).max(2048),
350
+ })
351
+ .strict(),
352
+ z
353
+ .object({
354
+ status: z.literal("unavailable"),
355
+ reason: z.literal("OUTLINE_NOT_SUPPORTED"),
356
+ })
357
+ .strict(),
358
+ failedResultSchema,
359
+ ]);
360
+ export const readOutlineToolOutputSchema = z
361
+ .object({
362
+ status: z.enum(["outline", "cursor", "unavailable", "failed"]),
363
+ coverage: z.enum(["complete", "partial", "none"]).optional(),
364
+ nodes: z.array(outlineNodeSchema).optional(),
365
+ cursor: z.string().min(1).max(2048).optional(),
366
+ reason: z.literal("OUTLINE_NOT_SUPPORTED").optional(),
367
+ error: stableErrorSchema.optional(),
368
+ })
369
+ .strict();
290
370
  export const discardResultOutputSchema = z.discriminatedUnion("status", [
291
371
  z
292
372
  .object({
package/dist/tools.d.ts CHANGED
@@ -5,6 +5,7 @@ import type { ReaderCapabilitiesV1 } from "./capabilities.js";
5
5
  import { type CubeGrantClient } from "./cube-client.js";
6
6
  import type { IiisClient, ResultRetentionSink } from "./iiis-client.js";
7
7
  import { type OpenAllowedFileOptions, type OpenedAllowedFile } from "./path-security.js";
8
+ import type { OutlineResult } from "./outline.js";
8
9
  import type { RemoteOmniClient } from "./remote-client.js";
9
10
  import { type ParseResult } from "./result-contract.js";
10
11
  interface ToolRetention extends ResultRetentionSink {
@@ -14,6 +15,8 @@ interface ToolArtifactStore {
14
15
  createRetention(): ToolRetention;
15
16
  read(resultId: string, cursor?: string, maxBytes?: number): Promise<ArtifactReadResult>;
16
17
  readBundlePart?(resultId: string, cursor: string, maxBytes?: number): Promise<BundlePartReadChunk>;
18
+ readOutline?(resultId: string): Promise<OutlineResult>;
19
+ mintOutlineCursor?(resultId: string, byteOffset: number): Promise<string>;
17
20
  discard(resultId: string): Promise<boolean>;
18
21
  }
19
22
  export interface ParseOperationController {
package/dist/tools.js CHANGED
@@ -3,10 +3,11 @@ import { CallToolRequestSchema, CancelTaskRequestSchema, ErrorCode, McpError, }
3
3
  import { selectDirectProfile, selectUrlProfile, } from "./capabilities.js";
4
4
  import { createClientRequestId as newClientRequestId, } from "./cube-client.js";
5
5
  import { OmniBridgeError } from "./errors.js";
6
+ import { hydrateInlineUrlResultSafely } from "./operation-manager.js";
6
7
  import { openAllowedFile, } from "./path-security.js";
7
8
  import { NOOP_PROGRESS } from "./progress.js";
8
- import { MACHINE_INSTRUCTIONS, cancelParseSchema, discardResultSchema, getParseStatusSchema, normalizeRepresentation, parseSchema, readResultSchema, } from "./protocol.js";
9
- import { discardResultOutputSchema, discardResultToolOutputSchema, parseResultSchema, parseToolOutputSchema, readResultOutputSchema, readResultToolOutputSchema, structuredResult, } from "./result-contract.js";
9
+ import { MACHINE_INSTRUCTIONS, cancelParseSchema, discardResultSchema, getParseStatusSchema, normalizeRepresentation, parseSchema, readOutlineSchema, readResultSchema, } from "./protocol.js";
10
+ import { discardResultOutputSchema, discardResultToolOutputSchema, localResultToResultField, parseResultSchema, parseToolOutputSchema, readOutlineOutputSchema, readOutlineToolOutputSchema, readResultOutputSchema, readResultToolOutputSchema, structuredResult, } from "./result-contract.js";
10
11
  import { classifySource } from "./source.js";
11
12
  import { TaskRuntime } from "./task-runtime.js";
12
13
  function bridgeError(code, message, facts = {}) {
@@ -51,38 +52,15 @@ function completedLocalResult(local) {
51
52
  original_source: "unchanged",
52
53
  remote_content_retained: false,
53
54
  };
54
- if (local.kind === "inline") {
55
- return {
56
- status: "completed",
57
- operation_id: local.operationId,
58
- result: { kind: "inline", text: local.text },
59
- data_handling: dataHandling,
60
- };
61
- }
62
- if (local.nextCursor === undefined) {
63
- throw bridgeError("LOCAL_RESULT_INTEGRITY_FAILED", "The local result artifact cursor is missing.", {
64
- operationCreated: true,
65
- fileUploaded: true,
66
- parserStarted: true,
67
- contentReleased: true,
68
- });
69
- }
55
+ const result = localResultToResultField(local);
70
56
  return {
71
57
  status: "completed",
72
58
  operation_id: local.operationId,
73
- result: {
74
- kind: "artifact",
75
- result_id: local.resultId,
76
- result_bytes: local.resultBytes,
77
- expires_at: local.expiresAt,
78
- preview: local.preview,
79
- next_cursor: local.nextCursor,
80
- },
59
+ result,
81
60
  data_handling: dataHandling,
82
- local_result_cache: {
83
- expires_at: local.expiresAt,
84
- discard_action: "discard_result",
85
- },
61
+ ...(result.kind === "artifact"
62
+ ? { local_result_cache: { expires_at: local.expiresAt, discard_action: "discard_result" } }
63
+ : {}),
86
64
  };
87
65
  }
88
66
  async function parseLocal(args, clientRequestId, signal, progress, dependencies) {
@@ -229,7 +207,8 @@ async function parseValue(args, signal, progress, dependencies) {
229
207
  if (dependencies.remoteClient === undefined) {
230
208
  throw bridgeError("REMOTE_PARSE_UNAVAILABLE", "Remote URL parsing is not available in this Bridge build.", { retryable: true });
231
209
  }
232
- return await dependencies.remoteClient.parse(source.source, clientRequestId, signal);
210
+ const remoteResult = await dependencies.remoteClient.parse(source.source, clientRequestId, signal);
211
+ return await hydrateInlineUrlResultSafely(remoteResult, dependencies.artifactStore);
233
212
  }
234
213
  return await parseLocal(args, clientRequestId, signal, progress, dependencies);
235
214
  }
@@ -256,7 +235,8 @@ async function statusValue(operationId, waitMs, signal, dependencies) {
256
235
  }
257
236
  }
258
237
  if (dependencies.remoteClient !== undefined) {
259
- return await dependencies.remoteClient.status(operationId, waitMs, signal);
238
+ const remoteResult = await dependencies.remoteClient.status(operationId, waitMs, signal);
239
+ return await hydrateInlineUrlResultSafely(remoteResult, dependencies.artifactStore);
260
240
  }
261
241
  throw bridgeError("OPERATION_NOT_FOUND", "The requested parse operation is not available.", { operationCreated: true });
262
242
  }
@@ -283,7 +263,8 @@ async function cancelValue(operationId, signal, dependencies) {
283
263
  }
284
264
  }
285
265
  if (dependencies.remoteClient !== undefined) {
286
- return await dependencies.remoteClient.cancel(operationId, signal);
266
+ const remoteResult = await dependencies.remoteClient.cancel(operationId, signal);
267
+ return await hydrateInlineUrlResultSafely(remoteResult, dependencies.artifactStore);
287
268
  }
288
269
  throw bridgeError("OPERATION_NOT_FOUND", "The requested parse operation is not available.", { operationCreated: true });
289
270
  }
@@ -354,12 +335,55 @@ async function callDiscardResult(resultId, dependencies) {
354
335
  return structuredResult(discardResultOutputSchema, failed(error));
355
336
  }
356
337
  }
338
+ async function callReadOutline(resultId, nodeId, dependencies) {
339
+ if (dependencies.artifactStore.readOutline === undefined
340
+ || dependencies.artifactStore.mintOutlineCursor === undefined) {
341
+ return structuredResult(readOutlineOutputSchema, {
342
+ status: "unavailable",
343
+ reason: "OUTLINE_NOT_SUPPORTED",
344
+ });
345
+ }
346
+ try {
347
+ const outline = await dependencies.artifactStore.readOutline(resultId);
348
+ if (nodeId === undefined) {
349
+ return structuredResult(readOutlineOutputSchema, {
350
+ status: "outline",
351
+ coverage: outline.coverage,
352
+ // Strip byteOffset: it is an internal detail the caller mints a cursor for,
353
+ // never a value it reads or supplies back directly.
354
+ nodes: outline.nodes.map((node) => ({
355
+ id: node.id,
356
+ level: node.level,
357
+ title: node.title,
358
+ preview: node.preview,
359
+ })),
360
+ });
361
+ }
362
+ const node = outline.nodes.find((candidate) => candidate.id === nodeId);
363
+ if (node === undefined) {
364
+ throw bridgeError("OUTLINE_NODE_NOT_FOUND", "The requested outline node does not exist.", {
365
+ operationCreated: true,
366
+ fileUploaded: true,
367
+ parserStarted: true,
368
+ billed: true,
369
+ contentReleased: true,
370
+ });
371
+ }
372
+ const cursor = await dependencies.artifactStore.mintOutlineCursor(resultId, node.byteOffset);
373
+ return structuredResult(readOutlineOutputSchema, { status: "cursor", cursor });
374
+ }
375
+ catch (error) {
376
+ return structuredResult(readOutlineOutputSchema, failed(error));
377
+ }
378
+ }
357
379
  function invalidArgumentsResult(name) {
358
380
  const error = failed(bridgeError("INVALID_TOOL_ARGUMENTS", "The Omni tool arguments are invalid."));
359
381
  if (name === "read_result")
360
382
  return structuredResult(readResultOutputSchema, error);
361
383
  if (name === "discard_result")
362
384
  return structuredResult(discardResultOutputSchema, error);
385
+ if (name === "read_outline")
386
+ return structuredResult(readOutlineOutputSchema, error);
363
387
  return structuredResult(parseResultSchema, error);
364
388
  }
365
389
  async function dispatchTool(name, rawArguments, extra, dependencies) {
@@ -393,6 +417,12 @@ async function dispatchTool(name, rawArguments, extra, dependencies) {
393
417
  ? callDiscardResult(parsed.data.result_id, dependencies)
394
418
  : invalidArgumentsResult(name);
395
419
  }
420
+ if (name === "read_outline") {
421
+ const parsed = readOutlineSchema.safeParse(rawArguments ?? {});
422
+ return parsed.success
423
+ ? callReadOutline(parsed.data.result_id, parsed.data.node_id, dependencies)
424
+ : invalidArgumentsResult(name);
425
+ }
396
426
  return structuredResult(parseResultSchema, failed(bridgeError("TOOL_NOT_FOUND", "The requested Omni tool is not available.")));
397
427
  }
398
428
  export function registerOmniTools(server, dependencies, taskStore) {
@@ -452,7 +482,7 @@ export function registerOmniTools(server, dependencies, taskStore) {
452
482
  },
453
483
  }, (args, extra) => callCancel(args.operation_id, extra, dependencies));
454
484
  server.registerTool("read_result", {
455
- description: "Read one UTF-8 chunk from a Bridge-created local result artifact.",
485
+ description: "Read one UTF-8 chunk from a Bridge-created local result artifact. Pass each returned next_cursor as cursor in the next call until it is absent.",
456
486
  inputSchema: readResultSchema,
457
487
  outputSchema: readResultToolOutputSchema,
458
488
  annotations: {
@@ -473,6 +503,17 @@ export function registerOmniTools(server, dependencies, taskStore) {
473
503
  openWorldHint: false,
474
504
  },
475
505
  }, (args) => callDiscardResult(args.result_id, dependencies));
506
+ server.registerTool("read_outline", {
507
+ description: "Return a local result's heading outline. Pass node_id from a returned node's id to mint a read_result-compatible cursor that jumps straight to that section, instead of reading sequentially from the start.",
508
+ inputSchema: readOutlineSchema,
509
+ outputSchema: readOutlineToolOutputSchema,
510
+ annotations: {
511
+ readOnlyHint: true,
512
+ destructiveHint: false,
513
+ idempotentHint: true,
514
+ openWorldHint: false,
515
+ },
516
+ }, (args) => callReadOutline(args.result_id, args.node_id, dependencies));
476
517
  server.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
477
518
  if (request.params.task !== undefined) {
478
519
  if (request.params.name !== "parse") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueai/omni-reader-mcp",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Local stdio MCP bridge for direct Omni document parsing",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",