@nimbus-dev/connectors 0.2.3 → 0.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/connectors/apple/src/server.ts +5 -1
- package/connectors/aws/src/server.ts +5 -1
- package/connectors/azure/src/server.ts +5 -1
- package/connectors/bitbucket/src/server.ts +5 -1
- package/connectors/circleci/src/server.ts +5 -1
- package/connectors/confluence/src/server.ts +5 -1
- package/connectors/fastmail/src/server.ts +5 -1
- package/connectors/gcp/src/server.ts +5 -1
- package/connectors/github/src/server.ts +2 -0
- package/connectors/github-actions/src/server.ts +5 -1
- package/connectors/gitlab/src/server.ts +5 -1
- package/connectors/gmail/src/server.ts +5 -1
- package/connectors/google-drive/src/server.ts +5 -1
- package/connectors/iac/src/server.ts +5 -1
- package/connectors/iac/src/tools.ts +77 -20
- package/connectors/imap/src/server.ts +5 -1
- package/connectors/jenkins/src/server.ts +5 -1
- package/connectors/jira/src/server.ts +5 -1
- package/connectors/kubernetes/src/server.ts +5 -1
- package/connectors/linear/src/server.ts +5 -1
- package/connectors/notion/src/server.ts +5 -1
- package/connectors/obsidian/src/server.ts +5 -1
- package/connectors/onedrive/src/server.ts +5 -1
- package/connectors/outlook/src/server.ts +5 -1
- package/connectors/pagerduty/src/server.ts +5 -1
- package/connectors/protonmail/src/server.ts +5 -1
- package/connectors/readwise/nimbus.extension.json +9 -1
- package/connectors/readwise/src/book-mapping.ts +109 -0
- package/connectors/readwise/src/extension.ts +11 -0
- package/connectors/readwise/src/highlight-mapping.ts +88 -0
- package/connectors/readwise/src/readwise-base.ts +6 -0
- package/connectors/readwise/src/record-fields.ts +72 -0
- package/connectors/readwise/src/sync-tool.ts +56 -0
- package/connectors/readwise/src/sync.ts +348 -0
- package/connectors/readwise/src/tools.ts +2 -2
- package/connectors/slack/src/server.ts +5 -1
- package/connectors/teams/src/server.ts +5 -1
- package/package.json +2 -2
- package/shared/cli-json-kit.ts +1 -13
- package/shared/consent-kit.ts +39 -17
- package/shared/nimbus-spawn.ts +31 -1
- package/shared/run-read-only-mcp-connector.ts +7 -1
- package/shared/safe-cli-arg.ts +0 -20
- package/shared/windows-batch-args.ts +23 -9
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Readwise book (`GET /api/v2/books/`) as a sync item. Ported from the gateway's
|
|
3
|
+
* `readwise-book-mapping.ts` (Nimbus origin/main 61e034a5) with every field rule unchanged;
|
|
4
|
+
* `test/mapping-parity.test.ts` pins each field to what the gateway stored.
|
|
5
|
+
*
|
|
6
|
+
* `externalId` is `book/<numeric id>`, NOT the bare id. Readwise numbers books and highlights in
|
|
7
|
+
* separate sequences, so book 9001 and highlight 9001 both exist, and the gateway's item key is
|
|
8
|
+
* `<service>:<externalId>`: a bare id would let one type's row overwrite the other's.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
asRecord,
|
|
12
|
+
namedTags,
|
|
13
|
+
numberField,
|
|
14
|
+
parseIsoMs,
|
|
15
|
+
type ReadwiseMappingContext,
|
|
16
|
+
type ReadwiseSyncItem,
|
|
17
|
+
stringField,
|
|
18
|
+
} from "./record-fields.ts";
|
|
19
|
+
|
|
20
|
+
/** Keeps `readwise:book/<id>` distinct from the highlight `readwise:<id>`. */
|
|
21
|
+
export const READWISE_BOOK_EXTERNAL_ID_PREFIX = "book/";
|
|
22
|
+
|
|
23
|
+
/** Trimmed string field, or null when absent/blank. */
|
|
24
|
+
function trimmedField(row: Record<string, unknown>, key: string): string | null {
|
|
25
|
+
const raw = stringField(row, key);
|
|
26
|
+
if (raw === undefined) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const trimmed = raw.trim();
|
|
30
|
+
return trimmed === "" ? null : trimmed;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function firstNonNull(candidates: readonly (string | null)[], fallback: string): string {
|
|
34
|
+
for (const candidate of candidates) {
|
|
35
|
+
if (candidate !== null) {
|
|
36
|
+
return candidate;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return fallback;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function mapReadwiseBookToItem(
|
|
43
|
+
raw: unknown,
|
|
44
|
+
ctx: ReadwiseMappingContext,
|
|
45
|
+
): ReadwiseSyncItem | null {
|
|
46
|
+
const row = asRecord(raw);
|
|
47
|
+
if (row === undefined) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const idNum = numberField(row, "id");
|
|
52
|
+
if (idNum === undefined) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const id = String(idNum);
|
|
56
|
+
|
|
57
|
+
const title = trimmedField(row, "title");
|
|
58
|
+
const author = trimmedField(row, "author");
|
|
59
|
+
const category = trimmedField(row, "category");
|
|
60
|
+
const source = trimmedField(row, "source");
|
|
61
|
+
const documentNote = trimmedField(row, "document_note");
|
|
62
|
+
const asin = trimmedField(row, "asin");
|
|
63
|
+
const sourceUrl = trimmedField(row, "source_url");
|
|
64
|
+
const highlightsUrl = trimmedField(row, "highlights_url");
|
|
65
|
+
const numHighlights = numberField(row, "num_highlights") ?? null;
|
|
66
|
+
const tags = namedTags(row["tags"]);
|
|
67
|
+
|
|
68
|
+
const lastHighlightAt = parseIsoMs(row["last_highlight_at"]);
|
|
69
|
+
const updatedAt = parseIsoMs(row["updated"]);
|
|
70
|
+
|
|
71
|
+
const titleText = firstNonNull([title, author], `Book ${id}`);
|
|
72
|
+
const bodyPreview = firstNonNull([documentNote, author, category], titleText);
|
|
73
|
+
|
|
74
|
+
// Kindle/ePub books have no public source URL; the Readwise book-review page
|
|
75
|
+
// is the only stable, user-openable URL for them.
|
|
76
|
+
const canonicalUrl = sourceUrl ?? highlightsUrl;
|
|
77
|
+
|
|
78
|
+
const modifiedAt = updatedAt ?? lastHighlightAt ?? ctx.syncedAt;
|
|
79
|
+
|
|
80
|
+
const metadata: Record<string, unknown> = {
|
|
81
|
+
// NUMBER, not the stringified id: a highlight stores its parent as
|
|
82
|
+
// `metadata.book_id` (a number), and this is the field that joins them.
|
|
83
|
+
book_id: idNum,
|
|
84
|
+
title,
|
|
85
|
+
author,
|
|
86
|
+
category,
|
|
87
|
+
source,
|
|
88
|
+
num_highlights: numHighlights,
|
|
89
|
+
asin,
|
|
90
|
+
tags,
|
|
91
|
+
document_note: documentNote,
|
|
92
|
+
source_url: sourceUrl,
|
|
93
|
+
highlights_url: highlightsUrl,
|
|
94
|
+
last_highlight_at: lastHighlightAt,
|
|
95
|
+
updated_at: updatedAt,
|
|
96
|
+
canonical_url: canonicalUrl,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
type: "book",
|
|
101
|
+
externalId: `${READWISE_BOOK_EXTERNAL_ID_PREFIX}${id}`,
|
|
102
|
+
title: titleText,
|
|
103
|
+
bodyPreview,
|
|
104
|
+
url: canonicalUrl,
|
|
105
|
+
canonicalUrl,
|
|
106
|
+
modifiedAt,
|
|
107
|
+
metadata,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Entry point of the signed Readwise extension (bundled to dist/server.js by `build:extension`).
|
|
2
|
+
// Kept apart from server.ts so the standalone/gateway entry stays untouched; bootstrap only.
|
|
3
|
+
// Only this entry registers the reserved `__nimbus_sync` tool; server.ts users never see it.
|
|
4
|
+
import { runReadOnlyMcpConnector } from "../../../shared/run-read-only-mcp-connector.ts";
|
|
5
|
+
import { registerReadwiseSyncTool } from "./sync-tool.ts";
|
|
6
|
+
import { registerReadwiseTools } from "./tools.ts";
|
|
7
|
+
|
|
8
|
+
await runReadOnlyMcpConnector("nimbus-readwise", (reg) => {
|
|
9
|
+
registerReadwiseTools(reg);
|
|
10
|
+
registerReadwiseSyncTool(reg);
|
|
11
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Readwise highlight (`GET /api/v2/highlights/`) as a sync item. Ported from the gateway's
|
|
3
|
+
* `readwise-highlight-mapping.ts` (Nimbus origin/main 61e034a5) with every field rule unchanged:
|
|
4
|
+
* a migrated user's rows are updated in place only while `type` and `externalId` stay
|
|
5
|
+
* byte-identical, and `test/mapping-parity.test.ts` pins every other field to what the gateway
|
|
6
|
+
* stored. Highlights keep the BARE numeric id as `externalId`.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
asRecord,
|
|
10
|
+
namedTags,
|
|
11
|
+
numberField,
|
|
12
|
+
parseIsoMs,
|
|
13
|
+
type ReadwiseMappingContext,
|
|
14
|
+
type ReadwiseSyncItem,
|
|
15
|
+
stringField,
|
|
16
|
+
} from "./record-fields.ts";
|
|
17
|
+
|
|
18
|
+
const TITLE_MAX = 80;
|
|
19
|
+
|
|
20
|
+
export function mapReadwiseHighlightToItem(
|
|
21
|
+
raw: unknown,
|
|
22
|
+
ctx: ReadwiseMappingContext,
|
|
23
|
+
): ReadwiseSyncItem | null {
|
|
24
|
+
const row = asRecord(raw);
|
|
25
|
+
if (row === undefined) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const idNum = numberField(row, "id");
|
|
30
|
+
if (idNum === undefined) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const id = String(idNum);
|
|
34
|
+
|
|
35
|
+
const text = stringField(row, "text") ?? null;
|
|
36
|
+
const note = stringField(row, "note") ?? null;
|
|
37
|
+
const location = numberField(row, "location") ?? null;
|
|
38
|
+
const locationType = stringField(row, "location_type") ?? null;
|
|
39
|
+
const color = stringField(row, "color") ?? null;
|
|
40
|
+
const bookId = numberField(row, "book_id") ?? null;
|
|
41
|
+
const sourceUrl = stringField(row, "url") ?? null;
|
|
42
|
+
const tags = namedTags(row["tags"]);
|
|
43
|
+
|
|
44
|
+
const highlightedAt = parseIsoMs(row["highlighted_at"]);
|
|
45
|
+
const updatedAt = parseIsoMs(row["updated"]);
|
|
46
|
+
|
|
47
|
+
const trimmedText = text === null ? "" : text.trim();
|
|
48
|
+
let title: string;
|
|
49
|
+
if (trimmedText === "") {
|
|
50
|
+
title = `Highlight ${id}`;
|
|
51
|
+
} else if (trimmedText.length > TITLE_MAX) {
|
|
52
|
+
title = `${trimmedText.slice(0, TITLE_MAX)}…`;
|
|
53
|
+
} else {
|
|
54
|
+
title = trimmedText;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const bodyPreview = note !== null && note !== "" ? note : (text ?? "");
|
|
58
|
+
|
|
59
|
+
const canonicalUrl = sourceUrl !== null && sourceUrl !== "" ? sourceUrl : null;
|
|
60
|
+
|
|
61
|
+
const modifiedAt = updatedAt ?? highlightedAt ?? ctx.syncedAt;
|
|
62
|
+
|
|
63
|
+
const metadata: Record<string, unknown> = {
|
|
64
|
+
highlight_id: id,
|
|
65
|
+
text,
|
|
66
|
+
note,
|
|
67
|
+
book_id: bookId,
|
|
68
|
+
location,
|
|
69
|
+
location_type: locationType,
|
|
70
|
+
color,
|
|
71
|
+
tags,
|
|
72
|
+
source_url: canonicalUrl,
|
|
73
|
+
highlighted_at: highlightedAt,
|
|
74
|
+
updated_at: updatedAt,
|
|
75
|
+
canonical_url: canonicalUrl,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
type: "highlight",
|
|
80
|
+
externalId: id,
|
|
81
|
+
title,
|
|
82
|
+
bodyPreview,
|
|
83
|
+
url: canonicalUrl,
|
|
84
|
+
canonicalUrl,
|
|
85
|
+
modifiedAt,
|
|
86
|
+
metadata,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Readwise origin, in ONE place: the read tools and the sync walk both import it. There is no
|
|
3
|
+
* environment or argument override — the SaaS host is fixed — so the only way to point either at
|
|
4
|
+
* another host is to substitute this module in a test-only build.
|
|
5
|
+
*/
|
|
6
|
+
export const READWISE_BASE = "https://readwise.io";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Field readers for the two Readwise mappers, ported unchanged from the gateway's
|
|
3
|
+
* `unknown-record.ts` and `_lib/field-helpers.ts` (Nimbus origin/main 61e034a5). Each rule is
|
|
4
|
+
* load-bearing for parity with rows the gateway already indexed, so none of them is "simplified":
|
|
5
|
+
* `shared/search-filter.ts` has an `asRecord` of the same rule but a `stringField` that returns ""
|
|
6
|
+
* rather than `undefined`, which would change what a missing field maps to.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function asRecord(v: unknown): Record<string, unknown> | undefined {
|
|
10
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
return v as Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function stringField(r: Record<string, unknown>, key: string): string | undefined {
|
|
17
|
+
const v = r[key];
|
|
18
|
+
return typeof v === "string" ? v : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function numberField(r: Record<string, unknown>, key: string): number | undefined {
|
|
22
|
+
const v = r[key];
|
|
23
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The non-empty `name` values of an array of tag objects (`[{ id, name }]`), in input order. A
|
|
28
|
+
* non-array yields `[]`; an entry that is not a plain object, or whose `name` is absent, not a
|
|
29
|
+
* string, or empty, is skipped.
|
|
30
|
+
*/
|
|
31
|
+
export function namedTags(raw: unknown): string[] {
|
|
32
|
+
if (!Array.isArray(raw)) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const names: string[] = [];
|
|
36
|
+
for (const t of raw) {
|
|
37
|
+
const row = asRecord(t);
|
|
38
|
+
if (row === undefined) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const name = stringField(row, "name");
|
|
42
|
+
if (name !== undefined && name !== "") {
|
|
43
|
+
names.push(name);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return names;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** An ISO-8601 string as epoch ms, or `null` when it is not a string or does not parse. */
|
|
50
|
+
export function parseIsoMs(v: unknown): number | null {
|
|
51
|
+
return typeof v === "string" && Number.isFinite(Date.parse(v)) ? Date.parse(v) : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The one item shape both mappers emit: a sync-v1 `SyncItem` with the body as a preview. Exactly
|
|
56
|
+
* these eight fields — `service` and `syncedAt` are the gateway's, never the item's.
|
|
57
|
+
*/
|
|
58
|
+
export interface ReadwiseSyncItem {
|
|
59
|
+
readonly type: "highlight" | "book";
|
|
60
|
+
readonly externalId: string;
|
|
61
|
+
readonly title: string;
|
|
62
|
+
readonly bodyPreview: string;
|
|
63
|
+
readonly url: string | null;
|
|
64
|
+
readonly canonicalUrl: string | null;
|
|
65
|
+
readonly modifiedAt: number;
|
|
66
|
+
readonly metadata: Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ReadwiseMappingContext {
|
|
70
|
+
/** The walk's `now`: only the `modifiedAt` fallback for a row with no usable timestamp. */
|
|
71
|
+
readonly syncedAt: number;
|
|
72
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reserved `__nimbus_sync` tool (sync contract v1, `@nimbus-dev/sdk/sync`). Registered by the
|
|
3
|
+
* extension entry (`extension.ts`) ONLY: the standalone and gateway-bundled `server.ts` never
|
|
4
|
+
* offers it. A read tool — it only GETs Readwise — and it takes no URL: the host is fixed in
|
|
5
|
+
* `readwise-base.ts`, and the cursor only ever selects a stream and a page number.
|
|
6
|
+
*/
|
|
7
|
+
import { SYNC_TOOL_NAME, type SyncRequest } from "@nimbus-dev/sdk/sync";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import type { McpListResult } from "../../../shared/mcp-tool-kit.ts";
|
|
10
|
+
import type { ZodToolRegistrar } from "../../../shared/run-read-only-mcp-connector.ts";
|
|
11
|
+
import { type ReadwiseFetch, readwiseSyncPage } from "./sync.ts";
|
|
12
|
+
|
|
13
|
+
const SYNC_REQUEST = z.object({
|
|
14
|
+
cursor: z.string().nullable(),
|
|
15
|
+
depth: z.enum(["metadata_only", "summary", "full"]),
|
|
16
|
+
historyFloorMs: z.number().optional(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The parsed arguments as the SDK's `SyncRequest`. Typed both ways on purpose: `depth` must stay the
|
|
21
|
+
* SDK's `SyncDepth`, and an absent `historyFloorMs` stays absent rather than `undefined`.
|
|
22
|
+
*/
|
|
23
|
+
function toSyncRequest(args: z.infer<typeof SYNC_REQUEST>): SyncRequest {
|
|
24
|
+
return {
|
|
25
|
+
cursor: args.cursor,
|
|
26
|
+
depth: args.depth,
|
|
27
|
+
...(args.historyFloorMs === undefined ? {} : { historyFloorMs: args.historyFloorMs }),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ReadwiseSyncToolOptions {
|
|
32
|
+
/** Defaults to the global `fetch`; tests serve Readwise through it. */
|
|
33
|
+
readonly fetch?: ReadwiseFetch;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function registerReadwiseSyncTool(
|
|
37
|
+
reg: ZodToolRegistrar,
|
|
38
|
+
options: ReadwiseSyncToolOptions = {},
|
|
39
|
+
): void {
|
|
40
|
+
reg(
|
|
41
|
+
SYNC_TOOL_NAME,
|
|
42
|
+
"Nimbus gateway sync (contract v1): one page of the user's Readwise highlights, then books, per call. Reserved for the gateway.",
|
|
43
|
+
SYNC_REQUEST,
|
|
44
|
+
async (args): Promise<McpListResult> => {
|
|
45
|
+
const request = toSyncRequest(args);
|
|
46
|
+
// `depth` and `historyFloorMs` are accepted and ignored: the gateway applies depth itself,
|
|
47
|
+
// and Readwise's walk was never bounded by a history floor.
|
|
48
|
+
const response = await readwiseSyncPage(request.cursor, {
|
|
49
|
+
// Read per call, as the read tools do, so a token set after start-up is used.
|
|
50
|
+
token: process.env["READWISE_TOKEN"],
|
|
51
|
+
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
52
|
+
});
|
|
53
|
+
return { content: [{ type: "text", text: JSON.stringify(response) }] };
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Readwise sync walk, served one page per `__nimbus_sync` call.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the gateway's `readwise-sync.ts` + `runSinglePassPaginatedSync` (Nimbus origin/main
|
|
5
|
+
* 61e034a5), which walked two streams in one run: highlights (`/api/v2/highlights/`), then books
|
|
6
|
+
* (`/api/v2/books/`), each at most {@link READWISE_SYNC_MAX_PAGES} DRF pages of 1000. The gateway
|
|
7
|
+
* now drives the paging, so the position between calls lives in an extension-owned cursor:
|
|
8
|
+
*
|
|
9
|
+
* - `null`, the resting cursor, or ANY value this module did not mint → highlights page 1.
|
|
10
|
+
* - mid-walk: `nimbus-readwise1:` + base64url(`{"stream":"highlights"|"books","page":N}`), the
|
|
11
|
+
* encoding the gateway's `encodeNimbusJsonCursor` used for the resting cursor.
|
|
12
|
+
* - after the last books page: {@link READWISE_RESTING_CURSOR}, byte-identical to the gateway's
|
|
13
|
+
* `pass1Cursor()`, with `hasMore: false`.
|
|
14
|
+
* - a page split for size: the same position plus `"skip":K`, the number of that page's MAPPED
|
|
15
|
+
* items (see {@link mapPage}) already returned. See {@link READWISE_SYNC_RESPONSE_BUDGET_BYTES}.
|
|
16
|
+
*
|
|
17
|
+
* A stream ends when its page is empty, its `next` is null/empty, or its 20th page was read; the
|
|
18
|
+
* highlights stream's end hands off to books page 1 in the same response that carries the page's
|
|
19
|
+
* items. An HTTP error other than 429/401/403, or a body that is not JSON, ends the CURRENT stream
|
|
20
|
+
* with no items — exactly where today's walk degraded a first-page error to an empty result and
|
|
21
|
+
* broke out on a later-page one. A rejected fetch propagates (today's `sync()` threw).
|
|
22
|
+
*
|
|
23
|
+
* The token is only ever an `Authorization` header value: no response, log or error carries it.
|
|
24
|
+
*/
|
|
25
|
+
import type { SyncResponse } from "@nimbus-dev/sdk/sync";
|
|
26
|
+
import { mapReadwiseBookToItem } from "./book-mapping.ts";
|
|
27
|
+
import { mapReadwiseHighlightToItem } from "./highlight-mapping.ts";
|
|
28
|
+
import { READWISE_BASE } from "./readwise-base.ts";
|
|
29
|
+
import { asRecord, type ReadwiseMappingContext, type ReadwiseSyncItem } from "./record-fields.ts";
|
|
30
|
+
|
|
31
|
+
type OkResponse = Extract<SyncResponse, { status: "ok" }>;
|
|
32
|
+
|
|
33
|
+
const CURSOR_PREFIX = "nimbus-readwise1:";
|
|
34
|
+
const PAGE_SIZE = 1000;
|
|
35
|
+
|
|
36
|
+
/** Per-stream page cap, as today. */
|
|
37
|
+
export const READWISE_SYNC_MAX_PAGES = 20;
|
|
38
|
+
|
|
39
|
+
export type ReadwiseStream = "highlights" | "books";
|
|
40
|
+
|
|
41
|
+
export interface ReadwiseCursorPosition {
|
|
42
|
+
readonly stream: ReadwiseStream;
|
|
43
|
+
readonly page: number;
|
|
44
|
+
/**
|
|
45
|
+
* How many of this page's MAPPED items (after null-mapped rows are dropped and repeated ids
|
|
46
|
+
* collapsed) earlier calls already returned. Absent means 0; when present, a positive integer.
|
|
47
|
+
*/
|
|
48
|
+
readonly skip?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The most a response's JSON text may weigh, in UTF-8 bytes. The gateway refuses a `__nimbus_sync`
|
|
53
|
+
* result over 4 MiB (`MCP_TOOL_RESULT_MAX_BYTES`), which fails the run; 3 MiB leaves room for the
|
|
54
|
+
* MCP envelope around the text. A page of 1000 long highlights repeats each text in the title, the
|
|
55
|
+
* preview and the metadata, and can pass 4 MiB, so a page over budget is split across calls.
|
|
56
|
+
*/
|
|
57
|
+
export const READWISE_SYNC_RESPONSE_BUDGET_BYTES = 3 * 1024 * 1024;
|
|
58
|
+
|
|
59
|
+
const STREAMS: Readonly<
|
|
60
|
+
Record<
|
|
61
|
+
ReadwiseStream,
|
|
62
|
+
{
|
|
63
|
+
readonly path: string;
|
|
64
|
+
readonly map: (raw: unknown, ctx: ReadwiseMappingContext) => ReadwiseSyncItem | null;
|
|
65
|
+
}
|
|
66
|
+
>
|
|
67
|
+
> = {
|
|
68
|
+
highlights: { path: "/api/v2/highlights/", map: mapReadwiseHighlightToItem },
|
|
69
|
+
books: { path: "/api/v2/books/", map: mapReadwiseBookToItem },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function encodePayload(payload: unknown): string {
|
|
73
|
+
return CURSOR_PREFIX + Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The cursor a finished pass rests on: the gateway's `pass1Cursor()`, byte for byte. */
|
|
77
|
+
export const READWISE_RESTING_CURSOR = encodePayload({ pass: 1 });
|
|
78
|
+
|
|
79
|
+
export function encodeReadwiseCursor(position: ReadwiseCursorPosition): string {
|
|
80
|
+
const skip = position.skip ?? 0;
|
|
81
|
+
return encodePayload(
|
|
82
|
+
skip === 0
|
|
83
|
+
? { stream: position.stream, page: position.page }
|
|
84
|
+
: { stream: position.stream, page: position.page, skip },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isStream(v: unknown): v is ReadwiseStream {
|
|
89
|
+
return v === "highlights" || v === "books";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The position a cursor this module minted points at, or `null` for anything else — which the
|
|
94
|
+
* walk reads as "start a full pass". Strict: exactly `stream` and `page`, plus `skip` only when it
|
|
95
|
+
* is a positive safe integer; a known stream, a page within the cap, and the canonical encoding
|
|
96
|
+
* (so no two spellings name one position).
|
|
97
|
+
*/
|
|
98
|
+
export function decodeReadwiseCursor(raw: string | null): ReadwiseCursorPosition | null {
|
|
99
|
+
if (!raw?.startsWith(CURSOR_PREFIX)) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
let payload: unknown;
|
|
103
|
+
try {
|
|
104
|
+
payload = JSON.parse(
|
|
105
|
+
Buffer.from(raw.slice(CURSOR_PREFIX.length), "base64url").toString("utf8"),
|
|
106
|
+
) as unknown;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const o = asRecord(payload);
|
|
111
|
+
if (o === undefined) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const { stream, page, skip } = o;
|
|
115
|
+
const keyCount = Object.keys(o).length;
|
|
116
|
+
if (keyCount === 3) {
|
|
117
|
+
if (typeof skip !== "number" || !Number.isSafeInteger(skip) || skip < 1) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
} else if (keyCount !== 2) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
if (
|
|
124
|
+
!isStream(stream) ||
|
|
125
|
+
typeof page !== "number" ||
|
|
126
|
+
!Number.isSafeInteger(page) ||
|
|
127
|
+
page < 1 ||
|
|
128
|
+
page > READWISE_SYNC_MAX_PAGES
|
|
129
|
+
) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const position: ReadwiseCursorPosition =
|
|
133
|
+
typeof skip === "number" ? { stream, page, skip } : { stream, page };
|
|
134
|
+
return encodeReadwiseCursor(position) === raw ? position : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The fetch the walk uses; a seam so tests serve Readwise without a network. */
|
|
138
|
+
export type ReadwiseFetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
139
|
+
|
|
140
|
+
export interface ReadwiseSyncDeps {
|
|
141
|
+
/** `READWISE_TOKEN` as the process received it; blank or absent → `unauthenticated`. */
|
|
142
|
+
readonly token: string | undefined;
|
|
143
|
+
/** Defaults to the global `fetch`. */
|
|
144
|
+
readonly fetch?: ReadwiseFetch;
|
|
145
|
+
/** Defaults to `Date.now`. The mappers' `modifiedAt` fallback and the Retry-After clock. */
|
|
146
|
+
readonly now?: () => number;
|
|
147
|
+
/** Defaults to {@link READWISE_SYNC_RESPONSE_BUDGET_BYTES}; tests lower it to split small pages. */
|
|
148
|
+
readonly budgetBytes?: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
type PageOutcome =
|
|
152
|
+
| { readonly kind: "ok"; readonly parsed: unknown }
|
|
153
|
+
| { readonly kind: "http_error"; readonly status: number; readonly retryAfter: string | null }
|
|
154
|
+
| { readonly kind: "parse_error" };
|
|
155
|
+
|
|
156
|
+
/** A failure's message with every occurrence of the token cut out. */
|
|
157
|
+
function redactedError(e: unknown, token: string): Error {
|
|
158
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
159
|
+
return new Error(`Readwise request failed: ${message.split(token).join("[redacted]")}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function getPage(url: string, token: string, fetchFn: ReadwiseFetch): Promise<PageOutcome> {
|
|
163
|
+
let text: string;
|
|
164
|
+
let res: Response;
|
|
165
|
+
try {
|
|
166
|
+
res = await fetchFn(url, {
|
|
167
|
+
headers: { Authorization: `Token ${token}`, Accept: "application/json" },
|
|
168
|
+
});
|
|
169
|
+
text = await res.text();
|
|
170
|
+
} catch (e) {
|
|
171
|
+
throw redactedError(e, token);
|
|
172
|
+
}
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
return { kind: "http_error", status: res.status, retryAfter: res.headers.get("retry-after") };
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
return { kind: "ok", parsed: JSON.parse(text) as unknown };
|
|
178
|
+
} catch {
|
|
179
|
+
return { kind: "parse_error" };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The DRF page envelope both list endpoints use; anything else reads as an empty, final page. */
|
|
184
|
+
function parseReadwisePage(parsed: unknown): { items: unknown[]; hasMore: boolean } {
|
|
185
|
+
const root = asRecord(parsed);
|
|
186
|
+
if (root === undefined) {
|
|
187
|
+
return { items: [], hasMore: false };
|
|
188
|
+
}
|
|
189
|
+
const results = root["results"];
|
|
190
|
+
const items = Array.isArray(results) ? results : [];
|
|
191
|
+
const nextRaw = root["next"];
|
|
192
|
+
const next = typeof nextRaw === "string" && nextRaw !== "" ? nextRaw : null;
|
|
193
|
+
return { items, hasMore: items.length > 0 && next !== null };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* `Retry-After` as a non-negative integer of ms: delta-seconds, or an HTTP-date (IMF-fixdate or
|
|
198
|
+
* RFC 850, both in GMT) as the time until then, clamped at 0. Anything else — absent, negative,
|
|
199
|
+
* fractional, too large, the obsolete zone-less asctime form — is `undefined`, and the field is
|
|
200
|
+
* left out so the gateway applies its own default.
|
|
201
|
+
*/
|
|
202
|
+
export function parseRetryAfterMs(header: string | null, now: number): number | undefined {
|
|
203
|
+
if (header === null) {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
const v = header.trim();
|
|
207
|
+
if (/^\d+$/.test(v)) {
|
|
208
|
+
const ms = Number(v) * 1000;
|
|
209
|
+
return Number.isSafeInteger(ms) ? ms : undefined;
|
|
210
|
+
}
|
|
211
|
+
if (!/^[A-Za-z]{3,9}, .+ GMT$/.test(v)) {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
const at = Date.parse(v);
|
|
215
|
+
if (!Number.isSafeInteger(at)) {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
return Math.max(0, at - now);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function pageUrl(position: ReadwiseCursorPosition): string {
|
|
222
|
+
const params = new URLSearchParams({ page_size: String(PAGE_SIZE), page: String(position.page) });
|
|
223
|
+
return `${READWISE_BASE}${STREAMS[position.stream].path}?${params.toString()}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The mapped rows of one page; a repeated externalId keeps its LAST row, as today's upserts did. */
|
|
227
|
+
function mapPage(
|
|
228
|
+
rows: readonly unknown[],
|
|
229
|
+
stream: ReadwiseStream,
|
|
230
|
+
ctx: ReadwiseMappingContext,
|
|
231
|
+
): ReadwiseSyncItem[] {
|
|
232
|
+
const byId = new Map<string, ReadwiseSyncItem>();
|
|
233
|
+
for (const raw of rows) {
|
|
234
|
+
const item = STREAMS[stream].map(raw, ctx);
|
|
235
|
+
if (item !== null) {
|
|
236
|
+
byId.set(item.externalId, item);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return [...byId.values()];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The response that ends `stream`: highlights hand off to books page 1; books end the pass. */
|
|
243
|
+
function endOfStream(stream: ReadwiseStream, items: ReadwiseSyncItem[]): OkResponse {
|
|
244
|
+
return stream === "highlights"
|
|
245
|
+
? {
|
|
246
|
+
status: "ok",
|
|
247
|
+
items,
|
|
248
|
+
cursor: encodeReadwiseCursor({ stream: "books", page: 1 }),
|
|
249
|
+
hasMore: true,
|
|
250
|
+
}
|
|
251
|
+
: { status: "ok", items, cursor: READWISE_RESTING_CURSOR, hasMore: false };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The UTF-8 size of the exact text the tool returns for `response`. */
|
|
255
|
+
export function syncResponseBytes(response: SyncResponse): number {
|
|
256
|
+
return Buffer.byteLength(JSON.stringify(response), "utf8");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* `whole` when it fits the budget; otherwise the largest prefix of `items` that fits, with a
|
|
261
|
+
* cursor that resumes this page right after it. The page's own continuation (next page, handoff,
|
|
262
|
+
* end of pass) lives in `whole`, so only the call that returns the page's LAST item carries it;
|
|
263
|
+
* a remainder still over budget is split again by the next call.
|
|
264
|
+
*
|
|
265
|
+
* At least one item goes out even when that item alone is over budget, so the walk always
|
|
266
|
+
* progresses instead of re-fetching the same page forever. That is safe: one item is bounded — the
|
|
267
|
+
* gateway drops any item whose metadata passes 64 KiB, and its title and preview are one
|
|
268
|
+
* highlight's or book's own text — so a one-item response stays far under the gateway's 4 MiB.
|
|
269
|
+
*/
|
|
270
|
+
function fitToBudget(
|
|
271
|
+
whole: OkResponse,
|
|
272
|
+
position: ReadwiseCursorPosition,
|
|
273
|
+
budget: number,
|
|
274
|
+
): SyncResponse {
|
|
275
|
+
// One item (or none) is never split: that response carries the page's own continuation.
|
|
276
|
+
if (whole.items.length <= 1 || syncResponseBytes(whole) <= budget) {
|
|
277
|
+
return whole;
|
|
278
|
+
}
|
|
279
|
+
const skipped = position.skip ?? 0;
|
|
280
|
+
const prefix = (count: number): OkResponse => ({
|
|
281
|
+
status: "ok",
|
|
282
|
+
items: whole.items.slice(0, count),
|
|
283
|
+
cursor: encodeReadwiseCursor({ ...position, skip: skipped + count }),
|
|
284
|
+
hasMore: true,
|
|
285
|
+
});
|
|
286
|
+
// The largest count in [1, items - 1] that fits; the size only grows with the count, and all
|
|
287
|
+
// of the items (`whole`) are already known not to fit.
|
|
288
|
+
let lo = 1;
|
|
289
|
+
let hi = whole.items.length - 1;
|
|
290
|
+
let best = 1;
|
|
291
|
+
while (lo <= hi) {
|
|
292
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
293
|
+
if (syncResponseBytes(prefix(mid)) <= budget) {
|
|
294
|
+
best = mid;
|
|
295
|
+
lo = mid + 1;
|
|
296
|
+
} else {
|
|
297
|
+
hi = mid - 1;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return prefix(best);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** One `__nimbus_sync` call: fetch the page `cursor` points at and answer with its items. */
|
|
304
|
+
export async function readwiseSyncPage(
|
|
305
|
+
cursor: string | null,
|
|
306
|
+
deps: ReadwiseSyncDeps,
|
|
307
|
+
): Promise<SyncResponse> {
|
|
308
|
+
const token = deps.token?.trim() ?? "";
|
|
309
|
+
if (token === "") {
|
|
310
|
+
return { status: "unauthenticated" };
|
|
311
|
+
}
|
|
312
|
+
const position = decodeReadwiseCursor(cursor) ?? { stream: "highlights", page: 1 };
|
|
313
|
+
const now = (deps.now ?? Date.now)();
|
|
314
|
+
const fetchFn: ReadwiseFetch = deps.fetch ?? ((url, init) => fetch(url, init));
|
|
315
|
+
|
|
316
|
+
const outcome = await getPage(pageUrl(position), token, fetchFn);
|
|
317
|
+
if (outcome.kind === "http_error") {
|
|
318
|
+
if (outcome.status === 429) {
|
|
319
|
+
const retryAfterMs = parseRetryAfterMs(outcome.retryAfter, now);
|
|
320
|
+
return retryAfterMs === undefined
|
|
321
|
+
? { status: "rate_limited" }
|
|
322
|
+
: { status: "rate_limited", retryAfterMs };
|
|
323
|
+
}
|
|
324
|
+
if (outcome.status === 401 || outcome.status === 403) {
|
|
325
|
+
return { status: "unauthenticated" };
|
|
326
|
+
}
|
|
327
|
+
return endOfStream(position.stream, []);
|
|
328
|
+
}
|
|
329
|
+
if (outcome.kind === "parse_error") {
|
|
330
|
+
return endOfStream(position.stream, []);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const page = parseReadwisePage(outcome.parsed);
|
|
334
|
+
// `skip` counts MAPPED items: the same page maps to the same items in the same order, so the
|
|
335
|
+
// count holds across the calls that split it. A field edited in between changes values, not
|
|
336
|
+
// positions; a row added to or removed from the page in between shifts the split by that many.
|
|
337
|
+
const items = mapPage(page.items, position.stream, { syncedAt: now }).slice(position.skip ?? 0);
|
|
338
|
+
const whole: OkResponse =
|
|
339
|
+
page.hasMore && position.page < READWISE_SYNC_MAX_PAGES
|
|
340
|
+
? {
|
|
341
|
+
status: "ok",
|
|
342
|
+
items,
|
|
343
|
+
cursor: encodeReadwiseCursor({ stream: position.stream, page: position.page + 1 }),
|
|
344
|
+
hasMore: true,
|
|
345
|
+
}
|
|
346
|
+
: endOfStream(position.stream, items);
|
|
347
|
+
return fitToBudget(whole, position, deps.budgetBytes ?? READWISE_SYNC_RESPONSE_BUDGET_BYTES);
|
|
348
|
+
}
|