@ashdev/codex-plugin-release-tsundoku 1.36.1 → 1.37.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/dist/index.js +11 -11
- package/dist/index.js.map +2 -2
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
//
|
|
3
|
+
// node_modules/@ashdev/codex-plugin-sdk/dist/types/rpc.js
|
|
4
4
|
var JSON_RPC_ERROR_CODES = {
|
|
5
5
|
/** Invalid JSON was received */
|
|
6
6
|
PARSE_ERROR: -32700,
|
|
@@ -14,7 +14,7 @@ var JSON_RPC_ERROR_CODES = {
|
|
|
14
14
|
INTERNAL_ERROR: -32603
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
//
|
|
17
|
+
// node_modules/@ashdev/codex-plugin-sdk/dist/errors.js
|
|
18
18
|
var PluginError = class extends Error {
|
|
19
19
|
data;
|
|
20
20
|
constructor(message, data) {
|
|
@@ -34,7 +34,7 @@ var PluginError = class extends Error {
|
|
|
34
34
|
}
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
-
//
|
|
37
|
+
// node_modules/@ashdev/codex-plugin-sdk/dist/request-context.js
|
|
38
38
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
39
39
|
var store = new AsyncLocalStorage();
|
|
40
40
|
function runWithParentRequestId(forwardRequestId, fn) {
|
|
@@ -44,7 +44,7 @@ function currentParentRequestId() {
|
|
|
44
44
|
return store.getStore();
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
//
|
|
47
|
+
// node_modules/@ashdev/codex-plugin-sdk/dist/host-rpc.js
|
|
48
48
|
var HostRpcError = class extends Error {
|
|
49
49
|
code;
|
|
50
50
|
data;
|
|
@@ -150,7 +150,7 @@ var HostRpcClient = class {
|
|
|
150
150
|
}
|
|
151
151
|
};
|
|
152
152
|
|
|
153
|
-
//
|
|
153
|
+
// node_modules/@ashdev/codex-plugin-sdk/dist/logger.js
|
|
154
154
|
var LOG_LEVELS = {
|
|
155
155
|
debug: 0,
|
|
156
156
|
info: 1,
|
|
@@ -215,10 +215,10 @@ function createLogger(options) {
|
|
|
215
215
|
return new Logger(options);
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
-
//
|
|
218
|
+
// node_modules/@ashdev/codex-plugin-sdk/dist/server.js
|
|
219
219
|
import { createInterface } from "node:readline";
|
|
220
220
|
|
|
221
|
-
//
|
|
221
|
+
// node_modules/@ashdev/codex-plugin-sdk/dist/storage.js
|
|
222
222
|
var StorageError = class extends Error {
|
|
223
223
|
code;
|
|
224
224
|
data;
|
|
@@ -361,7 +361,7 @@ var PluginStorage = class {
|
|
|
361
361
|
}
|
|
362
362
|
};
|
|
363
363
|
|
|
364
|
-
//
|
|
364
|
+
// node_modules/@ashdev/codex-plugin-sdk/dist/server.js
|
|
365
365
|
function validateStringFields(params, fields) {
|
|
366
366
|
if (params === null || params === void 0) {
|
|
367
367
|
return { field: "params", message: "params is required" };
|
|
@@ -554,7 +554,7 @@ function createReleaseSourcePlugin(options) {
|
|
|
554
554
|
createPluginServer({ manifest: manifest2, onInitialize, logLevel, label: "release-source", router });
|
|
555
555
|
}
|
|
556
556
|
|
|
557
|
-
//
|
|
557
|
+
// node_modules/@ashdev/codex-plugin-sdk/dist/types/releases.js
|
|
558
558
|
var RELEASES_METHODS = {
|
|
559
559
|
/** List tracked series, scoped to what the plugin's manifest declared. */
|
|
560
560
|
LIST_TRACKED: "releases/list_tracked",
|
|
@@ -691,7 +691,7 @@ function isFeedResponse(value) {
|
|
|
691
691
|
// package.json
|
|
692
692
|
var package_default = {
|
|
693
693
|
name: "@ashdev/codex-plugin-release-tsundoku",
|
|
694
|
-
version: "1.
|
|
694
|
+
version: "1.37.0",
|
|
695
695
|
description: "Tsundoku release-source plugin for Codex - announces new volume/chapter coverage for tracked series via the Tsundoku incremental series feed, matched by exact external IDs",
|
|
696
696
|
main: "dist/index.js",
|
|
697
697
|
bin: "dist/index.js",
|
|
@@ -730,7 +730,7 @@ var package_default = {
|
|
|
730
730
|
node: ">=22.0.0"
|
|
731
731
|
},
|
|
732
732
|
dependencies: {
|
|
733
|
-
"@ashdev/codex-plugin-sdk": "
|
|
733
|
+
"@ashdev/codex-plugin-sdk": "^1.37.0"
|
|
734
734
|
},
|
|
735
735
|
devDependencies: {
|
|
736
736
|
"@biomejs/biome": "^2.4.4",
|
package/dist/index.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": ["
|
|
4
|
-
"sourcesContent": ["/**\n * JSON-RPC 2.0 types for plugin communication\n */\n\nexport interface JsonRpcRequest {\n jsonrpc: \"2.0\";\n id: string | number | null;\n method: string;\n params?: unknown;\n /**\n * Reverse-RPC only: id of the forward call this plugin is currently\n * servicing. Tells the host to route the reverse-RPC back to the\n * originating caller's task so emitted events land in that caller's\n * recording broadcaster (and replay correctly in distributed\n * deployments). The SDK stamps this automatically via\n * `AsyncLocalStorage` \u2014 plugin authors don't set it.\n */\n parentRequestId?: string | number | null;\n}\n\nexport interface JsonRpcSuccessResponse {\n jsonrpc: \"2.0\";\n id: string | number | null;\n result: unknown;\n}\n\nexport interface JsonRpcErrorResponse {\n jsonrpc: \"2.0\";\n id: string | number | null;\n error: JsonRpcError;\n}\n\nexport interface JsonRpcError {\n code: number;\n message: string;\n data?: unknown;\n}\n\nexport type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse;\n\n/**\n * Standard JSON-RPC error codes\n */\nexport const JSON_RPC_ERROR_CODES = {\n /** Invalid JSON was received */\n PARSE_ERROR: -32700,\n /** The JSON sent is not a valid Request object */\n INVALID_REQUEST: -32600,\n /** The method does not exist / is not available */\n METHOD_NOT_FOUND: -32601,\n /** Invalid method parameter(s) */\n INVALID_PARAMS: -32602,\n /** Internal JSON-RPC error */\n INTERNAL_ERROR: -32603,\n} as const;\n\n/**\n * Plugin-specific error codes (in the -32000 to -32099 range)\n */\nexport const PLUGIN_ERROR_CODES = {\n /** Rate limited by external API */\n RATE_LIMITED: -32001,\n /** Resource not found (e.g., series ID doesn't exist) */\n NOT_FOUND: -32002,\n /** Authentication failed (invalid credentials) */\n AUTH_FAILED: -32003,\n /** External API error */\n API_ERROR: -32004,\n /** Plugin configuration error */\n CONFIG_ERROR: -32005,\n} as const;\n", "/**\n * Plugin error classes for structured error handling\n */\n\nimport { type JsonRpcError, PLUGIN_ERROR_CODES } from \"./types/rpc.js\";\n\n/**\n * Base class for plugin errors that map to JSON-RPC errors\n */\nexport abstract class PluginError extends Error {\n abstract readonly code: number;\n readonly data?: unknown;\n\n constructor(message: string, data?: unknown) {\n super(message);\n this.name = this.constructor.name;\n this.data = data;\n }\n\n /**\n * Convert to JSON-RPC error format\n */\n toJsonRpcError(): JsonRpcError {\n return {\n code: this.code,\n message: this.message,\n data: this.data,\n };\n }\n}\n\n/**\n * Thrown when rate limited by an external API\n */\nexport class RateLimitError extends PluginError {\n readonly code = PLUGIN_ERROR_CODES.RATE_LIMITED;\n /** Seconds to wait before retrying */\n readonly retryAfterSeconds: number;\n\n constructor(retryAfterSeconds: number, message?: string) {\n super(message ?? `Rate limited, retry after ${retryAfterSeconds}s`, {\n retryAfterSeconds,\n });\n this.retryAfterSeconds = retryAfterSeconds;\n }\n}\n\n/**\n * Thrown when a requested resource is not found\n */\nexport class NotFoundError extends PluginError {\n readonly code = PLUGIN_ERROR_CODES.NOT_FOUND;\n}\n\n/**\n * Thrown when authentication fails (invalid credentials)\n */\nexport class AuthError extends PluginError {\n readonly code = PLUGIN_ERROR_CODES.AUTH_FAILED;\n\n constructor(message?: string) {\n super(message ?? \"Authentication failed\");\n }\n}\n\n/**\n * Thrown when an external API returns an error\n */\nexport class ApiError extends PluginError {\n readonly code = PLUGIN_ERROR_CODES.API_ERROR;\n readonly statusCode: number | undefined;\n\n constructor(message: string, statusCode?: number) {\n super(message, statusCode !== undefined ? { statusCode } : undefined);\n this.statusCode = statusCode;\n }\n}\n\n/**\n * Thrown when the plugin is misconfigured\n */\nexport class ConfigError extends PluginError {\n readonly code = PLUGIN_ERROR_CODES.CONFIG_ERROR;\n}\n", "/**\n * Async-local context for the currently-handled forward request.\n *\n * When the SDK dispatches a forward call (e.g. `releases/poll`), it stores\n * the call's `id` in this context for the duration of the handler. Any\n * reverse-RPC the plugin makes while servicing that call (e.g.\n * `releases/record` via `HostRpcClient.call`) reads the id and stamps it as\n * `parentRequestId` on the outgoing request.\n *\n * The host uses `parentRequestId` to route the reverse-RPC back to the\n * originating caller's tokio task, so emitted events land in the recording\n * broadcaster scoped to that task and replay correctly in distributed\n * deployments. Without this stamping, plugins that emit events via\n * reverse-RPC would silently lose them on the worker.\n *\n * Plugin authors don't interact with this directly. The SDK's request\n * dispatch (`server.ts`) sets it; `HostRpcClient.call` reads it.\n */\n\nimport { AsyncLocalStorage } from \"node:async_hooks\";\n\nconst store = new AsyncLocalStorage<string | number | null>();\n\n/**\n * Run `fn` with `forwardRequestId` as the current parent. Calls to\n * `currentParentRequestId()` made inside `fn` (or anything it awaits) will\n * see this value.\n */\nexport function runWithParentRequestId<T>(\n forwardRequestId: string | number | null,\n fn: () => Promise<T>,\n): Promise<T> {\n return store.run(forwardRequestId, fn);\n}\n\n/**\n * Snapshot the current forward request id, or `undefined` if no forward\n * request is on the call stack (e.g. background timers in the plugin that\n * fire reverse-RPCs outside a forward-call context \u2014 those won't be replay-\n * eligible, by design, since they don't belong to any task).\n */\nexport function currentParentRequestId(): string | number | null | undefined {\n return store.getStore();\n}\n", "/**\n * Generic host reverse-RPC client.\n *\n * Plugins use this to call host methods outside the storage namespace \u2014\n * notably `releases/list_tracked`, `releases/record`,\n * `releases/source_state/get`, and `releases/source_state/set`. The class is\n * intentionally generic so future reverse-RPC namespaces can reuse it\n * without a per-namespace client.\n *\n * Wire-format and lifecycle mirror `PluginStorage`: send a JSON-RPC request\n * over stdout with a unique id, and resolve when the host's response with\n * the matching id arrives on stdin. The plugin server's main loop calls\n * `handleResponse(line)` on every incoming response; whichever client owns\n * the id resolves it (others no-op silently).\n *\n * The id counter starts at a high value (`1_000_000_000`) so it can never\n * collide with `PluginStorage`'s sequence (`1, 2, 3, ...`). This means the\n * dispatch in the server doesn't need to know which client a response\n * belongs to \u2014 it can fan out to both, and at most one will match.\n */\n\nimport { currentParentRequestId } from \"./request-context.js\";\nimport type { JsonRpcError, JsonRpcRequest } from \"./types/rpc.js\";\n\n/** Write function signature for sending JSON-RPC requests. */\ntype WriteFn = (line: string) => void;\n\n/**\n * Error thrown when a reverse-RPC call fails (host returned a JSON-RPC error,\n * or the client was canceled).\n */\nexport class HostRpcError extends Error {\n constructor(\n message: string,\n public readonly code: number,\n public readonly data?: unknown,\n ) {\n super(message);\n this.name = \"HostRpcError\";\n }\n}\n\n/**\n * Generic reverse-RPC client. Construct one per plugin instance and pass it\n * around via `InitializeParams`.\n */\nexport class HostRpcClient {\n // Start the counter high so it can't collide with PluginStorage's id space.\n // `Number.MAX_SAFE_INTEGER` is far above this, so we have plenty of room\n // before wrapping (and we never expect a single plugin lifetime to issue\n // more than ~9 quintillion calls).\n private nextId = 1_000_000_000;\n private pendingRequests = new Map<\n number,\n {\n resolve: (value: unknown) => void;\n reject: (error: Error) => void;\n }\n >();\n private writeFn: WriteFn;\n\n /**\n * @param writeFn - Optional custom write function (defaults to\n * `process.stdout.write`). Useful for testing.\n */\n constructor(writeFn?: WriteFn) {\n this.writeFn =\n writeFn ??\n ((line: string) => {\n process.stdout.write(line);\n });\n }\n\n /**\n * Send a JSON-RPC request to the host and resolve with the result.\n *\n * @param method - JSON-RPC method name (e.g. `\"releases/list_tracked\"`).\n * @param params - Method-specific params. Pass `undefined` when the method\n * takes no params.\n */\n async call<T = unknown>(method: string, params?: unknown): Promise<T> {\n const id = this.nextId++;\n // Stamp the forward call we're inside so the host can route this\n // reverse-RPC back to the originating caller's task. Lifted from the\n // `request-context` async-local storage that `server.ts` sets around\n // every forward-request handler.\n const parent = currentParentRequestId();\n const request: JsonRpcRequest = {\n jsonrpc: \"2.0\",\n id,\n method,\n params,\n ...(parent !== undefined ? { parentRequestId: parent } : {}),\n };\n\n return new Promise<T>((resolve, reject) => {\n this.pendingRequests.set(id, {\n resolve: (v) => resolve(v as T),\n reject,\n });\n try {\n this.writeFn(`${JSON.stringify(request)}\\n`);\n } catch (err) {\n this.pendingRequests.delete(id);\n const message = err instanceof Error ? err.message : \"Unknown write error\";\n reject(new HostRpcError(`Failed to send request: ${message}`, -1));\n }\n });\n }\n\n /**\n * Process an incoming JSON-RPC response line. Returns `true` if this\n * client owned the response id and resolved it, `false` otherwise (so\n * other clients can try).\n *\n * Called by the plugin server's main loop on every response.\n */\n handleResponse(line: string): boolean {\n const trimmed = line.trim();\n if (!trimmed) return false;\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(trimmed);\n } catch {\n return false;\n }\n\n const obj = parsed as Record<string, unknown>;\n if (obj.method !== undefined) return false; // not a response\n const rawId = obj.id;\n if (typeof rawId !== \"number\") return false;\n if (!this.pendingRequests.has(rawId)) return false;\n\n const pending = this.pendingRequests.get(rawId);\n if (!pending) return false;\n this.pendingRequests.delete(rawId);\n\n if (\"error\" in obj && obj.error) {\n const err = obj.error as JsonRpcError;\n pending.reject(new HostRpcError(err.message, err.code, err.data));\n } else {\n pending.resolve(obj.result);\n }\n return true;\n }\n\n /** Reject all pending requests (e.g. on shutdown). */\n cancelAll(): void {\n for (const [, pending] of this.pendingRequests) {\n pending.reject(new HostRpcError(\"Host RPC client stopped\", -1));\n }\n this.pendingRequests.clear();\n }\n}\n", "/**\n * Logging utilities for plugins\n *\n * IMPORTANT: Plugins must ONLY write to stderr for logging.\n * stdout is reserved for JSON-RPC communication.\n */\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\n\nconst LOG_LEVELS: Record<LogLevel, number> = {\n debug: 0,\n info: 1,\n warn: 2,\n error: 3,\n};\n\nexport interface LoggerOptions {\n /** Plugin name to prefix log messages */\n name: string;\n /** Minimum log level (default: \"info\") */\n level?: LogLevel;\n /** Whether to include timestamps (default: true) */\n timestamps?: boolean;\n}\n\n/**\n * Logger that writes to stderr (safe for plugins)\n */\nexport class Logger {\n private readonly name: string;\n private readonly minLevel: number;\n private readonly timestamps: boolean;\n\n constructor(options: LoggerOptions) {\n this.name = options.name;\n this.minLevel = LOG_LEVELS[options.level ?? \"info\"];\n this.timestamps = options.timestamps ?? true;\n }\n\n private shouldLog(level: LogLevel): boolean {\n return LOG_LEVELS[level] >= this.minLevel;\n }\n\n private format(level: LogLevel, message: string, data?: unknown): string {\n const parts: string[] = [];\n\n if (this.timestamps) {\n parts.push(new Date().toISOString());\n }\n\n parts.push(`[${level.toUpperCase()}]`);\n parts.push(`[${this.name}]`);\n parts.push(message);\n\n if (data !== undefined) {\n if (data instanceof Error) {\n parts.push(`- ${data.message}`);\n if (data.stack) {\n parts.push(`\\n${data.stack}`);\n }\n } else if (typeof data === \"object\") {\n parts.push(`- ${JSON.stringify(data)}`);\n } else {\n parts.push(`- ${String(data)}`);\n }\n }\n\n return parts.join(\" \");\n }\n\n private log(level: LogLevel, message: string, data?: unknown): void {\n if (this.shouldLog(level)) {\n // Write to stderr (not stdout!) - stdout is for JSON-RPC only\n process.stderr.write(`${this.format(level, message, data)}\\n`);\n }\n }\n\n debug(message: string, data?: unknown): void {\n this.log(\"debug\", message, data);\n }\n\n info(message: string, data?: unknown): void {\n this.log(\"info\", message, data);\n }\n\n warn(message: string, data?: unknown): void {\n this.log(\"warn\", message, data);\n }\n\n error(message: string, data?: unknown): void {\n this.log(\"error\", message, data);\n }\n}\n\n/**\n * Create a logger for a plugin\n */\nexport function createLogger(options: LoggerOptions): Logger {\n return new Logger(options);\n}\n", "/**\n * Plugin server - handles JSON-RPC communication over stdio\n *\n * Provides factory functions for creating different plugin types.\n * All plugin types share a common base server that handles:\n * - stdin readline parsing\n * - JSON-RPC error handling\n * - initialize/ping/shutdown lifecycle methods\n *\n * Each plugin type adds its own method routing on top.\n */\n\nimport { createInterface } from \"node:readline\";\nimport { PluginError } from \"./errors.js\";\nimport { HostRpcClient } from \"./host-rpc.js\";\nimport { createLogger, type Logger } from \"./logger.js\";\nimport { runWithParentRequestId } from \"./request-context.js\";\nimport { PluginStorage } from \"./storage.js\";\nimport type {\n BookMetadataProvider,\n MetadataContentType,\n MetadataProvider,\n RecommendationProvider,\n ReleaseSourceProvider,\n SyncProvider,\n} from \"./types/capabilities.js\";\nimport type { PluginManifest, ReleaseSourceCapability } from \"./types/manifest.js\";\nimport type {\n BookMatchParams,\n BookSearchParams,\n MetadataGetParams,\n MetadataMatchParams,\n MetadataSearchParams,\n} from \"./types/protocol.js\";\nimport type {\n ProfileUpdateRequest,\n RecommendationDismissRequest,\n RecommendationRequest,\n} from \"./types/recommendations.js\";\nimport type { ReleasePollRequest } from \"./types/releases.js\";\nimport { JSON_RPC_ERROR_CODES, type JsonRpcRequest, type JsonRpcResponse } from \"./types/rpc.js\";\nimport type { SyncPullRequest, SyncPushRequest } from \"./types/sync.js\";\n\n// =============================================================================\n// Parameter Validation\n// =============================================================================\n\ninterface ValidationError {\n field: string;\n message: string;\n}\n\n/**\n * Validate that the required string fields are present and non-empty\n */\nfunction validateStringFields(params: unknown, fields: string[]): ValidationError | null {\n if (params === null || params === undefined) {\n return { field: \"params\", message: \"params is required\" };\n }\n if (typeof params !== \"object\") {\n return { field: \"params\", message: \"params must be an object\" };\n }\n\n const obj = params as Record<string, unknown>;\n for (const field of fields) {\n const value = obj[field];\n if (value === undefined || value === null) {\n return { field, message: `${field} is required` };\n }\n if (typeof value !== \"string\") {\n return { field, message: `${field} must be a string` };\n }\n if (value.trim() === \"\") {\n return { field, message: `${field} cannot be empty` };\n }\n }\n\n return null;\n}\n\n/**\n * Validate MetadataSearchParams\n */\nfunction validateSearchParams(params: unknown): ValidationError | null {\n return validateStringFields(params, [\"query\"]);\n}\n\n/**\n * Validate MetadataGetParams\n */\nfunction validateGetParams(params: unknown): ValidationError | null {\n return validateStringFields(params, [\"externalId\"]);\n}\n\n/**\n * Validate MetadataMatchParams\n */\nfunction validateMatchParams(params: unknown): ValidationError | null {\n return validateStringFields(params, [\"title\"]);\n}\n\n/**\n * Validate BookSearchParams - requires either isbn or query\n */\nfunction validateBookSearchParams(params: unknown): ValidationError | null {\n if (params === null || params === undefined) {\n return { field: \"params\", message: \"params is required\" };\n }\n if (typeof params !== \"object\") {\n return { field: \"params\", message: \"params must be an object\" };\n }\n\n const obj = params as Record<string, unknown>;\n const hasIsbn = obj.isbn !== undefined && obj.isbn !== null && obj.isbn !== \"\";\n const hasQuery = obj.query !== undefined && obj.query !== null && obj.query !== \"\";\n\n if (!hasIsbn && !hasQuery) {\n return { field: \"isbn/query\", message: \"either isbn or query is required\" };\n }\n\n return null;\n}\n\n/**\n * Validate BookMatchParams\n */\nfunction validateBookMatchParams(params: unknown): ValidationError | null {\n return validateStringFields(params, [\"title\"]);\n}\n\n/**\n * Create an INVALID_PARAMS error response\n */\nfunction invalidParamsError(id: string | number | null, error: ValidationError): JsonRpcResponse {\n return {\n jsonrpc: \"2.0\",\n id,\n error: {\n code: JSON_RPC_ERROR_CODES.INVALID_PARAMS,\n message: `Invalid params: ${error.message}`,\n data: { field: error.field },\n },\n };\n}\n\n// =============================================================================\n// Shared Types\n// =============================================================================\n\n/**\n * Initialize parameters received from Codex\n */\nexport interface InitializeParams {\n /** Admin-level plugin configuration (from plugin settings) */\n adminConfig?: Record<string, unknown>;\n /** Per-user plugin configuration (from user plugin settings) */\n userConfig?: Record<string, unknown>;\n /** Plugin credentials (API keys, tokens, etc.) */\n credentials?: Record<string, string>;\n /**\n * Scoped, writable data directory for this plugin's file storage.\n *\n * The host creates it at `{plugins_dir}/{plugin_name}/` and passes the\n * absolute path here. Unlike {@link storage} (a small DB-backed key-value\n * store with per-connection quotas), this is a real filesystem directory\n * with no quotas \u2014 use it for larger file-based storage (SQLite databases,\n * caches, debug dumps). Absent if the host has no plugin file storage\n * configured.\n */\n dataDir?: string;\n /**\n * Stable identifier of the Codex user this plugin instance is acting for.\n *\n * Sent for user-plugin spawns (sync / recommendation), absent for system\n * plugins. Because a credential-less or shared-key plugin can't derive the\n * user's identity from its credentials, use this (an opaque UUID) to scope\n * data per user in the plugin's own backend.\n */\n userId?: string;\n /**\n * Stable identifier of this user-plugin connection \u2014 the same scope the host\n * uses for {@link storage}. Sent for user-plugin spawns, absent for system\n * plugins. Opaque UUID. Use it when \"this connection\" (rather than the human)\n * is the right granularity.\n */\n userPluginId?: string;\n /**\n * Per-user key-value storage client.\n *\n * Use this to persist data across plugin restarts (e.g., dismissed IDs,\n * cached profiles, user preferences). Storage is scoped per user-plugin\n * instance \u2014 the host resolves the user context automatically.\n */\n storage: PluginStorage;\n /**\n * Generic host reverse-RPC client.\n *\n * Use this to call host methods outside the storage namespace, notably\n * the `releases/*` methods (`releases/list_tracked`, `releases/record`,\n * `releases/source_state/get`, `releases/source_state/set`) for plugins\n * declaring the `releaseSource` capability.\n */\n hostRpc: HostRpcClient;\n}\n\n/**\n * A method router handles capability-specific JSON-RPC methods.\n * Returns a response for known methods, or null to indicate \"not my method\".\n */\ntype MethodRouter = (\n method: string,\n params: unknown,\n id: string | number | null,\n) => Promise<JsonRpcResponse | null>;\n\n// =============================================================================\n// Shared Plugin Server\n// =============================================================================\n\ninterface PluginServerOptions {\n manifest: PluginManifest;\n onInitialize?: ((params: InitializeParams) => void | Promise<void>) | undefined;\n logLevel?: \"debug\" | \"info\" | \"warn\" | \"error\" | undefined;\n label?: string | undefined;\n router: MethodRouter;\n}\n\n/**\n * Shared plugin server that handles JSON-RPC communication over stdio.\n *\n * Handles the common lifecycle methods (initialize, ping, shutdown) and\n * delegates capability-specific methods to the provided router.\n */\nfunction createPluginServer(options: PluginServerOptions): void {\n const { manifest, onInitialize, logLevel = \"info\", label, router } = options;\n const logger = createLogger({ name: manifest.name, level: logLevel });\n const prefix = label ? `${label} plugin` : \"plugin\";\n const storage = new PluginStorage();\n const hostRpc = new HostRpcClient();\n\n logger.info(`Starting ${prefix}: ${manifest.displayName} v${manifest.version}`);\n\n const rl = createInterface({\n input: process.stdin,\n terminal: false,\n });\n\n rl.on(\"line\", (line) => {\n void handleLine(line, manifest, onInitialize, router, logger, storage, hostRpc);\n });\n\n rl.on(\"close\", () => {\n logger.info(\"stdin closed, shutting down\");\n storage.cancelAll();\n hostRpc.cancelAll();\n process.exit(0);\n });\n\n process.on(\"uncaughtException\", (error) => {\n logger.error(\"Uncaught exception\", error);\n process.exit(1);\n });\n\n process.on(\"unhandledRejection\", (reason) => {\n logger.error(\"Unhandled rejection\", reason);\n });\n}\n\n/**\n * Detect whether a parsed JSON object is a JSON-RPC response (not a request).\n *\n * A response has `id` and either `result` or `error`, but no `method`.\n * A request always has `method`.\n */\nfunction isJsonRpcResponse(obj: Record<string, unknown>): boolean {\n if (obj.method !== undefined) return false;\n if (obj.id === undefined || obj.id === null) return false;\n return \"result\" in obj || \"error\" in obj;\n}\n\nasync function handleLine(\n line: string,\n manifest: PluginManifest,\n onInitialize: ((params: InitializeParams) => void | Promise<void>) | undefined,\n router: MethodRouter,\n logger: Logger,\n storage: PluginStorage,\n hostRpc: HostRpcClient,\n): Promise<void> {\n const trimmed = line.trim();\n if (!trimmed) return;\n\n // Try to detect responses (storage or host-rpc) before full request handling.\n // Both come from the host on stdin \u2014 they have id + (result|error) but no\n // method field. The two clients use disjoint id ranges so each can claim\n // ownership without coordination; whichever owns the id resolves it.\n let parsed: Record<string, unknown> | undefined;\n try {\n parsed = JSON.parse(trimmed) as Record<string, unknown>;\n } catch {\n // Will be handled as a parse error below\n }\n\n if (parsed && isJsonRpcResponse(parsed)) {\n logger.debug(\"Routing reverse-RPC response\", { id: parsed.id });\n if (!hostRpc.handleResponse(trimmed)) {\n storage.handleResponse(trimmed);\n }\n return;\n }\n\n let id: string | number | null = null;\n\n try {\n const request = (parsed ?? JSON.parse(trimmed)) as JsonRpcRequest;\n id = request.id;\n\n logger.debug(`Received request: ${request.method}`, { id: request.id });\n\n // Run the request handler inside the parent-request async-local context.\n // Reverse-RPCs the handler issues via `HostRpcClient.call` will read this\n // and stamp `parentRequestId` so the host can route the call back to the\n // originating task. See `request-context.ts`.\n const response = await runWithParentRequestId(request.id, () =>\n handleRequest(request, manifest, onInitialize, router, logger, storage, hostRpc),\n );\n if (response !== null) {\n writeResponse(response);\n }\n } catch (error) {\n if (error instanceof SyntaxError) {\n writeResponse({\n jsonrpc: \"2.0\",\n id: null,\n error: {\n code: JSON_RPC_ERROR_CODES.PARSE_ERROR,\n message: \"Parse error: invalid JSON\",\n },\n });\n } else if (error instanceof PluginError) {\n writeResponse({\n jsonrpc: \"2.0\",\n id,\n error: error.toJsonRpcError(),\n });\n } else {\n const message = error instanceof Error ? error.message : \"Unknown error\";\n logger.error(\"Request failed\", error);\n writeResponse({\n jsonrpc: \"2.0\",\n id,\n error: {\n code: JSON_RPC_ERROR_CODES.INTERNAL_ERROR,\n message,\n },\n });\n }\n }\n}\n\nasync function handleRequest(\n request: JsonRpcRequest,\n manifest: PluginManifest,\n onInitialize: ((params: InitializeParams) => void | Promise<void>) | undefined,\n router: MethodRouter,\n logger: Logger,\n storage: PluginStorage,\n hostRpc: HostRpcClient,\n): Promise<JsonRpcResponse | null> {\n const { method, params, id } = request;\n\n // Common lifecycle methods\n switch (method) {\n case \"initialize\": {\n const initParams = (params ?? {}) as InitializeParams;\n // Inject the reverse-RPC clients so plugins can persist data and\n // call host-side methods (e.g. releases/list_tracked).\n initParams.storage = storage;\n initParams.hostRpc = hostRpc;\n if (onInitialize) {\n await onInitialize(initParams);\n }\n return { jsonrpc: \"2.0\", id, result: manifest };\n }\n\n case \"ping\":\n return { jsonrpc: \"2.0\", id, result: \"pong\" };\n\n case \"shutdown\": {\n logger.info(\"Shutdown requested\");\n storage.cancelAll();\n hostRpc.cancelAll();\n const response: JsonRpcResponse = { jsonrpc: \"2.0\", id, result: null };\n process.stdout.write(`${JSON.stringify(response)}\\n`, () => {\n process.exit(0);\n });\n // Response already written above; return null so handleLine skips the write\n return null;\n }\n }\n\n // Delegate to capability-specific router\n const response = await router(method, params, id);\n if (response !== null) {\n return response;\n }\n\n // Unknown method\n return {\n jsonrpc: \"2.0\",\n id,\n error: {\n code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND,\n message: `Method not found: ${method}`,\n },\n };\n}\n\nfunction writeResponse(response: JsonRpcResponse): void {\n process.stdout.write(`${JSON.stringify(response)}\\n`);\n}\n\n// =============================================================================\n// Response Helpers\n// =============================================================================\n\nfunction methodNotFound(id: string | number | null, message: string): JsonRpcResponse {\n return {\n jsonrpc: \"2.0\",\n id,\n error: {\n code: JSON_RPC_ERROR_CODES.METHOD_NOT_FOUND,\n message,\n },\n };\n}\n\nfunction success(id: string | number | null, result: unknown): JsonRpcResponse {\n return { jsonrpc: \"2.0\", id, result };\n}\n\n// =============================================================================\n// Metadata Plugin\n// =============================================================================\n\n/**\n * Options for creating a metadata plugin\n */\nexport interface MetadataPluginOptions {\n /** Plugin manifest - must have capabilities.metadataProvider with content types */\n manifest: PluginManifest & {\n capabilities: { metadataProvider: MetadataContentType[] };\n };\n /** Series MetadataProvider implementation (required if \"series\" in metadataProvider) */\n provider?: MetadataProvider;\n /** Book MetadataProvider implementation (required if \"book\" in metadataProvider) */\n bookProvider?: BookMetadataProvider;\n /** Called when plugin receives initialize with credentials/config */\n onInitialize?: (params: InitializeParams) => void | Promise<void>;\n /** Log level (default: \"info\") */\n logLevel?: \"debug\" | \"info\" | \"warn\" | \"error\";\n}\n\n/**\n * Create and run a metadata provider plugin\n *\n * Creates a plugin server that handles JSON-RPC communication over stdio.\n * The TypeScript compiler will ensure you implement all required methods.\n *\n * @example\n * ```typescript\n * import { createMetadataPlugin, type MetadataProvider } from \"@ashdev/codex-plugin-sdk\";\n *\n * const provider: MetadataProvider = {\n * async search(params) {\n * return {\n * results: [{\n * externalId: \"123\",\n * title: \"Example\",\n * alternateTitles: [],\n * relevanceScore: 0.95,\n * }],\n * };\n * },\n * async get(params) {\n * return {\n * externalId: params.externalId,\n * externalUrl: \"https://example.com/123\",\n * alternateTitles: [],\n * genres: [],\n * tags: [],\n * authors: [],\n * artists: [],\n * externalLinks: [],\n * };\n * },\n * };\n *\n * createMetadataPlugin({\n * manifest: {\n * name: \"my-plugin\",\n * displayName: \"My Plugin\",\n * version: \"1.0.0\",\n * description: \"Example plugin\",\n * author: \"Me\",\n * protocolVersion: \"1.0\",\n * capabilities: { metadataProvider: [\"series\"] },\n * },\n * provider,\n * });\n * ```\n */\nexport function createMetadataPlugin(options: MetadataPluginOptions): void {\n const { manifest, provider, bookProvider, onInitialize, logLevel } = options;\n\n // Validate that required providers are present based on manifest\n const contentTypes = manifest.capabilities.metadataProvider;\n if (contentTypes.includes(\"series\") && !provider) {\n throw new Error(\n \"Series metadata provider is required when 'series' is in metadataProvider capabilities\",\n );\n }\n if (contentTypes.includes(\"book\") && !bookProvider) {\n throw new Error(\n \"Book metadata provider is required when 'book' is in metadataProvider capabilities\",\n );\n }\n\n const router: MethodRouter = async (method, params, id) => {\n switch (method) {\n // Series metadata methods\n case \"metadata/series/search\": {\n if (!provider) return methodNotFound(id, \"This plugin does not support series metadata\");\n const err = validateSearchParams(params);\n if (err) return invalidParamsError(id, err);\n return success(id, await provider.search(params as MetadataSearchParams));\n }\n case \"metadata/series/get\": {\n if (!provider) return methodNotFound(id, \"This plugin does not support series metadata\");\n const err = validateGetParams(params);\n if (err) return invalidParamsError(id, err);\n return success(id, await provider.get(params as MetadataGetParams));\n }\n case \"metadata/series/match\": {\n if (!provider) return methodNotFound(id, \"This plugin does not support series metadata\");\n if (!provider.match) return methodNotFound(id, \"This plugin does not support series match\");\n const err = validateMatchParams(params);\n if (err) return invalidParamsError(id, err);\n return success(id, await provider.match(params as MetadataMatchParams));\n }\n\n // Book metadata methods\n case \"metadata/book/search\": {\n if (!bookProvider) return methodNotFound(id, \"This plugin does not support book metadata\");\n const err = validateBookSearchParams(params);\n if (err) return invalidParamsError(id, err);\n return success(id, await bookProvider.search(params as BookSearchParams));\n }\n case \"metadata/book/get\": {\n if (!bookProvider) return methodNotFound(id, \"This plugin does not support book metadata\");\n const err = validateGetParams(params);\n if (err) return invalidParamsError(id, err);\n return success(id, await bookProvider.get(params as MetadataGetParams));\n }\n case \"metadata/book/match\": {\n if (!bookProvider) return methodNotFound(id, \"This plugin does not support book metadata\");\n if (!bookProvider.match)\n return methodNotFound(id, \"This plugin does not support book match\");\n const err = validateBookMatchParams(params);\n if (err) return invalidParamsError(id, err);\n return success(id, await bookProvider.match(params as BookMatchParams));\n }\n\n default:\n return null;\n }\n };\n\n createPluginServer({ manifest, onInitialize, logLevel, router });\n}\n\n// =============================================================================\n// Sync Plugin\n// =============================================================================\n\n/**\n * Options for creating a sync provider plugin\n */\nexport interface SyncPluginOptions {\n /** Plugin manifest - must have capabilities.userReadSync: true */\n manifest: PluginManifest & {\n capabilities: { userReadSync: true };\n };\n /** SyncProvider implementation */\n provider: SyncProvider;\n /** Called when plugin receives initialize with credentials/config */\n onInitialize?: (params: InitializeParams) => void | Promise<void>;\n /** Log level (default: \"info\") */\n logLevel?: \"debug\" | \"info\" | \"warn\" | \"error\";\n}\n\n/**\n * Create and run a sync provider plugin\n *\n * Creates a plugin server that handles JSON-RPC communication over stdio\n * for sync operations (push/pull reading progress with external services).\n *\n * @example\n * ```typescript\n * import { createSyncPlugin, type SyncProvider } from \"@ashdev/codex-plugin-sdk\";\n *\n * const provider: SyncProvider = {\n * async getUserInfo() {\n * return { externalId: \"123\", username: \"user\" };\n * },\n * async pushProgress(params) {\n * return { success: [], failed: [] };\n * },\n * async pullProgress(params) {\n * return { entries: [], hasMore: false };\n * },\n * };\n *\n * createSyncPlugin({\n * manifest: {\n * name: \"my-sync-plugin\",\n * displayName: \"My Sync Plugin\",\n * version: \"1.0.0\",\n * description: \"Syncs reading progress\",\n * author: \"Me\",\n * protocolVersion: \"1.0\",\n * capabilities: { userReadSync: true },\n * },\n * provider,\n * });\n * ```\n */\nexport function createSyncPlugin(options: SyncPluginOptions): void {\n const { manifest, provider, onInitialize, logLevel } = options;\n\n const router: MethodRouter = async (method, params, id) => {\n switch (method) {\n case \"sync/getUserInfo\":\n return success(id, await provider.getUserInfo());\n case \"sync/pushProgress\":\n return success(id, await provider.pushProgress(params as SyncPushRequest));\n case \"sync/pullProgress\":\n return success(id, await provider.pullProgress(params as SyncPullRequest));\n case \"sync/status\": {\n if (!provider.status) return methodNotFound(id, \"This plugin does not support sync/status\");\n return success(id, await provider.status());\n }\n default:\n return null;\n }\n };\n\n createPluginServer({ manifest, onInitialize, logLevel, label: \"sync\", router });\n}\n\n// =============================================================================\n// Recommendation Plugin\n// =============================================================================\n\n/**\n * Options for creating a recommendation provider plugin\n */\nexport interface RecommendationPluginOptions {\n /** Plugin manifest - must have capabilities.userRecommendationProvider: true */\n manifest: PluginManifest & {\n capabilities: { userRecommendationProvider: true };\n };\n /** RecommendationProvider implementation */\n provider: RecommendationProvider;\n /** Called when plugin receives initialize with credentials/config */\n onInitialize?: (params: InitializeParams) => void | Promise<void>;\n /** Log level (default: \"info\") */\n logLevel?: \"debug\" | \"info\" | \"warn\" | \"error\";\n}\n\n/**\n * Create and run a recommendation provider plugin\n *\n * Creates a plugin server that handles JSON-RPC communication over stdio\n * for recommendation operations (get recommendations, update profile, dismiss).\n */\nexport function createRecommendationPlugin(options: RecommendationPluginOptions): void {\n const { manifest, provider, onInitialize, logLevel } = options;\n\n const router: MethodRouter = async (method, params, id) => {\n switch (method) {\n case \"recommendations/get\":\n return success(id, await provider.get(params as RecommendationRequest));\n case \"recommendations/updateProfile\": {\n if (!provider.updateProfile)\n return methodNotFound(id, \"This plugin does not support recommendations/updateProfile\");\n return success(id, await provider.updateProfile(params as ProfileUpdateRequest));\n }\n case \"recommendations/clear\": {\n if (!provider.clear)\n return methodNotFound(id, \"This plugin does not support recommendations/clear\");\n return success(id, await provider.clear());\n }\n case \"recommendations/dismiss\": {\n if (!provider.dismiss)\n return methodNotFound(id, \"This plugin does not support recommendations/dismiss\");\n const err = validateStringFields(params, [\"externalId\"]);\n if (err) return invalidParamsError(id, err);\n return success(id, await provider.dismiss(params as RecommendationDismissRequest));\n }\n default:\n return null;\n }\n };\n\n createPluginServer({ manifest, onInitialize, logLevel, label: \"recommendation\", router });\n}\n\n// =============================================================================\n// Release Source Plugin\n// =============================================================================\n\n/**\n * Validate `releases/poll` parameters. Requires a non-empty `sourceId` string;\n * `etag` is optional.\n */\nfunction validateReleasePollParams(params: unknown): ValidationError | null {\n return validateStringFields(params, [\"sourceId\"]);\n}\n\n/**\n * Options for creating a release-source plugin.\n */\nexport interface ReleaseSourcePluginOptions {\n /** Plugin manifest. Must declare `capabilities.releaseSource`. */\n manifest: PluginManifest & {\n capabilities: { releaseSource: ReleaseSourceCapability };\n };\n /** ReleaseSourceProvider implementation. */\n provider: ReleaseSourceProvider;\n /** Called when plugin receives initialize with credentials/config. */\n onInitialize?: (params: InitializeParams) => void | Promise<void>;\n /** Log level (default: \"info\"). */\n logLevel?: \"debug\" | \"info\" | \"warn\" | \"error\";\n}\n\n/**\n * Create and run a release-source plugin.\n *\n * The host calls `releases/poll` on a schedule (per `release_sources` row).\n * The plugin returns candidates either inline (in the poll response) or by\n * streaming `releases/record` reverse-RPC calls during the poll. Both styles\n * are supported by the host.\n *\n * Plugins typically:\n * 1. Fetch tracked series via `releases/list_tracked`.\n * 2. For each series, GET the upstream feed (with `If-None-Match` from the\n * previous ETag).\n * 3. Parse + filter (language, group blocklist, etc.).\n * 4. Either return all candidates in the poll response or call\n * `releases/record` for each.\n * 5. Persist the new ETag via `releases/source_state/set` (or include it on\n * the poll response).\n *\n * @example\n * ```typescript\n * import { createReleaseSourcePlugin, type ReleaseSourceProvider } from \"@ashdev/codex-plugin-sdk\";\n *\n * const provider: ReleaseSourceProvider = {\n * async poll({ sourceId, etag }) {\n * // ...fetch + parse...\n * return { candidates: [...], etag: \"new-etag\" };\n * },\n * };\n *\n * createReleaseSourcePlugin({ manifest, provider });\n * ```\n */\nexport function createReleaseSourcePlugin(options: ReleaseSourcePluginOptions): void {\n const { manifest, provider, onInitialize, logLevel } = options;\n\n if (!manifest.capabilities.releaseSource) {\n throw new Error(\n \"manifest.capabilities.releaseSource is required for createReleaseSourcePlugin\",\n );\n }\n\n const router: MethodRouter = async (method, params, id) => {\n switch (method) {\n case \"releases/poll\": {\n const err = validateReleasePollParams(params);\n if (err) return invalidParamsError(id, err);\n return success(id, await provider.poll(params as ReleasePollRequest));\n }\n default:\n return null;\n }\n };\n\n createPluginServer({ manifest, onInitialize, logLevel, label: \"release-source\", router });\n}\n", "/**\n * Plugin Storage - key-value storage for per-user plugin data\n *\n * Storage is scoped per user-plugin instance. Plugins only specify a key;\n * the host resolves the user context from the connection.\n *\n * Plugins send storage requests as JSON-RPC calls to the host over stdout\n * and receive responses on stdin. This is the reverse of the normal\n * host-to-plugin request flow.\n *\n * @example\n * ```typescript\n * import { PluginStorage } from \"@ashdev/codex-plugin-sdk\";\n *\n * const storage = new PluginStorage();\n *\n * // Store data\n * await storage.set(\"taste_profile\", { genres: [\"action\", \"drama\"] });\n *\n * // Retrieve data\n * const data = await storage.get(\"taste_profile\");\n *\n * // Store with TTL (expires in 24 hours)\n * const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();\n * await storage.set(\"cache\", { items: [1, 2, 3] }, expires);\n *\n * // List all keys\n * const keys = await storage.list();\n *\n * // Delete a key\n * await storage.delete(\"cache\");\n *\n * // Clear all data\n * await storage.clear();\n * ```\n */\n\nimport type { JsonRpcError, JsonRpcRequest } from \"./types/rpc.js\";\n\n// =============================================================================\n// Storage Types\n// =============================================================================\n\n/** Response from storage/get */\nexport interface StorageGetResponse {\n /** The stored data, or null if key doesn't exist */\n data: unknown | null;\n /** Expiration timestamp (ISO 8601) if TTL was set */\n expiresAt?: string;\n}\n\n/** Response from storage/set */\nexport interface StorageSetResponse {\n /** Always true on success */\n success: boolean;\n}\n\n/** Response from storage/delete */\nexport interface StorageDeleteResponse {\n /** Whether the key existed and was deleted */\n deleted: boolean;\n}\n\n/** Individual key entry from storage/list */\nexport interface StorageKeyEntry {\n /** Storage key name */\n key: string;\n /** Expiration timestamp (ISO 8601) if TTL was set */\n expiresAt?: string;\n /** Last update timestamp (ISO 8601) */\n updatedAt: string;\n}\n\n/** Response from storage/list */\nexport interface StorageListResponse {\n /** All keys for this plugin instance (excluding expired) */\n keys: StorageKeyEntry[];\n}\n\n/** Response from storage/clear */\nexport interface StorageClearResponse {\n /** Number of entries deleted */\n deletedCount: number;\n}\n\n// =============================================================================\n// Storage Error\n// =============================================================================\n\n/** Error from a storage operation */\nexport class StorageError extends Error {\n constructor(\n message: string,\n public readonly code: number,\n public readonly data?: unknown,\n ) {\n super(message);\n this.name = \"StorageError\";\n }\n}\n\n// =============================================================================\n// Plugin Storage Client\n// =============================================================================\n\n/** Write function signature for sending JSON-RPC requests */\ntype WriteFn = (line: string) => void;\n\n/**\n * Client for plugin key-value storage.\n *\n * Sends JSON-RPC requests to the host process over stdout and reads\n * responses on stdin. Each request gets a unique ID so responses can\n * be correlated even if they arrive out of order.\n */\nexport class PluginStorage {\n private nextId = 1;\n private pendingRequests = new Map<\n string | number,\n {\n resolve: (value: unknown) => void;\n reject: (error: Error) => void;\n }\n >();\n private writeFn: WriteFn;\n\n /**\n * Create a new storage client.\n *\n * @param writeFn - Optional custom write function (defaults to process.stdout.write).\n * Useful for testing or custom transport layers.\n */\n constructor(writeFn?: WriteFn) {\n this.writeFn =\n writeFn ??\n ((line: string) => {\n process.stdout.write(line);\n });\n }\n\n /**\n * Get a value by key\n *\n * @param key - Storage key to retrieve\n * @returns The stored data and optional expiration, or null data if key doesn't exist\n */\n async get(key: string): Promise<StorageGetResponse> {\n return (await this.sendRequest(\"storage/get\", { key })) as StorageGetResponse;\n }\n\n /**\n * Set a value by key (upsert - creates or updates)\n *\n * @param key - Storage key\n * @param data - JSON-serializable data to store\n * @param expiresAt - Optional expiration timestamp (ISO 8601)\n * @returns Success indicator\n */\n async set(key: string, data: unknown, expiresAt?: string): Promise<StorageSetResponse> {\n const params: Record<string, unknown> = { key, data };\n if (expiresAt !== undefined) {\n params.expiresAt = expiresAt;\n }\n return (await this.sendRequest(\"storage/set\", params)) as StorageSetResponse;\n }\n\n /**\n * Delete a value by key\n *\n * @param key - Storage key to delete\n * @returns Whether the key existed and was deleted\n */\n async delete(key: string): Promise<StorageDeleteResponse> {\n return (await this.sendRequest(\"storage/delete\", { key })) as StorageDeleteResponse;\n }\n\n /**\n * List all keys for this plugin instance (excluding expired)\n *\n * @returns List of key entries with metadata\n */\n async list(): Promise<StorageListResponse> {\n return (await this.sendRequest(\"storage/list\", {})) as StorageListResponse;\n }\n\n /**\n * Clear all data for this plugin instance\n *\n * @returns Number of entries deleted\n */\n async clear(): Promise<StorageClearResponse> {\n return (await this.sendRequest(\"storage/clear\", {})) as StorageClearResponse;\n }\n\n /**\n * Handle an incoming JSON-RPC response line from the host.\n *\n * Call this method from your readline handler to deliver responses\n * back to pending storage requests.\n */\n handleResponse(line: string): void {\n const trimmed = line.trim();\n if (!trimmed) return;\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(trimmed);\n } catch {\n // Not JSON - ignore\n return;\n }\n\n const obj = parsed as Record<string, unknown>;\n\n // Only handle responses (have \"result\" or \"error\", no \"method\")\n if (obj.method !== undefined) {\n // This is a host-to-plugin request, not a storage response - ignore\n return;\n }\n\n const id = obj.id;\n if (id === undefined || id === null) return;\n\n const pending = this.pendingRequests.get(id as string | number);\n if (!pending) return;\n\n this.pendingRequests.delete(id as string | number);\n\n if (\"error\" in obj && obj.error) {\n const err = obj.error as JsonRpcError;\n pending.reject(new StorageError(err.message, err.code, err.data));\n } else {\n pending.resolve(obj.result);\n }\n }\n\n /**\n * Cancel all pending requests (e.g. on shutdown).\n */\n cancelAll(): void {\n for (const [, pending] of this.pendingRequests) {\n pending.reject(new StorageError(\"Storage client stopped\", -1));\n }\n this.pendingRequests.clear();\n }\n\n // ===========================================================================\n // Internal\n // ===========================================================================\n\n private sendRequest(method: string, params: unknown): Promise<unknown> {\n const id = this.nextId++;\n\n const request: JsonRpcRequest = {\n jsonrpc: \"2.0\",\n id,\n method,\n params,\n };\n\n return new Promise((resolve, reject) => {\n this.pendingRequests.set(id, { resolve, reject });\n\n try {\n this.writeFn(`${JSON.stringify(request)}\\n`);\n } catch (err) {\n this.pendingRequests.delete(id);\n const message = err instanceof Error ? err.message : \"Unknown write error\";\n reject(new StorageError(`Failed to send request: ${message}`, -1));\n }\n });\n }\n}\n", "/**\n * Release-source protocol types - MUST match the Rust protocol exactly.\n *\n * Plugins implementing the `release_source` capability poll external sources\n * for new chapter/volume releases and emit `ReleaseCandidate` rows. The host\n * threshold-gates and dedups them through the `release_ledger` table.\n *\n * @see src/services/plugin/protocol.rs (`ReleasePollRequest`, `ReleasePollResponse`)\n * @see src/services/release/candidate.rs (`ReleaseCandidate`, `SeriesMatch`)\n * @see src/services/plugin/releases_handler.rs (reverse-RPC handlers)\n */\n\n// =============================================================================\n// Reverse-RPC method names (plugin -> host)\n// =============================================================================\n\n/**\n * Method names for the `releases/*` reverse-RPC namespace. Plugins call these\n * over the open RPC channel during `releases/poll` (or any other time).\n */\nexport const RELEASES_METHODS = {\n /** List tracked series, scoped to what the plugin's manifest declared. */\n LIST_TRACKED: \"releases/list_tracked\",\n /**\n * Count tracked series scoped to the plugin's `requiresExternalIds`.\n *\n * Plugins call this once at the start of a poll to learn the total\n * denominator before iterating, so subsequent `REPORT_PROGRESS` calls\n * carry a stable `current/total` ratio. Cheap (one batched DB lookup);\n * safe to call from `poll`.\n */\n COUNT_TRACKED: \"releases/count_tracked\",\n /**\n * Report intra-poll progress to the host. The host translates this into\n * a `TaskProgressEvent` on the active task's broadcaster; the inbox\n * progress bar updates live. Best-effort \u2014 calls outside an active\n * task scope are silently dropped, and rapid back-to-back calls are\n * rate-limited (~10/sec) by the host. Plugins SHOULD call this after\n * each unit of work (e.g. after each polled series) with `current` set\n * to the count of completed units and `total` from `COUNT_TRACKED`.\n */\n REPORT_PROGRESS: \"releases/report_progress\",\n /** Submit a candidate to the host's release ledger. */\n RECORD: \"releases/record\",\n /** Get persisted per-source state (etag, last_polled_at, last_error). */\n SOURCE_STATE_GET: \"releases/source_state/get\",\n /** Set persisted per-source state (etag only \u2014 other fields are host-owned). */\n SOURCE_STATE_SET: \"releases/source_state/set\",\n /**\n * Replace the set of `release_sources` rows owned by this plugin.\n *\n * Plugins call this from `onInitialize` (and after any config change, which\n * triggers a process restart that re-runs `onInitialize`). Each call carries\n * the plugin's full desired-state list; the host upserts every entry on\n * `(plugin_id, source_key)` and prunes rows whose `source_key` is not in\n * the request. User-managed fields (`enabled`, `pollIntervalS`) are\n * preserved across re-registrations so an admin's overrides aren't\n * trampled by a plugin restart.\n */\n REGISTER_SOURCES: \"releases/register_sources\",\n} as const;\n\n// =============================================================================\n// ReleaseCandidate (the wire shape plugins emit)\n// =============================================================================\n\n/**\n * Per-series match metadata attached to every candidate.\n *\n * - `codexSeriesId` is the host's UUID for the series. Plugins resolve this\n * from `releases/list_tracked` (don't invent series IDs).\n * - `confidence` (0.0..=1.0) tells the host how sure the plugin is about the\n * match. The host drops below-threshold candidates (default 0.7).\n * - `reason` is a short opaque string used for debugging/UI, e.g.\n * `\"mangaupdates_id\"`, `\"alias-exact\"`, `\"alias-fuzzy\"`.\n */\nexport interface SeriesMatch {\n codexSeriesId: string;\n confidence: number;\n reason: string;\n}\n\n/**\n * Inclusive numeric span. Single values are encoded as `start === end`\n * (`{ start: 5, end: 5 }`). Used on the volume and chapter axes of a\n * release candidate to express compilation/bundle coverage honestly,\n * including disjoint coverage (`v01-04 + v06-09` \u2192 two spans).\n */\nexport interface NumericSpan {\n start: number;\n end: number;\n}\n\n/**\n * Release candidate emitted by a plugin.\n *\n * **Field semantics:**\n * - `externalReleaseId`: Stable per-source ID. The first dedup key.\n * `(sourceId, externalReleaseId)` is `UNIQUE` in `release_ledger`.\n * - `volumes` / `chapters`: At least one should be non-null. Each is a\n * normalized [`NumericSpan`] list (sorted ascending, overlapping spans\n * merged) describing every volume / chapter the release covers. Single\n * values are one-element lists with `start === end`; ranges are\n * one-element lists with `end > start`; disjoint coverage produces\n * multiple spans. Decimals supported on chapter spans.\n * - `language`: ISO 639-1 code, lowercase. Must be non-empty. The host's\n * `latest_known_*` advance gate uses this against the per-series\n * effective language list.\n * - `groupOrUploader`: Scanlation group (MangaUpdates) or torrent uploader\n * handle (Nyaa). Optional but strongly recommended.\n * - `payloadUrl`: The link the user follows to actually consume / acquire\n * the release. Must be non-empty. Conventionally a human-readable landing\n * page (Nyaa view page, MangaUpdates release page).\n * - `mediaUrl` / `mediaUrlKind`: Optional second URL describing how to\n * actually fetch the bits \u2014 a `.torrent` file, a magnet link, or a direct\n * download. Set both together; leave both unset for sources that only\n * surface a landing page. The UI renders a kind-specific icon next to\n * the standard external-link icon when these are present.\n * - `infoHash`: Torrent info_hash if applicable. Cross-source dedup key.\n * - `metadata` / `formatHints`: Free-form JSON for plugin-specific data\n * (Nyaa size in bytes, MangaUpdates \"is volume bundle\" flag, etc.).\n * - `observedAt`: When the plugin detected this entry (conventionally \"now\"\n * at poll time). Used for ordering; bounded by `MAX_FUTURE_SKEW_S` (1h) on\n * the host side.\n * - `releasedAt`: The upstream publish date for the release (e.g. the RSS\n * `<pubDate>`). Optional \u2014 set to `null` when the source carried no usable\n * date. NOT skew-checked, since a publish date can legitimately be old.\n */\nexport interface ReleaseCandidate {\n seriesMatch: SeriesMatch;\n externalReleaseId: string;\n /** Volume coverage. Integer span list. `null` when no volume info. */\n volumes?: NumericSpan[] | null;\n /** Chapter coverage. Decimal-capable span list. `null` when no chapter info. */\n chapters?: NumericSpan[] | null;\n language: string;\n formatHints?: Record<string, unknown> | null;\n groupOrUploader?: string | null;\n payloadUrl: string;\n mediaUrl?: string | null;\n mediaUrlKind?: MediaUrlKind | null;\n infoHash?: string | null;\n metadata?: Record<string, unknown> | null;\n /** ISO-8601 timestamp. When the plugin detected the release (\"now\"). */\n observedAt: string;\n /** ISO-8601 upstream publish date, or `null`/omitted when unavailable. */\n releasedAt?: string | null;\n}\n\n/**\n * Classifies what `mediaUrl` points at so the UI can pick an appropriate\n * icon and the host can label it consistently across sources.\n *\n * - `torrent`: HTTP(S) URL to a `.torrent` file.\n * - `magnet`: `magnet:` URI.\n * - `direct`: HTTP(S) URL to the file itself (DDL host, CDN, etc.).\n * - `other`: anything else; render a generic download icon.\n */\nexport type MediaUrlKind = \"torrent\" | \"magnet\" | \"direct\" | \"other\";\n\n// =============================================================================\n// releases/list_tracked\n// =============================================================================\n\nexport interface ListTrackedRequest {\n sourceId: string;\n limit?: number;\n offset?: number;\n}\n\n/**\n * One tracked-series row scoped to what the plugin's manifest asked for.\n * Aliases are present only when `requiresAliases: true`; external IDs are\n * present only for sources the plugin listed in `requiresExternalIds`.\n */\nexport interface TrackedSeriesEntry {\n seriesId: string;\n aliases?: string[];\n /** Map keyed by external-ID source name (e.g. `{ mangaupdates: \"12345\" }`). */\n externalIds?: Record<string, string>;\n latestKnownChapter?: number | null;\n latestKnownVolume?: number | null;\n}\n\nexport interface ListTrackedResponse {\n tracked: TrackedSeriesEntry[];\n nextOffset?: number;\n}\n\n// =============================================================================\n// releases/record\n// =============================================================================\n\nexport interface RecordRequest {\n sourceId: string;\n candidate: ReleaseCandidate;\n}\n\nexport interface RecordResponse {\n ledgerId: string;\n /** True if the row deduped onto an existing ledger entry. */\n deduped: boolean;\n}\n\n// =============================================================================\n// releases/source_state\n// =============================================================================\n\nexport interface SourceStateGetRequest {\n sourceId: string;\n}\n\nexport interface SourceStateView {\n etag?: string;\n lastPolledAt?: string;\n lastError?: string;\n lastErrorAt?: string;\n}\n\nexport interface SourceStateSetRequest {\n sourceId: string;\n /** Only `etag` is plugin-writable; other fields are host-owned. */\n etag?: string;\n}\n\n// =============================================================================\n// releases/register_sources\n// =============================================================================\n\n/**\n * One source the plugin wants the host to materialize as a `release_sources`\n * row. The plugin owns the `sourceKey` namespace; the host treats it as an\n * opaque string for dedup keyed on `(pluginId, sourceKey)`.\n */\nexport interface RegisteredSourceInput {\n /**\n * Stable per-plugin identifier. Reuse the same key across calls so user\n * overrides (enabled, pollIntervalS) survive plugin restarts.\n */\n sourceKey: string;\n /** Human-readable label shown in the Release tracking settings UI. */\n displayName: string;\n /**\n * Must be one of the kinds the plugin declared in its\n * `releaseSource.kinds` capability \u2014 the host rejects anything else.\n */\n kind: \"rss-uploader\" | \"rss-series\" | \"api-feed\" | \"metadata-feed\";\n /**\n * Optional opaque per-source config snapshot persisted on the row. The\n * host doesn't interpret it; the plugin reads its own admin config\n * directly. Useful for surfacing \"what did this source originate from?\"\n * in the UI / logs.\n */\n config?: Record<string, unknown> | null;\n}\n\nexport interface RegisterSourcesRequest {\n sources: RegisteredSourceInput[];\n}\n\nexport interface RegisterSourcesResponse {\n /** Number of sources upserted (created or refreshed). */\n registered: number;\n /** Number of sources removed because they were not in the request. */\n pruned: number;\n}\n\n// =============================================================================\n// releases/poll (host -> plugin)\n// =============================================================================\n\n/**\n * Parameters for the host's call into a release-source plugin's\n * `releases/poll` handler. Carries the source row to poll plus any ETag the\n * plugin recorded on its previous poll, plus the plugin-defined source key\n * and per-source config snapshot so the plugin can dispatch directly without\n * a reverse-RPC roundtrip.\n */\nexport interface ReleasePollRequest {\n sourceId: string;\n /**\n * The same `sourceKey` the plugin passed to `releases/register_sources`.\n * Useful when one plugin process owns multiple source rows (e.g., one per\n * Nyaa uploader) and needs to know which one to poll.\n */\n sourceKey?: string;\n /**\n * Snapshot of `release_sources.config` for this row. Plugins that stash\n * per-source config on register can read it back here.\n */\n config?: Record<string, unknown> | null;\n etag?: string;\n}\n\n/**\n * Response from a `releases/poll` call.\n *\n * Plugins may also stream candidates over `releases/record` mid-poll; the\n * host treats both styles identically. Use `candidates` for plugins that\n * prefer to return everything at once.\n *\n * Plugins that stream via `releases/record` should also populate the\n * counter fields (`parsed`, `matched`, `recorded`, `deduped`). Without\n * them, the host can only see what came back in `candidates` and the\n * source's status badge will read \"Fetched 0 items\" no matter what\n * actually happened.\n */\nexport interface ReleasePollResponse {\n /** Optional batch of candidates the host should evaluate and ledger. */\n candidates?: ReleaseCandidate[];\n /** New ETag observed (e.g., from the upstream feed's `ETag` header). */\n etag?: string;\n /** Whether the upstream returned `304 Not Modified` (or equivalent). */\n notModified?: boolean;\n /** HTTP status code observed (used by host's per-host backoff). */\n upstreamStatus?: number;\n /**\n * Items the plugin parsed from the upstream feed before any matching\n * or threshold filtering. Streaming plugins should set this so the\n * host's `last_summary` reflects upstream activity, not just the shape\n * of the response payload.\n */\n parsed?: number;\n /**\n * Of those parsed, the count that matched a tracked-series alias (i.e.\n * became candidates the plugin then evaluated/streamed).\n */\n matched?: number;\n /**\n * Of those matched, the count actually inserted into the ledger\n * (excludes dedupes). For plugins that stream via `releases/record`,\n * this is the count of non-deduped record outcomes.\n */\n recorded?: number;\n /**\n * Of those matched, the count the host deduped onto an existing ledger\n * row. Optional; when omitted the host infers `matched - recorded`.\n */\n deduped?: number;\n}\n", "/**\n * Map a matched Tsundoku feed item to a Codex `ReleaseCandidate`.\n *\n * The feed already carries merged, gap-preserving coverage spans that line up\n * with Codex's `NumericSpan` model, so the volume/chapter axes pass through\n * verbatim. The candidate's `externalReleaseId` is keyed on the coverage\n * high-water mark, so a new ledger row (and announcement) fires only when the\n * frontier advances \u2014 re-delivery of the same coverage dedups host-side, and\n * the host's auto-ignore + `latest_known_*` gate handle \"already owned\".\n */\n\nimport type { ReleaseCandidate } from \"@ashdev/codex-plugin-sdk\";\nimport type { FeedCoverageSpan, FeedItem } from \"./fetcher.js\";\nimport type { MatchResult } from \"./matcher.js\";\n\n// `FeedCoverageSpan` is structurally identical to the SDK's `NumericSpan`\n// (`{ start, end }`), so a span list assigns directly to a candidate's\n// `volumes` / `chapters` without a separate type or an SDK barrel export.\n\n/** Inputs the candidate mapping needs beyond the feed item + match. */\nexport interface CandidateOptions {\n /** Tsundoku base URL (trailing slash tolerated) for building the landing link. */\n baseUrl: string;\n /** ISO 639-1 language stamped on the candidate (the feed carries none). */\n language: string;\n /** Detection timestamp (ISO-8601). Defaults to now; injectable for tests. */\n observedAt?: string;\n}\n\n/**\n * Convert a feed coverage list to a `NumericSpan[]`, or `null` when empty.\n * Coverage is already merged + sorted upstream, so this is a structural copy.\n */\nexport function toSpans(coverage: FeedCoverageSpan[]): FeedCoverageSpan[] | null {\n if (coverage.length === 0) return null;\n return coverage.map((s) => ({ start: s.start, end: s.end }));\n}\n\n/** Format a high-water value for the dedup key (`null` -> `-`). */\nfunction fmtHighwater(value: number | null): string {\n return value === null ? \"-\" : String(value);\n}\n\n/**\n * Stable per-source dedup key. Keyed on the coverage high-water mark so the\n * same frontier re-delivers to the same `(sourceId, externalReleaseId)` ledger\n * row (a no-op dedup), while a genuine advance produces a new row.\n */\nexport function externalReleaseId(item: FeedItem): string {\n return `tsundoku:${item.seriesId}:v${fmtHighwater(item.highestVolume)}:c${fmtHighwater(item.highestChapter)}`;\n}\n\n/**\n * Build a `ReleaseCandidate` for a matched feed item. Confidence and `reason`\n * come from the weighted-vote match: confidence reflects the net agreement\n * score, and `reason` lists the providers that agreed (highest-weight first).\n */\nexport function feedItemToCandidate(\n item: FeedItem,\n match: MatchResult,\n opts: CandidateOptions,\n): ReleaseCandidate {\n const base = opts.baseUrl.replace(/\\/+$/, \"\");\n return {\n seriesMatch: {\n codexSeriesId: match.codexSeriesId,\n confidence: match.confidence,\n reason: `tsundoku:vote:${match.agreeingProviders.join(\"+\")}`,\n },\n externalReleaseId: externalReleaseId(item),\n volumes: toSpans(item.volumeCoverage),\n chapters: toSpans(item.chapterCoverage),\n language: opts.language,\n groupOrUploader: null,\n payloadUrl: `${base}/series/${item.seriesId}`,\n observedAt: opts.observedAt ?? new Date().toISOString(),\n // Tsundoku's `updatedAt` is epoch seconds; a coverage change is the closest\n // thing the feed has to a publish date. Not skew-checked host-side.\n releasedAt: new Date(item.updatedAt * 1000).toISOString(),\n metadata: {\n tsundokuSeriesId: item.seriesId,\n canonicalTitle: item.canonicalTitle,\n highestVolume: item.highestVolume,\n highestChapter: item.highestChapter,\n },\n };\n}\n", "/**\n * Tsundoku series-feed fetcher.\n *\n * Wraps `fetch` against `POST {baseUrl}/api/v1/series/feed` with a hard\n * timeout and JSON parsing, returning a discriminated result so the caller\n * can act on a parsed page (`ok`) or surface the upstream status back to the\n * host's per-host backoff layer (`error`).\n *\n * We use the filtered `POST` variant \u2014 the body carries the consumer's\n * `provider:externalId` set so the feed returns only the tracked series, not\n * the whole catalog. The response is keyset-paginated: walk while `hasMore` is\n * true, passing `nextCursor` back as `cursor`. That cursor paginates *within a\n * single poll* and is not persisted \u2014 each poll re-walks the tracked set's\n * current coverage and relies on host-side dedup. Network and parsing are the\n * only side effects, which keeps it trivially testable with a mocked `fetch`.\n */\n\n// =============================================================================\n// Wire types (mirror Tsundoku's SeriesFeedResponse / SeriesFeedItem)\n// =============================================================================\n\n/** One provider mapping on a feed item (e.g. `{ provider: \"mangabaka\", ... }`). */\nexport interface FeedExternalId {\n provider: string;\n externalId: string;\n /** Epoch seconds the mapping was last fetched upstream. */\n fetchedAt: number;\n}\n\n/** One inclusive `[start, end]` coverage range (single values are `start === end`). */\nexport interface FeedCoverageSpan {\n start: number;\n end: number;\n}\n\n/** One series in the incremental release feed. */\nexport interface FeedItem {\n seriesId: number;\n canonicalTitle: string;\n /** Provider mappings the consumer matches on. */\n externalIds: FeedExternalId[];\n /** Merged available volume ranges (sorted, gaps preserved). */\n volumeCoverage: FeedCoverageSpan[];\n /** Merged available chapter ranges (sorted, gaps preserved). */\n chapterCoverage: FeedCoverageSpan[];\n /** Max end of `volumeCoverage`, or null when there is none. */\n highestVolume: number | null;\n /** Max end of `chapterCoverage`, or null when there is none. */\n highestChapter: number | null;\n /** Epoch seconds this series' coverage last changed (the cursor key). */\n updatedAt: number;\n}\n\n/** One page of the feed. */\nexport interface FeedResponse {\n items: FeedItem[];\n /** `true` when more series remain after this page (fetch again now). */\n hasMore: boolean;\n /** Opaque cursor at the last item, or null/absent when the page is empty. */\n nextCursor?: string | null;\n}\n\n// =============================================================================\n// Fetch result + options\n// =============================================================================\n\n/** Discriminated fetch result. */\nexport type FeedFetchResult =\n | { kind: \"ok\"; data: FeedResponse; status: 200 }\n | { kind: \"error\"; status: number; message: string };\n\nexport interface FeedFetcherOptions {\n /** Custom `fetch` impl (for testing). Defaults to global `fetch`. */\n fetchImpl?: typeof fetch;\n /** Per-request timeout. Defaults to 10s. */\n timeoutMs?: number;\n}\n\n/** Feed endpoint path appended to the configured base URL. */\nexport const FEED_PATH = \"/api/v1/series/feed\";\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\n\n/** Body for one `POST /series/feed` page. */\nexport interface FeedRequest {\n /**\n * `provider:externalId` filter \u2014 the feed is narrowed to series carrying one\n * of these. Must be non-empty (an empty list means \"no filter\" upstream,\n * i.e. the whole catalog \u2014 callers guard against that).\n */\n externalIds: string[];\n /** Pagination cursor within this poll. `null` starts at the beginning. */\n cursor: string | null;\n /** Page size (the caller clamps to 1..=500). */\n limit: number;\n}\n\n/** Build the feed endpoint URL (trailing slashes on `baseUrl` tolerated). */\nexport function feedUrl(baseUrl: string): string {\n return `${baseUrl.replace(/\\/+$/, \"\")}${FEED_PATH}`;\n}\n\n/**\n * Fetch one page of the filtered Tsundoku series feed via `POST`.\n *\n * We post the tracked `externalIds` set so the feed returns only the\n * consumer's series (not the whole catalog). The `cursor` is for pagination\n * *within a single poll* \u2014 it is not persisted across polls; each poll walks\n * the current coverage of the tracked set and relies on host-side dedup to\n * suppress unchanged releases.\n *\n * @param baseUrl - Tsundoku instance base URL (trailing slash tolerated).\n * @param req - Filter set + pagination cursor + page size.\n * @param opts - Fetcher options (custom fetch, timeout).\n */\nexport async function fetchFeedPage(\n baseUrl: string,\n req: FeedRequest,\n opts: FeedFetcherOptions = {},\n): Promise<FeedFetchResult> {\n const fetchImpl = opts.fetchImpl ?? globalThis.fetch;\n const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\n const url = feedUrl(baseUrl);\n const headers: Record<string, string> = {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n \"User-Agent\": \"Codex-ReleaseTracker/1.0 (+https://github.com/AshDevFr/codex)\",\n };\n const body = JSON.stringify({\n externalIds: req.externalIds,\n cursor: req.cursor,\n limit: req.limit,\n });\n\n // AbortSignal.timeout is the cleanest path; we already require Node 22+.\n const signal = AbortSignal.timeout(timeoutMs);\n\n let resp: Response;\n try {\n resp = await fetchImpl(url, { method: \"POST\", headers, body, signal });\n } catch (err) {\n const msg = err instanceof Error ? err.message : \"Unknown fetch error\";\n // Aborts and transport-level failures map to 0/unavailable so the host's\n // per-host backoff can react without us inventing a fake HTTP status.\n return { kind: \"error\", status: 0, message: msg };\n }\n\n if (resp.status !== 200) {\n // Pass through 429 / 5xx so the host's backoff layer sees the real status.\n return {\n kind: \"error\",\n status: resp.status,\n message: `upstream returned ${resp.status} ${resp.statusText}`.trim(),\n };\n }\n\n let parsed: unknown;\n try {\n parsed = await resp.json();\n } catch (err) {\n const msg = err instanceof Error ? err.message : \"invalid JSON\";\n return { kind: \"error\", status: 200, message: `failed to parse feed JSON: ${msg}` };\n }\n\n if (!isFeedResponse(parsed)) {\n return { kind: \"error\", status: 200, message: \"malformed feed response: missing items[]\" };\n }\n\n return { kind: \"ok\", data: parsed, status: 200 };\n}\n\n/**\n * Minimal structural guard: a valid page must carry an `items` array and a\n * boolean `hasMore`. We don't deep-validate each item \u2014 the matcher tolerates\n * missing fields per-item rather than failing the whole page.\n */\nfunction isFeedResponse(value: unknown): value is FeedResponse {\n if (value === null || typeof value !== \"object\") return false;\n const obj = value as Record<string, unknown>;\n return Array.isArray(obj.items) && typeof obj.hasMore === \"boolean\";\n}\n", "{\n \"name\": \"@ashdev/codex-plugin-release-tsundoku\",\n \"version\": \"1.36.1\",\n \"description\": \"Tsundoku release-source plugin for Codex - announces new volume/chapter coverage for tracked series via the Tsundoku incremental series feed, matched by exact external IDs\",\n \"main\": \"dist/index.js\",\n \"bin\": \"dist/index.js\",\n \"type\": \"module\",\n \"files\": [\n \"dist\",\n \"README.md\"\n ],\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"https://github.com/AshDevFr/codex.git\",\n \"directory\": \"plugins/release-tsundoku\"\n },\n \"scripts\": {\n \"build\": \"esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --sourcemap --banner:js='#!/usr/bin/env node'\",\n \"dev\": \"npm run build -- --watch\",\n \"clean\": \"rm -rf dist\",\n \"start\": \"node dist/index.js\",\n \"lint\": \"biome check .\",\n \"lint:fix\": \"biome check --write .\",\n \"typecheck\": \"tsc --noEmit\",\n \"test\": \"vitest run --passWithNoTests\",\n \"test:watch\": \"vitest\",\n \"prepublishOnly\": \"npm run lint && npm run build\"\n },\n \"keywords\": [\n \"codex\",\n \"plugin\",\n \"tsundoku\",\n \"release-source\",\n \"manga\"\n ],\n \"author\": \"Codex\",\n \"license\": \"MIT\",\n \"engines\": {\n \"node\": \">=22.0.0\"\n },\n \"dependencies\": {\n \"@ashdev/codex-plugin-sdk\": \"file:../sdk-typescript\"\n },\n \"devDependencies\": {\n \"@biomejs/biome\": \"^2.4.4\",\n \"@types/node\": \"^22.0.0\",\n \"esbuild\": \"^0.27.3\",\n \"typescript\": \"^5.9.3\",\n \"vitest\": \"^4.0.18\"\n }\n}\n", "import type { PluginManifest } from \"@ashdev/codex-plugin-sdk\";\nimport packageJson from \"../package.json\" with { type: \"json\" };\n\n/**\n * Maps a Codex external-ID source name to the provider name the Tsundoku feed\n * uses. Codex stores some sources under different names than Tsundoku emits\n * (e.g. Codex `myanimelist` \u2194 Tsundoku `mal`), so we translate when building\n * the match index and the feed filter. Identity for names that already agree.\n *\n * The keys are the *bare* Codex source names \u2014 the host strips the stored\n * `api:` / `plugin:` prefix before matching `requiresExternalIds`, so a series\n * stored as `api:myanimelist` is delivered to us as `myanimelist`.\n */\nexport const CODEX_TO_TSUNDOKU_PROVIDER: Record<string, string> = {\n mangabaka: \"mangabaka\",\n anilist: \"anilist\",\n myanimelist: \"mal\",\n mangaupdates: \"mangaupdates\",\n kitsu: \"kitsu\",\n shikimori: \"shikimori\",\n animeplanet: \"anime_planet\",\n animenewsnetwork: \"anime_news_network\",\n};\n\n/**\n * The Codex source names the plugin asks the host for via\n * `requiresExternalIds`. These must be the names Codex *stores* (the map keys),\n * not Tsundoku's \u2014 the host filters `series_external_ids.source` against them.\n */\nexport const CODEX_EXTERNAL_ID_SOURCES = Object.keys(CODEX_TO_TSUNDOKU_PROVIDER);\n\nexport const manifest = {\n name: \"release-tsundoku\",\n displayName: \"Tsundoku Releases\",\n version: packageJson.version,\n description:\n \"Announces new volume/chapter coverage for tracked series via a Tsundoku instance's incremental series feed. Matches series by exact external IDs (no fuzzy matching) and walks the feed by cursor, persisting its position between polls.\",\n author: \"Codex\",\n homepage: \"https://github.com/AshDevFr/codex\",\n protocolVersion: \"1.1\",\n capabilities: {\n releaseSource: {\n kinds: [\"api-feed\"],\n requiresAliases: false,\n requiresExternalIds: [...CODEX_EXTERNAL_ID_SOURCES],\n canAnnounceChapters: true,\n canAnnounceVolumes: true,\n },\n },\n configSchema: {\n description:\n \"Tsundoku plugin configuration. Point `baseUrl` at your Tsundoku instance; the plugin polls its public `/api/v1/series/feed` endpoint and matches results to your tracked series by external ID.\",\n fields: [\n {\n key: \"baseUrl\",\n label: \"Tsundoku Base URL\",\n description:\n \"Base URL of the Tsundoku instance, e.g. `https://tsundoku.example.com`. The plugin appends `/api/v1/series/feed`. No trailing slash required.\",\n type: \"string\" as const,\n required: true,\n example: \"https://tsundoku.example.com\",\n },\n {\n key: \"defaultLanguage\",\n label: \"Default Language\",\n description:\n \"ISO 639-1 language tag stamped on every announcement. The Tsundoku feed tracks official release coverage and carries no language of its own, so a default is required. Per-series language preferences on each series' tracking config still gate the high-water mark host-side.\",\n type: \"string\" as const,\n required: false,\n default: \"en\",\n example: \"en\",\n },\n {\n key: \"pageLimit\",\n label: \"Feed Page Size\",\n description:\n \"Items requested per feed page (1\u2013500). Larger pages mean fewer round-trips when walking a long backlog. Defaults to 100.\",\n type: \"number\" as const,\n required: false,\n default: 100,\n },\n {\n key: \"requestTimeoutMs\",\n label: \"Request Timeout (ms)\",\n description:\n \"How long to wait for a single feed page before giving up. Defaults to 10000 (10 seconds).\",\n type: \"number\" as const,\n required: false,\n default: 10_000,\n },\n ],\n },\n userDescription:\n \"Announces new volumes and chapters for series you've tracked, using a Tsundoku instance as the source. Matches your series by external ID (MangaBaka, AniList, MAL, and more). Notification-only \u2014 Codex does not download anything.\",\n adminSetupInstructions:\n \"1. Set `baseUrl` to your Tsundoku instance URL (e.g. `https://tsundoku.example.com`) and save. The plugin auto-registers a single source row (`Tsundoku Releases`) in **Settings \u2192 Release tracking**, where you can disable it, change the poll interval, or hit *Poll now*. 2. To get announcements for a series, make sure it has at least one external ID Tsundoku also knows (MangaBaka, AniList, MAL, MangaUpdates, Kitsu, Shikimori, Anime-Planet, or Anime News Network) \u2014 populate these via a metadata refresh or by pasting them in the series tracking panel. 3. Optional: adjust `defaultLanguage` (default `en`), `pageLimit`, and `requestTimeoutMs`. The Tsundoku feed endpoint is public; no credentials are needed. Note: the feed is incremental, so newly tracked series only announce on their *next* Tsundoku coverage change.\",\n} as const satisfies PluginManifest & {\n capabilities: { releaseSource: { kinds: [\"api-feed\"] } };\n};\n", "/**\n * Match Tsundoku feed items to tracked Codex series by external ID \u2014 using\n * *weighted voting* across providers rather than trusting a single ID.\n *\n * Why voting: provider IDs vary in quality. MangaBaka is an aggregation hub\n * with reliably 1:1 IDs; others (MAL, MangaUpdates, \u2026) occasionally share or\n * merge IDs across distinct series, so a lone matching ID can be a false\n * positive. So for each candidate we tally the providers the feed item and the\n * Codex series *both* carry: a shared ID that agrees adds its weight, one that\n * disagrees subtracts it. A series matches only when agreement outweighs\n * disagreement \u2014 a trusted disagreement (e.g. different MangaBaka IDs) vetoes a\n * sloppy agreement (e.g. a shared MAL ID).\n *\n * Codex's `releases/record` keys on a `codexSeriesId`, so matching is done\n * here, plugin-side, over the full ID sets both the host and the feed expose.\n */\n\nimport type { TrackedSeriesEntry } from \"@ashdev/codex-plugin-sdk\";\nimport type { FeedItem } from \"./fetcher.js\";\nimport { CODEX_TO_TSUNDOKU_PROVIDER } from \"./manifest.js\";\n\n/**\n * Vote weight per provider \u2014 higher means more trusted as a match signal.\n * MangaBaka leads (its IDs are reliably 1:1), AniList next; the rest default\n * to 1. Tune here if real data shows a source is noisier than assumed.\n */\nexport const PROVIDER_WEIGHTS: Record<string, number> = {\n mangabaka: 3,\n anilist: 2,\n};\nconst DEFAULT_WEIGHT = 1;\n\nfunction weightOf(provider: string): number {\n return PROVIDER_WEIGHTS[provider] ?? DEFAULT_WEIGHT;\n}\n\n/** Result of resolving a feed item to a tracked Codex series. */\nexport interface MatchResult {\n /** The Codex series UUID the candidate should be recorded against. */\n codexSeriesId: string;\n /** Net vote score (agreeing weights minus disagreeing). Always `> 0`. */\n score: number;\n /** Host confidence in `[0.8, 1.0]`, derived from the score. */\n confidence: number;\n /** Providers that agreed, highest-weight first \u2014 used for the candidate `reason`. */\n agreeingProviders: string[];\n}\n\n/** Pre-computed lookup over the tracked series for matching. */\nexport interface MatchContext {\n /** `provider:id` -> codex series ids carrying it (usually one). */\n byKey: Map<string, string[]>;\n /** codex series id -> its `provider -> id` map (for the conflict tally). */\n series: Map<string, Map<string, string>>;\n}\n\n/** Compose the lookup key for a `(provider, externalId)` pair. */\nfunction indexKey(provider: string, externalId: string): string {\n return `${provider}:${externalId}`;\n}\n\n/**\n * Build the match context from the host's tracked-series rows. Entries without\n * external IDs contribute nothing.\n */\nexport function buildMatchContext(entries: TrackedSeriesEntry[]): MatchContext {\n const byKey = new Map<string, string[]>();\n const series = new Map<string, Map<string, string>>();\n\n for (const entry of entries) {\n const ids = entry.externalIds;\n if (!ids) continue;\n const map = new Map<string, string>();\n for (const [codexProvider, externalId] of Object.entries(ids)) {\n if (!externalId) continue;\n // Translate the Codex source name to Tsundoku's provider name so both\n // the index keys and the feed filter line up with what the feed emits\n // (e.g. Codex `myanimelist` -> Tsundoku `mal`).\n const provider = CODEX_TO_TSUNDOKU_PROVIDER[codexProvider] ?? codexProvider;\n map.set(provider, externalId);\n const key = indexKey(provider, externalId);\n const arr = byKey.get(key);\n if (arr) {\n arr.push(entry.seriesId);\n } else {\n byKey.set(key, [entry.seriesId]);\n }\n }\n if (map.size > 0) {\n series.set(entry.seriesId, map);\n }\n }\n\n return { byKey, series };\n}\n\n/**\n * The full set of `provider:id` keys across all tracked series. This is the\n * filter set posted to Tsundoku's `POST /series/feed` so the feed is narrowed\n * to the consumer's catalog.\n */\nexport function externalIdFilter(ctx: MatchContext): string[] {\n return [...ctx.byKey.keys()];\n}\n\n/** Map a net score to a host confidence in `[0.8, 1.0]` (gate is 0.7). */\nfunction confidenceForScore(score: number): number {\n return Math.min(1, Math.max(0.7, 0.7 + 0.1 * score));\n}\n\n/**\n * Resolve a feed item to the single best-matching tracked series, or `null`\n * when nothing matches net-positive or the top two candidates tie (ambiguous \u2014\n * the item's IDs point at two series equally well, so we can't safely pick).\n */\nexport function matchItem(item: FeedItem, ctx: MatchContext): MatchResult | null {\n const itemMap = new Map<string, string>();\n for (const ext of item.externalIds) {\n if (ext.externalId) {\n itemMap.set(ext.provider, ext.externalId);\n }\n }\n\n // Candidate Codex series: any that shares at least one id with the item.\n const candidates = new Set<string>();\n for (const [provider, id] of itemMap) {\n const arr = ctx.byKey.get(indexKey(provider, id));\n if (arr) {\n for (const sid of arr) candidates.add(sid);\n }\n }\n if (candidates.size === 0) return null;\n\n let best: MatchResult | null = null;\n let tiedAtBest = false;\n\n for (const cid of candidates) {\n const cSeries = ctx.series.get(cid);\n if (!cSeries) continue;\n\n let agree = 0;\n let disagree = 0;\n const agreeing: Array<{ provider: string; weight: number }> = [];\n for (const [provider, idVal] of itemMap) {\n const cVal = cSeries.get(provider);\n if (cVal === undefined) continue; // provider not shared by both\n const w = weightOf(provider);\n if (cVal === idVal) {\n agree += w;\n agreeing.push({ provider, weight: w });\n } else {\n disagree += w;\n }\n }\n\n const score = agree - disagree;\n if (score <= 0) continue; // disagreement outweighs (or ties) agreement\n\n if (!best || score > best.score) {\n agreeing.sort((a, b) => b.weight - a.weight || a.provider.localeCompare(b.provider));\n best = {\n codexSeriesId: cid,\n score,\n confidence: confidenceForScore(score),\n agreeingProviders: agreeing.map((a) => a.provider),\n };\n tiedAtBest = false;\n } else if (score === best.score) {\n tiedAtBest = true;\n }\n }\n\n // No net-positive candidate, or two series matched equally well \u2192 don't guess.\n if (!best || tiedAtBest) return null;\n return best;\n}\n", "/**\n * Tsundoku API-feed release-source plugin for Codex.\n *\n * Tsundoku exposes a series feed at `/api/v1/series/feed` carrying, per series,\n * the provider external IDs Codex matches on plus the merged volume/chapter\n * coverage. This plugin polls the **filtered** `POST` variant, matches each\n * returned series to a tracked Codex series by weighted external-ID voting, and\n * records release candidates.\n *\n * Each poll:\n * 1. Builds a match context from the host's `releases/list_tracked` rows\n * (scoped by `requiresExternalIds`) and derives the `provider:externalId`\n * filter set.\n * 2. `POST`s that filter to `/series/feed`, so the response contains only the\n * tracked series \u2014 not the whole catalog. There is no persisted cursor:\n * each poll re-walks the tracked set's current coverage and relies on\n * host-side dedup to suppress unchanged releases. This keeps newly\n * tracked series backfilled and untracked ones dropped, automatically.\n * 3. Matches each item (weighted voting), resolves cross-item (one feed entry\n * per Codex series), and records via `releases/record`.\n *\n * The fetch, matching, and candidate mapping live in dedicated modules\n * (`fetcher`, `matcher`, `candidate`); this entry point owns plugin lifecycle,\n * config, source registration, and the poll orchestration.\n */\n\nimport {\n createLogger,\n createReleaseSourcePlugin,\n type HostRpcClient,\n HostRpcError,\n type InitializeParams,\n RELEASES_METHODS,\n type ReleaseCandidate,\n type ReleasePollRequest,\n type ReleasePollResponse,\n type TrackedSeriesEntry,\n} from \"@ashdev/codex-plugin-sdk\";\nimport { feedItemToCandidate } from \"./candidate.js\";\nimport { type FeedItem, fetchFeedPage } from \"./fetcher.js\";\nimport { manifest } from \"./manifest.js\";\nimport { buildMatchContext, externalIdFilter, type MatchResult, matchItem } from \"./matcher.js\";\n\nconst logger = createLogger({ name: manifest.name, level: \"info\" });\n\n/** Default feed page size when config omits / mis-types `pageLimit`. */\nconst DEFAULT_PAGE_LIMIT = 100;\n/** Tsundoku caps the feed page size at 500. */\nconst MAX_PAGE_LIMIT = 500;\n/** Default per-request timeout when config omits / mis-types `requestTimeoutMs`. */\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst MIN_TIMEOUT_MS = 1_000;\nconst MAX_TIMEOUT_MS = 60_000;\nconst DEFAULT_LANGUAGE = \"en\";\n\n// =============================================================================\n// Plugin-level state (set during initialize)\n// =============================================================================\n\ninterface PluginState {\n hostRpc: HostRpcClient | null;\n /** Tsundoku instance base URL (no trailing slash), e.g. `https://t.example.com`. */\n baseUrl: string;\n /** ISO 639-1 tag stamped on every candidate (the feed carries none). */\n defaultLanguage: string;\n /** Feed page size (1..=MAX_PAGE_LIMIT). */\n pageLimit: number;\n /** Hard timeout for a single feed-page fetch. */\n requestTimeoutMs: number;\n}\n\nconst state: PluginState = {\n hostRpc: null,\n baseUrl: \"\",\n defaultLanguage: DEFAULT_LANGUAGE,\n pageLimit: DEFAULT_PAGE_LIMIT,\n requestTimeoutMs: DEFAULT_TIMEOUT_MS,\n};\n\n/** Reset state. Exported for tests; not part of the plugin contract. */\nexport function _resetState(): void {\n state.hostRpc = null;\n state.baseUrl = \"\";\n state.defaultLanguage = DEFAULT_LANGUAGE;\n state.pageLimit = DEFAULT_PAGE_LIMIT;\n state.requestTimeoutMs = DEFAULT_TIMEOUT_MS;\n}\n\n/** Strip a single trailing slash so URL building stays predictable. */\nexport function normalizeBaseUrl(raw: string): string {\n return raw.trim().replace(/\\/+$/, \"\");\n}\n\n// =============================================================================\n// Source registration\n// =============================================================================\n\n/**\n * Register the single static source row representing the Tsundoku feed. The\n * whole catalog is polled under one logical source keyed `default`.\n *\n * No retry needed: the host parks an early reverse-RPC on its readiness\n * barrier until the plugin's capabilities + handlers are installed, so this\n * single call resolves cleanly even when fired from `onInitialize`.\n */\nexport async function registerSources(\n rpc: HostRpcClient,\n): Promise<{ registered: number; pruned: number } | null> {\n const sources = [\n {\n sourceKey: \"default\",\n displayName: \"Tsundoku Releases\",\n kind: \"api-feed\" as const,\n config: null,\n },\n ];\n try {\n return await rpc.call<{ registered: number; pruned: number }>(\n RELEASES_METHODS.REGISTER_SOURCES,\n { sources },\n );\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err);\n logger.error(`register_sources failed: ${reason}`);\n return null;\n }\n}\n\n// =============================================================================\n// Reverse-RPC wrappers\n// =============================================================================\n\ninterface ListTrackedResponse {\n tracked: TrackedSeriesEntry[];\n nextOffset?: number;\n}\n\ninterface RecordResponse {\n ledgerId: string;\n deduped: boolean;\n}\n\n/** Page size for the tracked-series sweep that builds the match index. */\nconst TRACKED_PAGE_SIZE = 200;\n\n/**\n * Lazily walk all tracked-series pages from the host. Yields one entry at a\n * time so the caller can build the reverse index without materializing every\n * page at once.\n */\nasync function* iterateTrackedSeries(\n rpc: HostRpcClient,\n sourceId: string,\n): AsyncGenerator<TrackedSeriesEntry> {\n let offset = 0;\n while (true) {\n const page = await rpc.call<ListTrackedResponse>(RELEASES_METHODS.LIST_TRACKED, {\n sourceId,\n offset,\n limit: TRACKED_PAGE_SIZE,\n });\n for (const entry of page.tracked) {\n yield entry;\n }\n if (page.nextOffset === undefined || page.tracked.length === 0) return;\n offset = page.nextOffset;\n }\n}\n\n/**\n * Submit one candidate to the host ledger. Per-candidate failures (threshold\n * rejection, validation, transient host error) are logged and swallowed so a\n * single bad item never aborts the walk; the next poll retries it.\n */\nasync function recordCandidate(\n rpc: HostRpcClient,\n sourceId: string,\n candidate: ReleaseCandidate,\n): Promise<RecordResponse | null> {\n try {\n return await rpc.call<RecordResponse>(RELEASES_METHODS.RECORD, { sourceId, candidate });\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err);\n const code = err instanceof HostRpcError ? ` (code ${err.code})` : \"\";\n logger.warn(`record failed for ${candidate.externalReleaseId}: ${reason}${code}`);\n return null;\n }\n}\n\n/**\n * Best-effort progress emit. Failures (including older hosts without the\n * method) are swallowed \u2014 progress is a UX nicety, never a reason to abort.\n */\nasync function reportProgress(\n rpc: HostRpcClient,\n current: number,\n total: number,\n message: string,\n): Promise<void> {\n try {\n await rpc.call(RELEASES_METHODS.REPORT_PROGRESS, { current, total, message });\n } catch (err) {\n if (err instanceof HostRpcError && err.code === -32601) return;\n const reason = err instanceof Error ? err.message : String(err);\n logger.debug(`report_progress dropped: ${reason}`);\n }\n}\n\n// =============================================================================\n// Poll\n// =============================================================================\n\n/** Dependencies a poll needs, defaulted from plugin state at the call site. */\nexport interface PollDeps {\n /** Tsundoku base URL (no trailing slash). */\n baseUrl: string;\n /** Language stamped on every candidate. */\n language: string;\n /** Feed page size. */\n pageLimit: number;\n /** Per-page fetch timeout. */\n timeoutMs: number;\n /** Custom `fetch` impl (tests). */\n fetchImpl?: typeof fetch;\n}\n\n/**\n * Top-level poll handler.\n *\n * Builds the match context from the host's tracked series and posts their\n * `provider:externalId` set to Tsundoku's filtered feed, so the response\n * contains only the tracked series (not the whole catalog). It walks every\n * page of that filtered feed each poll \u2014 there is no persisted cursor; the\n * in-poll cursor only paginates the current response, and host-side dedup\n * suppresses unchanged releases. Matched items are resolved cross-item (one\n * feed entry per Codex series) and recorded. Exported for tests.\n */\nexport async function poll(\n params: ReleasePollRequest,\n rpc: HostRpcClient,\n deps: PollDeps,\n): Promise<ReleasePollResponse> {\n const sourceId = params.sourceId;\n\n // 1. Build the match context from the user's tracked series, and derive the\n // `provider:externalId` filter we post to Tsundoku.\n const trackedEntries: TrackedSeriesEntry[] = [];\n for await (const entry of iterateTrackedSeries(rpc, sourceId)) {\n trackedEntries.push(entry);\n }\n const ctx = buildMatchContext(trackedEntries);\n const externalIds = externalIdFilter(ctx);\n if (externalIds.length === 0) {\n // Nothing to query. Posting an empty filter would mean \"no filter\" upstream\n // (the whole catalog), so skip entirely instead.\n logger.info(\n `poll: no tracked series carry a Tsundoku-known external ID (source=${sourceId}); nothing to fetch`,\n );\n return {\n notModified: false,\n upstreamStatus: 200,\n parsed: 0,\n matched: 0,\n recorded: 0,\n deduped: 0,\n };\n }\n\n // 2. Walk the filtered feed, collecting per-item matches. We resolve them\n // after the walk (cross-item) rather than recording inline, so that when\n // several feed entries map to the same Codex series we keep only the best\n // one instead of polluting the ledger. The cursor here is ephemeral \u2014 it\n // paginates this poll's response and is never persisted.\n let cursor: string | null = null;\n let parsed = 0;\n let worstStatus = 200;\n let pagesFetched = 0;\n const hits: Array<{ item: FeedItem; match: MatchResult }> = [];\n\n while (true) {\n const result = await fetchFeedPage(\n deps.baseUrl,\n { externalIds, cursor, limit: deps.pageLimit },\n { timeoutMs: deps.timeoutMs, fetchImpl: deps.fetchImpl },\n );\n\n if (result.kind === \"error\") {\n worstStatus = Math.max(worstStatus, result.status);\n // Couldn't fetch even the first page: surface a hard failure so the host\n // records `last_error` and the source shows it (e.g. an unreachable or\n // misconfigured `baseUrl`). A mid-walk failure, by contrast, keeps the\n // pages already processed and just stops.\n if (pagesFetched === 0) {\n throw new Error(`feed fetch failed (status ${result.status}): ${result.message}`);\n }\n logger.warn(`feed fetch failed (status ${result.status}): ${result.message}; stopping walk`);\n break;\n }\n\n pagesFetched++;\n const page = result.data;\n for (const item of page.items) {\n parsed++;\n const match = matchItem(item, ctx);\n if (match) {\n hits.push({ item, match });\n }\n }\n\n await reportProgress(rpc, parsed, parsed, `Processed ${parsed} feed items`);\n\n const next = page.nextCursor ?? null;\n if (!page.hasMore) break;\n if (!next) {\n // hasMore with no advancing cursor would loop forever; stop defensively.\n logger.warn(\"feed reported hasMore but no nextCursor; stopping walk\");\n break;\n }\n if (page.items.length === 0) break;\n cursor = next;\n }\n\n // 3. Cross-item resolution: a Codex series should map to at most one feed\n // entry. Group hits by Codex series; keep the highest-scoring one. If the\n // top two tie (e.g. two entries match only via the same low-trust ID),\n // it's genuinely ambiguous \u2014 skip both rather than record the wrong one.\n const byCodex = new Map<string, Array<{ item: FeedItem; match: MatchResult }>>();\n for (const hit of hits) {\n const arr = byCodex.get(hit.match.codexSeriesId);\n if (arr) {\n arr.push(hit);\n } else {\n byCodex.set(hit.match.codexSeriesId, [hit]);\n }\n }\n\n let matched = 0;\n let recorded = 0;\n let deduped = 0;\n let ambiguous = 0;\n let superseded = 0;\n\n for (const [codexSeriesId, group] of byCodex) {\n // Best score first; for ties prefer the most recently updated entry (newest\n // coverage). The same Tsundoku series appearing twice in one walk is not a\n // conflict \u2014 only *different* series tying is.\n group.sort((a, b) => b.match.score - a.match.score || b.item.updatedAt - a.item.updatedAt);\n if (\n group.length > 1 &&\n group[0].match.score === group[1].match.score &&\n group[0].item.seriesId !== group[1].item.seriesId\n ) {\n ambiguous += group.length;\n logger.warn(\n `ambiguous: feed entries from different Tsundoku series match Codex series ${codexSeriesId} at score ${group[0].match.score}; skipping`,\n );\n continue;\n }\n superseded += group.length - 1;\n\n const { item, match } = group[0];\n matched++;\n const candidate = feedItemToCandidate(item, match, {\n baseUrl: deps.baseUrl,\n language: deps.language,\n });\n const outcome = await recordCandidate(rpc, sourceId, candidate);\n if (!outcome) continue;\n if (outcome.deduped) {\n deduped++;\n } else {\n recorded++;\n }\n }\n\n logger.info(\n `poll complete: source=${sourceId} tracked=${trackedEntries.length} parsed=${parsed} matched=${matched} recorded=${recorded} deduped=${deduped} ambiguous=${ambiguous} superseded=${superseded} worst_status=${worstStatus}`,\n );\n\n return {\n notModified: false,\n upstreamStatus: worstStatus,\n parsed,\n matched,\n recorded,\n deduped,\n };\n}\n\n// =============================================================================\n// Plugin Initialization\n// =============================================================================\n\ncreateReleaseSourcePlugin({\n manifest,\n provider: {\n async poll(params: ReleasePollRequest): Promise<ReleasePollResponse> {\n if (!state.hostRpc) {\n throw new Error(\"Plugin not initialized: host RPC client missing\");\n }\n if (!state.baseUrl) {\n throw new Error(\"Plugin not configured: baseUrl is required\");\n }\n return poll(params, state.hostRpc, {\n baseUrl: state.baseUrl,\n language: state.defaultLanguage,\n pageLimit: state.pageLimit,\n timeoutMs: state.requestTimeoutMs,\n });\n },\n },\n logLevel: \"info\",\n async onInitialize(params: InitializeParams) {\n state.hostRpc = params.hostRpc;\n\n const ac = params.adminConfig ?? {};\n if (typeof ac.baseUrl === \"string\") {\n state.baseUrl = normalizeBaseUrl(ac.baseUrl);\n }\n if (typeof ac.defaultLanguage === \"string\" && ac.defaultLanguage.trim().length > 0) {\n state.defaultLanguage = ac.defaultLanguage.trim().toLowerCase();\n }\n if (typeof ac.pageLimit === \"number\" && Number.isFinite(ac.pageLimit)) {\n state.pageLimit = Math.max(1, Math.min(Math.trunc(ac.pageLimit), MAX_PAGE_LIMIT));\n }\n if (typeof ac.requestTimeoutMs === \"number\" && Number.isFinite(ac.requestTimeoutMs)) {\n state.requestTimeoutMs = Math.max(\n MIN_TIMEOUT_MS,\n Math.min(ac.requestTimeoutMs, MAX_TIMEOUT_MS),\n );\n }\n\n if (!state.baseUrl) {\n logger.warn(\n \"initialized without a baseUrl \u2014 set it in the plugin config; polls will error until then\",\n );\n }\n logger.info(\n `initialized: baseUrl=${state.baseUrl || \"(unset)\"} defaultLanguage=${state.defaultLanguage} pageLimit=${state.pageLimit} timeoutMs=${state.requestTimeoutMs}`,\n );\n\n // Materialize the single static source row. Deferred to a microtask so we\n // run *after* the host installs the releases reverse-RPC handler.\n queueMicrotask(() => {\n void registerSources(params.hostRpc).then((result) => {\n if (result) {\n logger.info(`register_sources: registered=${result.registered} pruned=${result.pruned}`);\n }\n });\n });\n },\n});\n\nlogger.info(\"Tsundoku release-source plugin started\");\n"],
|
|
3
|
+
"sources": ["../node_modules/@ashdev/codex-plugin-sdk/src/types/rpc.ts", "../node_modules/@ashdev/codex-plugin-sdk/src/errors.ts", "../node_modules/@ashdev/codex-plugin-sdk/src/request-context.ts", "../node_modules/@ashdev/codex-plugin-sdk/src/host-rpc.ts", "../node_modules/@ashdev/codex-plugin-sdk/src/logger.ts", "../node_modules/@ashdev/codex-plugin-sdk/src/server.ts", "../node_modules/@ashdev/codex-plugin-sdk/src/storage.ts", "../node_modules/@ashdev/codex-plugin-sdk/src/types/releases.ts", "../src/candidate.ts", "../src/fetcher.ts", "../package.json", "../src/manifest.ts", "../src/matcher.ts", "../src/index.ts"],
|
|
4
|
+
"sourcesContent": [null, null, null, null, null, null, null, null, "/**\n * Map a matched Tsundoku feed item to a Codex `ReleaseCandidate`.\n *\n * The feed already carries merged, gap-preserving coverage spans that line up\n * with Codex's `NumericSpan` model, so the volume/chapter axes pass through\n * verbatim. The candidate's `externalReleaseId` is keyed on the coverage\n * high-water mark, so a new ledger row (and announcement) fires only when the\n * frontier advances \u2014 re-delivery of the same coverage dedups host-side, and\n * the host's auto-ignore + `latest_known_*` gate handle \"already owned\".\n */\n\nimport type { ReleaseCandidate } from \"@ashdev/codex-plugin-sdk\";\nimport type { FeedCoverageSpan, FeedItem } from \"./fetcher.js\";\nimport type { MatchResult } from \"./matcher.js\";\n\n// `FeedCoverageSpan` is structurally identical to the SDK's `NumericSpan`\n// (`{ start, end }`), so a span list assigns directly to a candidate's\n// `volumes` / `chapters` without a separate type or an SDK barrel export.\n\n/** Inputs the candidate mapping needs beyond the feed item + match. */\nexport interface CandidateOptions {\n /** Tsundoku base URL (trailing slash tolerated) for building the landing link. */\n baseUrl: string;\n /** ISO 639-1 language stamped on the candidate (the feed carries none). */\n language: string;\n /** Detection timestamp (ISO-8601). Defaults to now; injectable for tests. */\n observedAt?: string;\n}\n\n/**\n * Convert a feed coverage list to a `NumericSpan[]`, or `null` when empty.\n * Coverage is already merged + sorted upstream, so this is a structural copy.\n */\nexport function toSpans(coverage: FeedCoverageSpan[]): FeedCoverageSpan[] | null {\n if (coverage.length === 0) return null;\n return coverage.map((s) => ({ start: s.start, end: s.end }));\n}\n\n/** Format a high-water value for the dedup key (`null` -> `-`). */\nfunction fmtHighwater(value: number | null): string {\n return value === null ? \"-\" : String(value);\n}\n\n/**\n * Stable per-source dedup key. Keyed on the coverage high-water mark so the\n * same frontier re-delivers to the same `(sourceId, externalReleaseId)` ledger\n * row (a no-op dedup), while a genuine advance produces a new row.\n */\nexport function externalReleaseId(item: FeedItem): string {\n return `tsundoku:${item.seriesId}:v${fmtHighwater(item.highestVolume)}:c${fmtHighwater(item.highestChapter)}`;\n}\n\n/**\n * Build a `ReleaseCandidate` for a matched feed item. Confidence and `reason`\n * come from the weighted-vote match: confidence reflects the net agreement\n * score, and `reason` lists the providers that agreed (highest-weight first).\n */\nexport function feedItemToCandidate(\n item: FeedItem,\n match: MatchResult,\n opts: CandidateOptions,\n): ReleaseCandidate {\n const base = opts.baseUrl.replace(/\\/+$/, \"\");\n return {\n seriesMatch: {\n codexSeriesId: match.codexSeriesId,\n confidence: match.confidence,\n reason: `tsundoku:vote:${match.agreeingProviders.join(\"+\")}`,\n },\n externalReleaseId: externalReleaseId(item),\n volumes: toSpans(item.volumeCoverage),\n chapters: toSpans(item.chapterCoverage),\n language: opts.language,\n groupOrUploader: null,\n payloadUrl: `${base}/series/${item.seriesId}`,\n observedAt: opts.observedAt ?? new Date().toISOString(),\n // Tsundoku's `updatedAt` is epoch seconds; a coverage change is the closest\n // thing the feed has to a publish date. Not skew-checked host-side.\n releasedAt: new Date(item.updatedAt * 1000).toISOString(),\n metadata: {\n tsundokuSeriesId: item.seriesId,\n canonicalTitle: item.canonicalTitle,\n highestVolume: item.highestVolume,\n highestChapter: item.highestChapter,\n },\n };\n}\n", "/**\n * Tsundoku series-feed fetcher.\n *\n * Wraps `fetch` against `POST {baseUrl}/api/v1/series/feed` with a hard\n * timeout and JSON parsing, returning a discriminated result so the caller\n * can act on a parsed page (`ok`) or surface the upstream status back to the\n * host's per-host backoff layer (`error`).\n *\n * We use the filtered `POST` variant \u2014 the body carries the consumer's\n * `provider:externalId` set so the feed returns only the tracked series, not\n * the whole catalog. The response is keyset-paginated: walk while `hasMore` is\n * true, passing `nextCursor` back as `cursor`. That cursor paginates *within a\n * single poll* and is not persisted \u2014 each poll re-walks the tracked set's\n * current coverage and relies on host-side dedup. Network and parsing are the\n * only side effects, which keeps it trivially testable with a mocked `fetch`.\n */\n\n// =============================================================================\n// Wire types (mirror Tsundoku's SeriesFeedResponse / SeriesFeedItem)\n// =============================================================================\n\n/** One provider mapping on a feed item (e.g. `{ provider: \"mangabaka\", ... }`). */\nexport interface FeedExternalId {\n provider: string;\n externalId: string;\n /** Epoch seconds the mapping was last fetched upstream. */\n fetchedAt: number;\n}\n\n/** One inclusive `[start, end]` coverage range (single values are `start === end`). */\nexport interface FeedCoverageSpan {\n start: number;\n end: number;\n}\n\n/** One series in the incremental release feed. */\nexport interface FeedItem {\n seriesId: number;\n canonicalTitle: string;\n /** Provider mappings the consumer matches on. */\n externalIds: FeedExternalId[];\n /** Merged available volume ranges (sorted, gaps preserved). */\n volumeCoverage: FeedCoverageSpan[];\n /** Merged available chapter ranges (sorted, gaps preserved). */\n chapterCoverage: FeedCoverageSpan[];\n /** Max end of `volumeCoverage`, or null when there is none. */\n highestVolume: number | null;\n /** Max end of `chapterCoverage`, or null when there is none. */\n highestChapter: number | null;\n /** Epoch seconds this series' coverage last changed (the cursor key). */\n updatedAt: number;\n}\n\n/** One page of the feed. */\nexport interface FeedResponse {\n items: FeedItem[];\n /** `true` when more series remain after this page (fetch again now). */\n hasMore: boolean;\n /** Opaque cursor at the last item, or null/absent when the page is empty. */\n nextCursor?: string | null;\n}\n\n// =============================================================================\n// Fetch result + options\n// =============================================================================\n\n/** Discriminated fetch result. */\nexport type FeedFetchResult =\n | { kind: \"ok\"; data: FeedResponse; status: 200 }\n | { kind: \"error\"; status: number; message: string };\n\nexport interface FeedFetcherOptions {\n /** Custom `fetch` impl (for testing). Defaults to global `fetch`. */\n fetchImpl?: typeof fetch;\n /** Per-request timeout. Defaults to 10s. */\n timeoutMs?: number;\n}\n\n/** Feed endpoint path appended to the configured base URL. */\nexport const FEED_PATH = \"/api/v1/series/feed\";\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\n\n/** Body for one `POST /series/feed` page. */\nexport interface FeedRequest {\n /**\n * `provider:externalId` filter \u2014 the feed is narrowed to series carrying one\n * of these. Must be non-empty (an empty list means \"no filter\" upstream,\n * i.e. the whole catalog \u2014 callers guard against that).\n */\n externalIds: string[];\n /** Pagination cursor within this poll. `null` starts at the beginning. */\n cursor: string | null;\n /** Page size (the caller clamps to 1..=500). */\n limit: number;\n}\n\n/** Build the feed endpoint URL (trailing slashes on `baseUrl` tolerated). */\nexport function feedUrl(baseUrl: string): string {\n return `${baseUrl.replace(/\\/+$/, \"\")}${FEED_PATH}`;\n}\n\n/**\n * Fetch one page of the filtered Tsundoku series feed via `POST`.\n *\n * We post the tracked `externalIds` set so the feed returns only the\n * consumer's series (not the whole catalog). The `cursor` is for pagination\n * *within a single poll* \u2014 it is not persisted across polls; each poll walks\n * the current coverage of the tracked set and relies on host-side dedup to\n * suppress unchanged releases.\n *\n * @param baseUrl - Tsundoku instance base URL (trailing slash tolerated).\n * @param req - Filter set + pagination cursor + page size.\n * @param opts - Fetcher options (custom fetch, timeout).\n */\nexport async function fetchFeedPage(\n baseUrl: string,\n req: FeedRequest,\n opts: FeedFetcherOptions = {},\n): Promise<FeedFetchResult> {\n const fetchImpl = opts.fetchImpl ?? globalThis.fetch;\n const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\n const url = feedUrl(baseUrl);\n const headers: Record<string, string> = {\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n \"User-Agent\": \"Codex-ReleaseTracker/1.0 (+https://github.com/AshDevFr/codex)\",\n };\n const body = JSON.stringify({\n externalIds: req.externalIds,\n cursor: req.cursor,\n limit: req.limit,\n });\n\n // AbortSignal.timeout is the cleanest path; we already require Node 22+.\n const signal = AbortSignal.timeout(timeoutMs);\n\n let resp: Response;\n try {\n resp = await fetchImpl(url, { method: \"POST\", headers, body, signal });\n } catch (err) {\n const msg = err instanceof Error ? err.message : \"Unknown fetch error\";\n // Aborts and transport-level failures map to 0/unavailable so the host's\n // per-host backoff can react without us inventing a fake HTTP status.\n return { kind: \"error\", status: 0, message: msg };\n }\n\n if (resp.status !== 200) {\n // Pass through 429 / 5xx so the host's backoff layer sees the real status.\n return {\n kind: \"error\",\n status: resp.status,\n message: `upstream returned ${resp.status} ${resp.statusText}`.trim(),\n };\n }\n\n let parsed: unknown;\n try {\n parsed = await resp.json();\n } catch (err) {\n const msg = err instanceof Error ? err.message : \"invalid JSON\";\n return { kind: \"error\", status: 200, message: `failed to parse feed JSON: ${msg}` };\n }\n\n if (!isFeedResponse(parsed)) {\n return { kind: \"error\", status: 200, message: \"malformed feed response: missing items[]\" };\n }\n\n return { kind: \"ok\", data: parsed, status: 200 };\n}\n\n/**\n * Minimal structural guard: a valid page must carry an `items` array and a\n * boolean `hasMore`. We don't deep-validate each item \u2014 the matcher tolerates\n * missing fields per-item rather than failing the whole page.\n */\nfunction isFeedResponse(value: unknown): value is FeedResponse {\n if (value === null || typeof value !== \"object\") return false;\n const obj = value as Record<string, unknown>;\n return Array.isArray(obj.items) && typeof obj.hasMore === \"boolean\";\n}\n", "{\n \"name\": \"@ashdev/codex-plugin-release-tsundoku\",\n \"version\": \"1.37.0\",\n \"description\": \"Tsundoku release-source plugin for Codex - announces new volume/chapter coverage for tracked series via the Tsundoku incremental series feed, matched by exact external IDs\",\n \"main\": \"dist/index.js\",\n \"bin\": \"dist/index.js\",\n \"type\": \"module\",\n \"files\": [\n \"dist\",\n \"README.md\"\n ],\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"https://github.com/AshDevFr/codex.git\",\n \"directory\": \"plugins/release-tsundoku\"\n },\n \"scripts\": {\n \"build\": \"esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --sourcemap --banner:js='#!/usr/bin/env node'\",\n \"dev\": \"npm run build -- --watch\",\n \"clean\": \"rm -rf dist\",\n \"start\": \"node dist/index.js\",\n \"lint\": \"biome check .\",\n \"lint:fix\": \"biome check --write .\",\n \"typecheck\": \"tsc --noEmit\",\n \"test\": \"vitest run --passWithNoTests\",\n \"test:watch\": \"vitest\",\n \"prepublishOnly\": \"npm run lint && npm run build\"\n },\n \"keywords\": [\n \"codex\",\n \"plugin\",\n \"tsundoku\",\n \"release-source\",\n \"manga\"\n ],\n \"author\": \"Codex\",\n \"license\": \"MIT\",\n \"engines\": {\n \"node\": \">=22.0.0\"\n },\n \"dependencies\": {\n \"@ashdev/codex-plugin-sdk\": \"^1.37.0\"\n },\n \"devDependencies\": {\n \"@biomejs/biome\": \"^2.4.4\",\n \"@types/node\": \"^22.0.0\",\n \"esbuild\": \"^0.27.3\",\n \"typescript\": \"^5.9.3\",\n \"vitest\": \"^4.0.18\"\n }\n}\n", "import type { PluginManifest } from \"@ashdev/codex-plugin-sdk\";\nimport packageJson from \"../package.json\" with { type: \"json\" };\n\n/**\n * Maps a Codex external-ID source name to the provider name the Tsundoku feed\n * uses. Codex stores some sources under different names than Tsundoku emits\n * (e.g. Codex `myanimelist` \u2194 Tsundoku `mal`), so we translate when building\n * the match index and the feed filter. Identity for names that already agree.\n *\n * The keys are the *bare* Codex source names \u2014 the host strips the stored\n * `api:` / `plugin:` prefix before matching `requiresExternalIds`, so a series\n * stored as `api:myanimelist` is delivered to us as `myanimelist`.\n */\nexport const CODEX_TO_TSUNDOKU_PROVIDER: Record<string, string> = {\n mangabaka: \"mangabaka\",\n anilist: \"anilist\",\n myanimelist: \"mal\",\n mangaupdates: \"mangaupdates\",\n kitsu: \"kitsu\",\n shikimori: \"shikimori\",\n animeplanet: \"anime_planet\",\n animenewsnetwork: \"anime_news_network\",\n};\n\n/**\n * The Codex source names the plugin asks the host for via\n * `requiresExternalIds`. These must be the names Codex *stores* (the map keys),\n * not Tsundoku's \u2014 the host filters `series_external_ids.source` against them.\n */\nexport const CODEX_EXTERNAL_ID_SOURCES = Object.keys(CODEX_TO_TSUNDOKU_PROVIDER);\n\nexport const manifest = {\n name: \"release-tsundoku\",\n displayName: \"Tsundoku Releases\",\n version: packageJson.version,\n description:\n \"Announces new volume/chapter coverage for tracked series via a Tsundoku instance's incremental series feed. Matches series by exact external IDs (no fuzzy matching) and walks the feed by cursor, persisting its position between polls.\",\n author: \"Codex\",\n homepage: \"https://github.com/AshDevFr/codex\",\n protocolVersion: \"1.1\",\n capabilities: {\n releaseSource: {\n kinds: [\"api-feed\"],\n requiresAliases: false,\n requiresExternalIds: [...CODEX_EXTERNAL_ID_SOURCES],\n canAnnounceChapters: true,\n canAnnounceVolumes: true,\n },\n },\n configSchema: {\n description:\n \"Tsundoku plugin configuration. Point `baseUrl` at your Tsundoku instance; the plugin polls its public `/api/v1/series/feed` endpoint and matches results to your tracked series by external ID.\",\n fields: [\n {\n key: \"baseUrl\",\n label: \"Tsundoku Base URL\",\n description:\n \"Base URL of the Tsundoku instance, e.g. `https://tsundoku.example.com`. The plugin appends `/api/v1/series/feed`. No trailing slash required.\",\n type: \"string\" as const,\n required: true,\n example: \"https://tsundoku.example.com\",\n },\n {\n key: \"defaultLanguage\",\n label: \"Default Language\",\n description:\n \"ISO 639-1 language tag stamped on every announcement. The Tsundoku feed tracks official release coverage and carries no language of its own, so a default is required. Per-series language preferences on each series' tracking config still gate the high-water mark host-side.\",\n type: \"string\" as const,\n required: false,\n default: \"en\",\n example: \"en\",\n },\n {\n key: \"pageLimit\",\n label: \"Feed Page Size\",\n description:\n \"Items requested per feed page (1\u2013500). Larger pages mean fewer round-trips when walking a long backlog. Defaults to 100.\",\n type: \"number\" as const,\n required: false,\n default: 100,\n },\n {\n key: \"requestTimeoutMs\",\n label: \"Request Timeout (ms)\",\n description:\n \"How long to wait for a single feed page before giving up. Defaults to 10000 (10 seconds).\",\n type: \"number\" as const,\n required: false,\n default: 10_000,\n },\n ],\n },\n userDescription:\n \"Announces new volumes and chapters for series you've tracked, using a Tsundoku instance as the source. Matches your series by external ID (MangaBaka, AniList, MAL, and more). Notification-only \u2014 Codex does not download anything.\",\n adminSetupInstructions:\n \"1. Set `baseUrl` to your Tsundoku instance URL (e.g. `https://tsundoku.example.com`) and save. The plugin auto-registers a single source row (`Tsundoku Releases`) in **Settings \u2192 Release tracking**, where you can disable it, change the poll interval, or hit *Poll now*. 2. To get announcements for a series, make sure it has at least one external ID Tsundoku also knows (MangaBaka, AniList, MAL, MangaUpdates, Kitsu, Shikimori, Anime-Planet, or Anime News Network) \u2014 populate these via a metadata refresh or by pasting them in the series tracking panel. 3. Optional: adjust `defaultLanguage` (default `en`), `pageLimit`, and `requestTimeoutMs`. The Tsundoku feed endpoint is public; no credentials are needed. Note: the feed is incremental, so newly tracked series only announce on their *next* Tsundoku coverage change.\",\n} as const satisfies PluginManifest & {\n capabilities: { releaseSource: { kinds: [\"api-feed\"] } };\n};\n", "/**\n * Match Tsundoku feed items to tracked Codex series by external ID \u2014 using\n * *weighted voting* across providers rather than trusting a single ID.\n *\n * Why voting: provider IDs vary in quality. MangaBaka is an aggregation hub\n * with reliably 1:1 IDs; others (MAL, MangaUpdates, \u2026) occasionally share or\n * merge IDs across distinct series, so a lone matching ID can be a false\n * positive. So for each candidate we tally the providers the feed item and the\n * Codex series *both* carry: a shared ID that agrees adds its weight, one that\n * disagrees subtracts it. A series matches only when agreement outweighs\n * disagreement \u2014 a trusted disagreement (e.g. different MangaBaka IDs) vetoes a\n * sloppy agreement (e.g. a shared MAL ID).\n *\n * Codex's `releases/record` keys on a `codexSeriesId`, so matching is done\n * here, plugin-side, over the full ID sets both the host and the feed expose.\n */\n\nimport type { TrackedSeriesEntry } from \"@ashdev/codex-plugin-sdk\";\nimport type { FeedItem } from \"./fetcher.js\";\nimport { CODEX_TO_TSUNDOKU_PROVIDER } from \"./manifest.js\";\n\n/**\n * Vote weight per provider \u2014 higher means more trusted as a match signal.\n * MangaBaka leads (its IDs are reliably 1:1), AniList next; the rest default\n * to 1. Tune here if real data shows a source is noisier than assumed.\n */\nexport const PROVIDER_WEIGHTS: Record<string, number> = {\n mangabaka: 3,\n anilist: 2,\n};\nconst DEFAULT_WEIGHT = 1;\n\nfunction weightOf(provider: string): number {\n return PROVIDER_WEIGHTS[provider] ?? DEFAULT_WEIGHT;\n}\n\n/** Result of resolving a feed item to a tracked Codex series. */\nexport interface MatchResult {\n /** The Codex series UUID the candidate should be recorded against. */\n codexSeriesId: string;\n /** Net vote score (agreeing weights minus disagreeing). Always `> 0`. */\n score: number;\n /** Host confidence in `[0.8, 1.0]`, derived from the score. */\n confidence: number;\n /** Providers that agreed, highest-weight first \u2014 used for the candidate `reason`. */\n agreeingProviders: string[];\n}\n\n/** Pre-computed lookup over the tracked series for matching. */\nexport interface MatchContext {\n /** `provider:id` -> codex series ids carrying it (usually one). */\n byKey: Map<string, string[]>;\n /** codex series id -> its `provider -> id` map (for the conflict tally). */\n series: Map<string, Map<string, string>>;\n}\n\n/** Compose the lookup key for a `(provider, externalId)` pair. */\nfunction indexKey(provider: string, externalId: string): string {\n return `${provider}:${externalId}`;\n}\n\n/**\n * Build the match context from the host's tracked-series rows. Entries without\n * external IDs contribute nothing.\n */\nexport function buildMatchContext(entries: TrackedSeriesEntry[]): MatchContext {\n const byKey = new Map<string, string[]>();\n const series = new Map<string, Map<string, string>>();\n\n for (const entry of entries) {\n const ids = entry.externalIds;\n if (!ids) continue;\n const map = new Map<string, string>();\n for (const [codexProvider, externalId] of Object.entries(ids)) {\n if (!externalId) continue;\n // Translate the Codex source name to Tsundoku's provider name so both\n // the index keys and the feed filter line up with what the feed emits\n // (e.g. Codex `myanimelist` -> Tsundoku `mal`).\n const provider = CODEX_TO_TSUNDOKU_PROVIDER[codexProvider] ?? codexProvider;\n map.set(provider, externalId);\n const key = indexKey(provider, externalId);\n const arr = byKey.get(key);\n if (arr) {\n arr.push(entry.seriesId);\n } else {\n byKey.set(key, [entry.seriesId]);\n }\n }\n if (map.size > 0) {\n series.set(entry.seriesId, map);\n }\n }\n\n return { byKey, series };\n}\n\n/**\n * The full set of `provider:id` keys across all tracked series. This is the\n * filter set posted to Tsundoku's `POST /series/feed` so the feed is narrowed\n * to the consumer's catalog.\n */\nexport function externalIdFilter(ctx: MatchContext): string[] {\n return [...ctx.byKey.keys()];\n}\n\n/** Map a net score to a host confidence in `[0.8, 1.0]` (gate is 0.7). */\nfunction confidenceForScore(score: number): number {\n return Math.min(1, Math.max(0.7, 0.7 + 0.1 * score));\n}\n\n/**\n * Resolve a feed item to the single best-matching tracked series, or `null`\n * when nothing matches net-positive or the top two candidates tie (ambiguous \u2014\n * the item's IDs point at two series equally well, so we can't safely pick).\n */\nexport function matchItem(item: FeedItem, ctx: MatchContext): MatchResult | null {\n const itemMap = new Map<string, string>();\n for (const ext of item.externalIds) {\n if (ext.externalId) {\n itemMap.set(ext.provider, ext.externalId);\n }\n }\n\n // Candidate Codex series: any that shares at least one id with the item.\n const candidates = new Set<string>();\n for (const [provider, id] of itemMap) {\n const arr = ctx.byKey.get(indexKey(provider, id));\n if (arr) {\n for (const sid of arr) candidates.add(sid);\n }\n }\n if (candidates.size === 0) return null;\n\n let best: MatchResult | null = null;\n let tiedAtBest = false;\n\n for (const cid of candidates) {\n const cSeries = ctx.series.get(cid);\n if (!cSeries) continue;\n\n let agree = 0;\n let disagree = 0;\n const agreeing: Array<{ provider: string; weight: number }> = [];\n for (const [provider, idVal] of itemMap) {\n const cVal = cSeries.get(provider);\n if (cVal === undefined) continue; // provider not shared by both\n const w = weightOf(provider);\n if (cVal === idVal) {\n agree += w;\n agreeing.push({ provider, weight: w });\n } else {\n disagree += w;\n }\n }\n\n const score = agree - disagree;\n if (score <= 0) continue; // disagreement outweighs (or ties) agreement\n\n if (!best || score > best.score) {\n agreeing.sort((a, b) => b.weight - a.weight || a.provider.localeCompare(b.provider));\n best = {\n codexSeriesId: cid,\n score,\n confidence: confidenceForScore(score),\n agreeingProviders: agreeing.map((a) => a.provider),\n };\n tiedAtBest = false;\n } else if (score === best.score) {\n tiedAtBest = true;\n }\n }\n\n // No net-positive candidate, or two series matched equally well \u2192 don't guess.\n if (!best || tiedAtBest) return null;\n return best;\n}\n", "/**\n * Tsundoku API-feed release-source plugin for Codex.\n *\n * Tsundoku exposes a series feed at `/api/v1/series/feed` carrying, per series,\n * the provider external IDs Codex matches on plus the merged volume/chapter\n * coverage. This plugin polls the **filtered** `POST` variant, matches each\n * returned series to a tracked Codex series by weighted external-ID voting, and\n * records release candidates.\n *\n * Each poll:\n * 1. Builds a match context from the host's `releases/list_tracked` rows\n * (scoped by `requiresExternalIds`) and derives the `provider:externalId`\n * filter set.\n * 2. `POST`s that filter to `/series/feed`, so the response contains only the\n * tracked series \u2014 not the whole catalog. There is no persisted cursor:\n * each poll re-walks the tracked set's current coverage and relies on\n * host-side dedup to suppress unchanged releases. This keeps newly\n * tracked series backfilled and untracked ones dropped, automatically.\n * 3. Matches each item (weighted voting), resolves cross-item (one feed entry\n * per Codex series), and records via `releases/record`.\n *\n * The fetch, matching, and candidate mapping live in dedicated modules\n * (`fetcher`, `matcher`, `candidate`); this entry point owns plugin lifecycle,\n * config, source registration, and the poll orchestration.\n */\n\nimport {\n createLogger,\n createReleaseSourcePlugin,\n type HostRpcClient,\n HostRpcError,\n type InitializeParams,\n RELEASES_METHODS,\n type ReleaseCandidate,\n type ReleasePollRequest,\n type ReleasePollResponse,\n type TrackedSeriesEntry,\n} from \"@ashdev/codex-plugin-sdk\";\nimport { feedItemToCandidate } from \"./candidate.js\";\nimport { type FeedItem, fetchFeedPage } from \"./fetcher.js\";\nimport { manifest } from \"./manifest.js\";\nimport { buildMatchContext, externalIdFilter, type MatchResult, matchItem } from \"./matcher.js\";\n\nconst logger = createLogger({ name: manifest.name, level: \"info\" });\n\n/** Default feed page size when config omits / mis-types `pageLimit`. */\nconst DEFAULT_PAGE_LIMIT = 100;\n/** Tsundoku caps the feed page size at 500. */\nconst MAX_PAGE_LIMIT = 500;\n/** Default per-request timeout when config omits / mis-types `requestTimeoutMs`. */\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst MIN_TIMEOUT_MS = 1_000;\nconst MAX_TIMEOUT_MS = 60_000;\nconst DEFAULT_LANGUAGE = \"en\";\n\n// =============================================================================\n// Plugin-level state (set during initialize)\n// =============================================================================\n\ninterface PluginState {\n hostRpc: HostRpcClient | null;\n /** Tsundoku instance base URL (no trailing slash), e.g. `https://t.example.com`. */\n baseUrl: string;\n /** ISO 639-1 tag stamped on every candidate (the feed carries none). */\n defaultLanguage: string;\n /** Feed page size (1..=MAX_PAGE_LIMIT). */\n pageLimit: number;\n /** Hard timeout for a single feed-page fetch. */\n requestTimeoutMs: number;\n}\n\nconst state: PluginState = {\n hostRpc: null,\n baseUrl: \"\",\n defaultLanguage: DEFAULT_LANGUAGE,\n pageLimit: DEFAULT_PAGE_LIMIT,\n requestTimeoutMs: DEFAULT_TIMEOUT_MS,\n};\n\n/** Reset state. Exported for tests; not part of the plugin contract. */\nexport function _resetState(): void {\n state.hostRpc = null;\n state.baseUrl = \"\";\n state.defaultLanguage = DEFAULT_LANGUAGE;\n state.pageLimit = DEFAULT_PAGE_LIMIT;\n state.requestTimeoutMs = DEFAULT_TIMEOUT_MS;\n}\n\n/** Strip a single trailing slash so URL building stays predictable. */\nexport function normalizeBaseUrl(raw: string): string {\n return raw.trim().replace(/\\/+$/, \"\");\n}\n\n// =============================================================================\n// Source registration\n// =============================================================================\n\n/**\n * Register the single static source row representing the Tsundoku feed. The\n * whole catalog is polled under one logical source keyed `default`.\n *\n * No retry needed: the host parks an early reverse-RPC on its readiness\n * barrier until the plugin's capabilities + handlers are installed, so this\n * single call resolves cleanly even when fired from `onInitialize`.\n */\nexport async function registerSources(\n rpc: HostRpcClient,\n): Promise<{ registered: number; pruned: number } | null> {\n const sources = [\n {\n sourceKey: \"default\",\n displayName: \"Tsundoku Releases\",\n kind: \"api-feed\" as const,\n config: null,\n },\n ];\n try {\n return await rpc.call<{ registered: number; pruned: number }>(\n RELEASES_METHODS.REGISTER_SOURCES,\n { sources },\n );\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err);\n logger.error(`register_sources failed: ${reason}`);\n return null;\n }\n}\n\n// =============================================================================\n// Reverse-RPC wrappers\n// =============================================================================\n\ninterface ListTrackedResponse {\n tracked: TrackedSeriesEntry[];\n nextOffset?: number;\n}\n\ninterface RecordResponse {\n ledgerId: string;\n deduped: boolean;\n}\n\n/** Page size for the tracked-series sweep that builds the match index. */\nconst TRACKED_PAGE_SIZE = 200;\n\n/**\n * Lazily walk all tracked-series pages from the host. Yields one entry at a\n * time so the caller can build the reverse index without materializing every\n * page at once.\n */\nasync function* iterateTrackedSeries(\n rpc: HostRpcClient,\n sourceId: string,\n): AsyncGenerator<TrackedSeriesEntry> {\n let offset = 0;\n while (true) {\n const page = await rpc.call<ListTrackedResponse>(RELEASES_METHODS.LIST_TRACKED, {\n sourceId,\n offset,\n limit: TRACKED_PAGE_SIZE,\n });\n for (const entry of page.tracked) {\n yield entry;\n }\n if (page.nextOffset === undefined || page.tracked.length === 0) return;\n offset = page.nextOffset;\n }\n}\n\n/**\n * Submit one candidate to the host ledger. Per-candidate failures (threshold\n * rejection, validation, transient host error) are logged and swallowed so a\n * single bad item never aborts the walk; the next poll retries it.\n */\nasync function recordCandidate(\n rpc: HostRpcClient,\n sourceId: string,\n candidate: ReleaseCandidate,\n): Promise<RecordResponse | null> {\n try {\n return await rpc.call<RecordResponse>(RELEASES_METHODS.RECORD, { sourceId, candidate });\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err);\n const code = err instanceof HostRpcError ? ` (code ${err.code})` : \"\";\n logger.warn(`record failed for ${candidate.externalReleaseId}: ${reason}${code}`);\n return null;\n }\n}\n\n/**\n * Best-effort progress emit. Failures (including older hosts without the\n * method) are swallowed \u2014 progress is a UX nicety, never a reason to abort.\n */\nasync function reportProgress(\n rpc: HostRpcClient,\n current: number,\n total: number,\n message: string,\n): Promise<void> {\n try {\n await rpc.call(RELEASES_METHODS.REPORT_PROGRESS, { current, total, message });\n } catch (err) {\n if (err instanceof HostRpcError && err.code === -32601) return;\n const reason = err instanceof Error ? err.message : String(err);\n logger.debug(`report_progress dropped: ${reason}`);\n }\n}\n\n// =============================================================================\n// Poll\n// =============================================================================\n\n/** Dependencies a poll needs, defaulted from plugin state at the call site. */\nexport interface PollDeps {\n /** Tsundoku base URL (no trailing slash). */\n baseUrl: string;\n /** Language stamped on every candidate. */\n language: string;\n /** Feed page size. */\n pageLimit: number;\n /** Per-page fetch timeout. */\n timeoutMs: number;\n /** Custom `fetch` impl (tests). */\n fetchImpl?: typeof fetch;\n}\n\n/**\n * Top-level poll handler.\n *\n * Builds the match context from the host's tracked series and posts their\n * `provider:externalId` set to Tsundoku's filtered feed, so the response\n * contains only the tracked series (not the whole catalog). It walks every\n * page of that filtered feed each poll \u2014 there is no persisted cursor; the\n * in-poll cursor only paginates the current response, and host-side dedup\n * suppresses unchanged releases. Matched items are resolved cross-item (one\n * feed entry per Codex series) and recorded. Exported for tests.\n */\nexport async function poll(\n params: ReleasePollRequest,\n rpc: HostRpcClient,\n deps: PollDeps,\n): Promise<ReleasePollResponse> {\n const sourceId = params.sourceId;\n\n // 1. Build the match context from the user's tracked series, and derive the\n // `provider:externalId` filter we post to Tsundoku.\n const trackedEntries: TrackedSeriesEntry[] = [];\n for await (const entry of iterateTrackedSeries(rpc, sourceId)) {\n trackedEntries.push(entry);\n }\n const ctx = buildMatchContext(trackedEntries);\n const externalIds = externalIdFilter(ctx);\n if (externalIds.length === 0) {\n // Nothing to query. Posting an empty filter would mean \"no filter\" upstream\n // (the whole catalog), so skip entirely instead.\n logger.info(\n `poll: no tracked series carry a Tsundoku-known external ID (source=${sourceId}); nothing to fetch`,\n );\n return {\n notModified: false,\n upstreamStatus: 200,\n parsed: 0,\n matched: 0,\n recorded: 0,\n deduped: 0,\n };\n }\n\n // 2. Walk the filtered feed, collecting per-item matches. We resolve them\n // after the walk (cross-item) rather than recording inline, so that when\n // several feed entries map to the same Codex series we keep only the best\n // one instead of polluting the ledger. The cursor here is ephemeral \u2014 it\n // paginates this poll's response and is never persisted.\n let cursor: string | null = null;\n let parsed = 0;\n let worstStatus = 200;\n let pagesFetched = 0;\n const hits: Array<{ item: FeedItem; match: MatchResult }> = [];\n\n while (true) {\n const result = await fetchFeedPage(\n deps.baseUrl,\n { externalIds, cursor, limit: deps.pageLimit },\n { timeoutMs: deps.timeoutMs, fetchImpl: deps.fetchImpl },\n );\n\n if (result.kind === \"error\") {\n worstStatus = Math.max(worstStatus, result.status);\n // Couldn't fetch even the first page: surface a hard failure so the host\n // records `last_error` and the source shows it (e.g. an unreachable or\n // misconfigured `baseUrl`). A mid-walk failure, by contrast, keeps the\n // pages already processed and just stops.\n if (pagesFetched === 0) {\n throw new Error(`feed fetch failed (status ${result.status}): ${result.message}`);\n }\n logger.warn(`feed fetch failed (status ${result.status}): ${result.message}; stopping walk`);\n break;\n }\n\n pagesFetched++;\n const page = result.data;\n for (const item of page.items) {\n parsed++;\n const match = matchItem(item, ctx);\n if (match) {\n hits.push({ item, match });\n }\n }\n\n await reportProgress(rpc, parsed, parsed, `Processed ${parsed} feed items`);\n\n const next = page.nextCursor ?? null;\n if (!page.hasMore) break;\n if (!next) {\n // hasMore with no advancing cursor would loop forever; stop defensively.\n logger.warn(\"feed reported hasMore but no nextCursor; stopping walk\");\n break;\n }\n if (page.items.length === 0) break;\n cursor = next;\n }\n\n // 3. Cross-item resolution: a Codex series should map to at most one feed\n // entry. Group hits by Codex series; keep the highest-scoring one. If the\n // top two tie (e.g. two entries match only via the same low-trust ID),\n // it's genuinely ambiguous \u2014 skip both rather than record the wrong one.\n const byCodex = new Map<string, Array<{ item: FeedItem; match: MatchResult }>>();\n for (const hit of hits) {\n const arr = byCodex.get(hit.match.codexSeriesId);\n if (arr) {\n arr.push(hit);\n } else {\n byCodex.set(hit.match.codexSeriesId, [hit]);\n }\n }\n\n let matched = 0;\n let recorded = 0;\n let deduped = 0;\n let ambiguous = 0;\n let superseded = 0;\n\n for (const [codexSeriesId, group] of byCodex) {\n // Best score first; for ties prefer the most recently updated entry (newest\n // coverage). The same Tsundoku series appearing twice in one walk is not a\n // conflict \u2014 only *different* series tying is.\n group.sort((a, b) => b.match.score - a.match.score || b.item.updatedAt - a.item.updatedAt);\n if (\n group.length > 1 &&\n group[0].match.score === group[1].match.score &&\n group[0].item.seriesId !== group[1].item.seriesId\n ) {\n ambiguous += group.length;\n logger.warn(\n `ambiguous: feed entries from different Tsundoku series match Codex series ${codexSeriesId} at score ${group[0].match.score}; skipping`,\n );\n continue;\n }\n superseded += group.length - 1;\n\n const { item, match } = group[0];\n matched++;\n const candidate = feedItemToCandidate(item, match, {\n baseUrl: deps.baseUrl,\n language: deps.language,\n });\n const outcome = await recordCandidate(rpc, sourceId, candidate);\n if (!outcome) continue;\n if (outcome.deduped) {\n deduped++;\n } else {\n recorded++;\n }\n }\n\n logger.info(\n `poll complete: source=${sourceId} tracked=${trackedEntries.length} parsed=${parsed} matched=${matched} recorded=${recorded} deduped=${deduped} ambiguous=${ambiguous} superseded=${superseded} worst_status=${worstStatus}`,\n );\n\n return {\n notModified: false,\n upstreamStatus: worstStatus,\n parsed,\n matched,\n recorded,\n deduped,\n };\n}\n\n// =============================================================================\n// Plugin Initialization\n// =============================================================================\n\ncreateReleaseSourcePlugin({\n manifest,\n provider: {\n async poll(params: ReleasePollRequest): Promise<ReleasePollResponse> {\n if (!state.hostRpc) {\n throw new Error(\"Plugin not initialized: host RPC client missing\");\n }\n if (!state.baseUrl) {\n throw new Error(\"Plugin not configured: baseUrl is required\");\n }\n return poll(params, state.hostRpc, {\n baseUrl: state.baseUrl,\n language: state.defaultLanguage,\n pageLimit: state.pageLimit,\n timeoutMs: state.requestTimeoutMs,\n });\n },\n },\n logLevel: \"info\",\n async onInitialize(params: InitializeParams) {\n state.hostRpc = params.hostRpc;\n\n const ac = params.adminConfig ?? {};\n if (typeof ac.baseUrl === \"string\") {\n state.baseUrl = normalizeBaseUrl(ac.baseUrl);\n }\n if (typeof ac.defaultLanguage === \"string\" && ac.defaultLanguage.trim().length > 0) {\n state.defaultLanguage = ac.defaultLanguage.trim().toLowerCase();\n }\n if (typeof ac.pageLimit === \"number\" && Number.isFinite(ac.pageLimit)) {\n state.pageLimit = Math.max(1, Math.min(Math.trunc(ac.pageLimit), MAX_PAGE_LIMIT));\n }\n if (typeof ac.requestTimeoutMs === \"number\" && Number.isFinite(ac.requestTimeoutMs)) {\n state.requestTimeoutMs = Math.max(\n MIN_TIMEOUT_MS,\n Math.min(ac.requestTimeoutMs, MAX_TIMEOUT_MS),\n );\n }\n\n if (!state.baseUrl) {\n logger.warn(\n \"initialized without a baseUrl \u2014 set it in the plugin config; polls will error until then\",\n );\n }\n logger.info(\n `initialized: baseUrl=${state.baseUrl || \"(unset)\"} defaultLanguage=${state.defaultLanguage} pageLimit=${state.pageLimit} timeoutMs=${state.requestTimeoutMs}`,\n );\n\n // Materialize the single static source row. Deferred to a microtask so we\n // run *after* the host installs the releases reverse-RPC handler.\n queueMicrotask(() => {\n void registerSources(params.hostRpc).then((result) => {\n if (result) {\n logger.info(`register_sources: registered=${result.registered} pruned=${result.pruned}`);\n }\n });\n });\n },\n});\n\nlogger.info(\"Tsundoku release-source plugin started\");\n"],
|
|
5
5
|
"mappings": "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",
|
|
6
6
|
"names": ["manifest", "logger", "response", "manifest", "DEFAULT_TIMEOUT_MS"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashdev/codex-plugin-release-tsundoku",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.37.0",
|
|
4
4
|
"description": "Tsundoku release-source plugin for Codex - announces new volume/chapter coverage for tracked series via the Tsundoku incremental series feed, matched by exact external IDs",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": "dist/index.js",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"node": ">=22.0.0"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@ashdev/codex-plugin-sdk": "
|
|
42
|
+
"@ashdev/codex-plugin-sdk": "^1.37.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@biomejs/biome": "^2.4.4",
|