@ontos-ai/knowhere-claw 0.1.0-beta.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,137 +1,53 @@
1
- # Knowhere OpenClaw Plugin
2
-
3
- Developer guide for the Knowhere plugin that integrates with OpenClaw.
4
-
5
- This repository is intentionally focused on runtime behavior, storage, and
6
- integration details. Agent-facing usage guidance lives in
7
- [`skills/knowhere/SKILL.md`](./skills/knowhere/SKILL.md).
8
-
9
- ## What this plugin does
10
-
11
- The plugin uses Knowhere for parsing and job orchestration, then stores the
12
- returned result package inside OpenClaw-managed local storage.
13
-
14
- Its responsibilities are:
15
-
16
- - register the `knowhere_*` tools
17
- - optionally auto-ingest supported attachments
18
- - persist extracted Knowhere result packages by scope
19
- - expose browse-first path, chunk, context, and raw-file access back to agents
20
- - inject compact document availability or status context when `autoGrounding` is enabled
21
-
22
- The package targets Node `>=22.12.0` and builds to `dist/` with Rolldown plus
23
- TypeScript declarations.
24
-
25
- ## Repository layout
26
-
27
- - [`src/index.ts`](./src/index.ts): plugin entrypoint and registration
28
- - [`src/config.ts`](./src/config.ts): config schema and resolution
29
- - [`src/client.ts`](./src/client.ts): Knowhere API client
30
- - [`src/store.ts`](./src/store.ts): scoped local storage and index maintenance
31
- - [`src/parser.ts`](./src/parser.ts): ZIP extraction and stored result readers
32
- - [`src/tools.ts`](./src/tools.ts): `knowhere_*` tool definitions and response formatting
33
- - [`src/hooks.ts`](./src/hooks.ts): auto-grounding and background attachment ingest
34
- - [`skills/knowhere/SKILL.md`](./skills/knowhere/SKILL.md): agent usage instructions
35
- - [`smoketest/run-tool.ts`](./smoketest/run-tool.ts): tool smoke-test entrypoint
36
- - [`openclaw.plugin.json`](./openclaw.plugin.json): plugin manifest, config schema, and bundled skill declaration
37
-
38
- ## Storage model
39
-
40
- The store is scope-aware. Supported `scopeMode` values are `session`, `agent`,
41
- and `global`.
42
-
43
- Each scope is stored under the resolved plugin storage directory with this
44
- shape:
45
-
46
- ```text
47
- <scope>/
48
- index.json
49
- documents/
50
- <docId>/
51
- metadata.json
52
- browse-index.json
53
- result/
54
- manifest.json
55
- chunks.json
56
- hierarchy.json
57
- full.md
58
- ...
59
- ```
60
-
61
- Storage roles:
62
-
63
- - `index.json`: internal per-scope cache of document summaries; rebuildable
64
- - `metadata.json`: plugin-local mapping layer for one stored document
65
- - `browse-index.json`: plugin-local browse index for path navigation and result-file inventory
66
- - `result/`: untouched extracted Knowhere result package files
67
-
68
- `index.json` is an optimization, not the primary source of truth. If its schema
69
- version no longer matches the current store code, the plugin rebuilds it from
70
- the per-document directories.
1
+ # Knowhere for OpenClaw
71
2
 
72
- ## Runtime model
3
+ Knowhere is an OpenClaw plugin that parses documents and URLs with Knowhere,
4
+ stores the extracted result package in OpenClaw state, and gives agents a
5
+ browse-first toolset for grounded document work.
73
6
 
74
- The plugin has three main runtime surfaces:
7
+ Quick mental model:
75
8
 
76
- - tools: explicit ingest, browse, raw-file read, job, preview, and cleanup operations
77
- - hooks: background attachment ingest plus prompt-time document/status injection
78
- - skill: agent guidance for when and how to use the tools
9
+ - Install the plugin
10
+ - Restart the Gateway
11
+ - Configure `plugins.entries."knowhere-claw".config`
12
+ - Ask an agent to read, search, or compare a document
13
+ - Let the bundled `knowhere` skill steer the agent toward the right tools
79
14
 
80
- The primary browse workflow is:
15
+ ## Where It Runs
81
16
 
82
- 1. `knowhere_read_result_file` on `manifest.json`
83
- 2. `knowhere_preview_document`
84
- 3. `knowhere_grep` for text search
85
- 4. `knowhere_read_result_file` again for `hierarchy.json`, `kb.csv`, or table HTML when needed
17
+ This plugin runs inside the OpenClaw Gateway process.
86
18
 
87
- `autoGrounding: true` enables hooks. `autoGrounding: false` leaves the plugin in
88
- manual tool mode while still loading the bundled skill.
19
+ If your agents talk to a remote Gateway, install and configure the plugin on
20
+ the machine running that Gateway, then restart that Gateway.
89
21
 
90
- ## Development
22
+ ## What You Get
91
23
 
92
- Install dependencies:
93
-
94
- ```bash
95
- pnpm install
96
- ```
24
+ - Ingest local files or document URLs with Knowhere
25
+ - Store parsed result packages inside OpenClaw-managed state
26
+ - Preview document structure, search chunks, and inspect raw result files
27
+ - Reuse stored documents across `session`, `agent`, or `global` scope
28
+ - Ship a bundled `knowhere` skill so agents prefer this toolchain for
29
+ document-heavy tasks
97
30
 
98
- Required validation after every code change:
31
+ ## Install
99
32
 
100
33
  ```bash
101
- pnpm fmt
102
- pnpm typecheck
103
- pnpm lint
104
- pnpm build
34
+ openclaw plugins install @ontos-ai/knowhere-claw
105
35
  ```
106
36
 
107
- Useful scripts:
37
+ Restart the Gateway afterwards.
108
38
 
109
- - `pnpm build`: bundle runtime code and emit declarations
110
- - `pnpm fmt`: format the repository with Oxc Formatter
111
- - `pnpm fmt:check`: check formatting without writing changes
112
- - `pnpm lint`: run Oxlint in type-aware mode
113
- - `pnpm lint:fix`: run Oxlint with fixes
114
- - `pnpm typecheck`: run `tsgo --noEmit`
115
- - `pnpm smoke:tools -- ...`: execute one plugin tool through the real registration path
116
- - `pnpm clean`: remove `dist/`
39
+ ## Config
117
40
 
118
- ## OpenClaw integration
119
-
120
- Minimal plugin config:
41
+ Set config under `plugins.entries."knowhere-claw".config`:
121
42
 
122
43
  ```json5
123
44
  {
124
45
  plugins: {
125
- load: {
126
- paths: ["/absolute/path/to/knowhere-openclaw-plugin"],
127
- },
128
46
  entries: {
129
- knowhere: {
47
+ "knowhere-claw": {
130
48
  enabled: true,
131
49
  config: {
132
- apiKey: "sk_...",
133
- scopeMode: "session",
134
- autoGrounding: true,
50
+ // apiKey is optional
135
51
  },
136
52
  },
137
53
  },
@@ -141,23 +57,80 @@ Minimal plugin config:
141
57
 
142
58
  Config notes:
143
59
 
144
- - `apiKey` falls back to `KNOWHERE_API_KEY`
145
- - `baseUrl` falls back to `KNOWHERE_BASE_URL`
146
- - `storageDir` defaults to the OpenClaw state directory under `plugins/<plugin-id>`
147
- - `scopeMode` controls document sharing boundaries
148
- - `maxContextChars`, polling, and timeout settings are declared in [`openclaw.plugin.json`](./openclaw.plugin.json)
149
-
150
- If you use skill filters in OpenClaw, allow the bundled `knowhere` skill or the
151
- agents will have the tools without the intended usage guidance.
152
-
153
- ## Packaging
154
-
155
- The published package includes:
156
-
157
- - `dist/`
158
- - `skills/`
159
- - [`openclaw.plugin.json`](./openclaw.plugin.json)
160
- - `README.md`
161
-
162
- That is enough for OpenClaw to load the plugin runtime and bundled skill from
163
- the installed package root.
60
+ - `apiKey`: optional Knowhere API key. If omitted, the plugin falls back to
61
+ `KNOWHERE_API_KEY` and any API key previously stored with
62
+ `knowhere_set_api_key`.
63
+ - If no API key is available when the agent first needs Knowhere, the plugin
64
+ guides the user to the Knowhere API key page at
65
+ `https://knowhereto.ai/api-keys`.
66
+ - `baseUrl`: optional Knowhere API base URL. Falls back to
67
+ `KNOWHERE_BASE_URL` and defaults to `https://api.knowhereto.ai`.
68
+ - `storageDir`: optional directory for persisted parsed documents. By default,
69
+ the plugin stores data under the OpenClaw state directory for
70
+ `knowhere-claw`.
71
+ - `scopeMode`: document sharing boundary. Supported values are `session`,
72
+ `agent`, and `global`. If omitted, the plugin defaults to `session`.
73
+ - `pollIntervalMs`, `pollTimeoutMs`, `requestTimeoutMs`, `uploadTimeoutMs`:
74
+ optional tuning for job polling, API calls, and large uploads.
75
+ - An explicit `storageDir` such as
76
+ `/home/<user>/.openclaw/plugin-state/knowhere` makes stored result packages
77
+ easier to inspect, back up, or clean up.
78
+
79
+ ## How OpenClaw Uses It
80
+
81
+ Once the plugin is enabled, you can ask an OpenClaw agent to:
82
+
83
+ - summarize a local document
84
+ - read a document URL
85
+ - find a table or section in a previously ingested document
86
+ - compare a new document with documents already stored in scope
87
+
88
+ The bundled `knowhere` skill teaches agents to use the `knowhere_*` tools
89
+ instead of raw file reads when document parsing matters.
90
+
91
+ If you use skill filters or allowlists in OpenClaw, keep the bundled
92
+ `knowhere` skill enabled or the tools will load without their intended usage
93
+ guidance.
94
+
95
+ If your agent runtime uses a tool allowlist, include `knowhere_*` so agents can
96
+ actually call the plugin tools.
97
+
98
+ ## Scope And Storage
99
+
100
+ `scopeMode` controls who can reuse parsed documents:
101
+
102
+ - `session`: only the current session can reuse the stored documents
103
+ - `agent`: all conversations for the same agent can reuse them
104
+ - `global`: all agents on the same Gateway can reuse them
105
+
106
+ Within each scope, the plugin keeps:
107
+
108
+ - an `index.json` cache of stored document summaries
109
+ - per-document metadata and browse indexes
110
+ - the extracted Knowhere result package under `result/`
111
+
112
+ ## Common Workflow
113
+
114
+ 1. Provide a file path or URL to the agent.
115
+ 2. The agent ingests it into Knowhere and waits for the parse to finish.
116
+ 3. Follow-up questions reuse stored results from the current scope.
117
+ 4. When needed, the agent can preview structure, search chunks, read raw result
118
+ files, or clear stored documents.
119
+
120
+ ## Troubleshooting
121
+
122
+ - Missing API key: `apiKey` config is optional. You can set
123
+ `plugins.entries."knowhere-claw".config.apiKey`, export
124
+ `KNOWHERE_API_KEY`, or let the plugin guide the user to
125
+ `https://knowhereto.ai/api-keys` and store the key with
126
+ `knowhere_set_api_key`.
127
+ - Documents are not shared between conversations: change `scopeMode` from
128
+ `session` to `agent` or `global`.
129
+ - The plugin is enabled but agents do not reach for Knowhere: check that the
130
+ bundled `knowhere` skill is not filtered out.
131
+ - Large documents time out: increase `pollTimeoutMs` or `uploadTimeoutMs`.
132
+
133
+ ## Developer Docs
134
+
135
+ Contributor-oriented architecture, workflow, and packaging notes live in
136
+ `DEVELOPMENT.md` at the repository root.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
2
+ import type { ChannelRouteRecord } from "./types";
3
+ type DeliverySurface = "discord" | "imessage" | "line" | "signal" | "slack" | "telegram" | "whatsapp";
4
+ type DeliveryResult = {
5
+ delivered: boolean;
6
+ surface?: DeliverySurface;
7
+ to?: string;
8
+ accountId?: string;
9
+ };
10
+ export declare function deliverChannelMessage(params: {
11
+ api: OpenClawPluginApi;
12
+ operationLabel: string;
13
+ context?: unknown;
14
+ sessionKey?: string;
15
+ messages?: unknown[];
16
+ channelRoute?: ChannelRouteRecord;
17
+ text: string;
18
+ mediaUrl?: string;
19
+ mediaLocalRoots?: readonly string[];
20
+ }): Promise<DeliveryResult>;
21
+ export {};
@@ -0,0 +1,337 @@
1
+ import { isRecord } from "./types.js";
2
+ import { normalizeWhitespace } from "./text.js";
3
+ import { findConversationSegmentValue, parseConversationSessionKey } from "./session.js";
4
+ import fs from "node:fs/promises";
5
+ //#region src/channel-delivery.ts
6
+ const DELIVERY_SURFACES = new Set([
7
+ "discord",
8
+ "imessage",
9
+ "line",
10
+ "signal",
11
+ "slack",
12
+ "telegram",
13
+ "whatsapp"
14
+ ]);
15
+ function readString(value) {
16
+ return normalizeWhitespace(value) || void 0;
17
+ }
18
+ function readInteger(value) {
19
+ const normalized = readString(value);
20
+ if (!normalized || !/^-?\d+$/.test(normalized)) return;
21
+ const parsed = Number(normalized);
22
+ return Number.isSafeInteger(parsed) ? parsed : void 0;
23
+ }
24
+ function normalizeDeliverySurface(value) {
25
+ const normalized = readString(value)?.toLowerCase();
26
+ if (!normalized || !DELIVERY_SURFACES.has(normalized)) return;
27
+ return normalized;
28
+ }
29
+ function readThreadValue(value) {
30
+ if (typeof value === "number" && Number.isFinite(value)) return Math.trunc(value);
31
+ return readString(value);
32
+ }
33
+ function parseSessionThreadInfo(sessionKey) {
34
+ const normalized = readString(sessionKey);
35
+ if (!normalized) return {};
36
+ const topicIndex = normalized.lastIndexOf(":topic:");
37
+ const threadIndex = normalized.lastIndexOf(":thread:");
38
+ const markerIndex = Math.max(topicIndex, threadIndex);
39
+ if (markerIndex < 0) return { baseSessionKey: normalized };
40
+ const marker = topicIndex > threadIndex ? ":topic:" : ":thread:";
41
+ return {
42
+ baseSessionKey: normalized.slice(0, markerIndex),
43
+ threadId: normalized.slice(markerIndex + marker.length).trim() || void 0
44
+ };
45
+ }
46
+ function buildSessionStoreLookupKeys(sessionKey) {
47
+ const normalized = readString(sessionKey);
48
+ if (!normalized) return [];
49
+ const { baseSessionKey } = parseSessionThreadInfo(normalized);
50
+ const candidates = [
51
+ normalized,
52
+ normalized.toLowerCase(),
53
+ baseSessionKey,
54
+ baseSessionKey?.toLowerCase()
55
+ ];
56
+ return [...new Set(candidates.filter((value) => Boolean(value)))];
57
+ }
58
+ function readSessionDelivery(value) {
59
+ const entry = isRecord(value) ? value : void 0;
60
+ const deliveryContext = isRecord(entry?.deliveryContext) ? entry.deliveryContext : void 0;
61
+ const surface = normalizeDeliverySurface(deliveryContext?.channel) || normalizeDeliverySurface(entry?.lastChannel) || normalizeDeliverySurface(entry?.channel);
62
+ const to = readString(deliveryContext?.to) || readString(entry?.lastTo);
63
+ const accountId = readString(deliveryContext?.accountId) || readString(entry?.lastAccountId);
64
+ const threadId = readThreadValue(deliveryContext?.threadId ?? entry?.lastThreadId);
65
+ if (!surface && !to && !accountId && threadId == null) return;
66
+ return {
67
+ surface,
68
+ to,
69
+ accountId,
70
+ threadId
71
+ };
72
+ }
73
+ async function resolveSessionDelivery(params) {
74
+ const sessionKey = readString(params.sessionKey);
75
+ if (!sessionKey) return;
76
+ try {
77
+ const parsedSession = parseConversationSessionKey(sessionKey);
78
+ const storePath = params.api.runtime.channel.session.resolveStorePath(params.api.config.session?.store, { agentId: parsedSession?.agentId });
79
+ const lookupKeys = buildSessionStoreLookupKeys(sessionKey);
80
+ params.api.logger.debug?.(`knowhere: ${params.operationLabel} session delivery lookup sessionKey=${sessionKey} storePath=${storePath} lookupKeys=${JSON.stringify(lookupKeys)}`);
81
+ const rawStore = await fs.readFile(storePath, "utf-8");
82
+ const store = JSON.parse(rawStore);
83
+ if (!isRecord(store)) {
84
+ params.api.logger.debug?.(`knowhere: ${params.operationLabel} session delivery store invalid sessionKey=${sessionKey} storePath=${storePath}`);
85
+ return;
86
+ }
87
+ for (const lookupKey of lookupKeys) {
88
+ const delivery = readSessionDelivery(store[lookupKey]);
89
+ if (delivery) {
90
+ params.api.logger.debug?.(`knowhere: ${params.operationLabel} session delivery resolved sessionKey=${sessionKey} matchedKey=${lookupKey} surface=${delivery.surface ?? "(none)"} to=${delivery.to ?? "(none)"} accountId=${delivery.accountId ?? "(none)"} threadId=${delivery.threadId ?? "(none)"}`);
91
+ return delivery;
92
+ }
93
+ }
94
+ params.api.logger.debug?.(`knowhere: ${params.operationLabel} session delivery not found sessionKey=${sessionKey} storePath=${storePath}`);
95
+ } catch (error) {
96
+ params.api.logger.debug?.(`knowhere: ${params.operationLabel} session delivery lookup failed${sessionKey ? ` sessionKey=${sessionKey}` : ""}. ${error instanceof Error ? error.message : String(error)}`);
97
+ }
98
+ }
99
+ function extractReplyReferenceFromValue(value, seen = /* @__PURE__ */ new WeakSet()) {
100
+ if (typeof value === "string") return readString(value.match(/"(?:messageId|message_id|replyTo|reply_to|threadTs|thread_ts|ts)"\s*:\s*"([^"]+)"/)?.[1]);
101
+ if (Array.isArray(value)) {
102
+ if (seen.has(value)) return;
103
+ seen.add(value);
104
+ for (let index = value.length - 1; index >= 0; index -= 1) {
105
+ const replyReference = extractReplyReferenceFromValue(value[index], seen);
106
+ if (replyReference) return replyReference;
107
+ }
108
+ return;
109
+ }
110
+ if (!isRecord(value)) return;
111
+ if (seen.has(value)) return;
112
+ seen.add(value);
113
+ for (const key of [
114
+ "messageId",
115
+ "message_id",
116
+ "replyTo",
117
+ "reply_to",
118
+ "threadTs",
119
+ "thread_ts",
120
+ "ts"
121
+ ]) {
122
+ const replyReference = readString(value[key]);
123
+ if (replyReference) return replyReference;
124
+ }
125
+ const preferredKeys = [
126
+ "content",
127
+ "text",
128
+ "body",
129
+ "message",
130
+ "messages",
131
+ "items"
132
+ ];
133
+ const preferredKeySet = new Set(preferredKeys);
134
+ for (const key of preferredKeys) {
135
+ const replyReference = extractReplyReferenceFromValue(value[key], seen);
136
+ if (replyReference) return replyReference;
137
+ }
138
+ for (const [key, nestedValue] of Object.entries(value).reverse()) {
139
+ if (preferredKeySet.has(key)) continue;
140
+ const replyReference = extractReplyReferenceFromValue(nestedValue, seen);
141
+ if (replyReference) return replyReference;
142
+ }
143
+ }
144
+ function resolveDeliveryTarget(params) {
145
+ const context = isRecord(params.context) ? params.context : void 0;
146
+ const session = parseConversationSessionKey(params.sessionKey);
147
+ const surface = normalizeDeliverySurface(context?.channelId) || normalizeDeliverySurface(context?.provider) || normalizeDeliverySurface(context?.surface) || normalizeDeliverySurface(params.channelRoute?.channelId) || params.sessionDelivery?.surface || normalizeDeliverySurface(session?.surface);
148
+ if (!surface) return;
149
+ const replyReference = readString(context?.messageId) || readString(context?.replyTo) || readString(context?.threadTs) || (params.messages ? extractReplyReferenceFromValue(params.messages) : void 0);
150
+ switch (surface) {
151
+ case "discord": {
152
+ const threadId = readString(context?.threadId) || findConversationSegmentValue(session, "thread") || readString(params.sessionDelivery?.threadId);
153
+ const channelId = findConversationSegmentValue(session, "channel", "guild");
154
+ const directId = findConversationSegmentValue(session, "direct", "user", "member");
155
+ const to = threadId && `channel:${threadId}` || readString(context?.conversationId) || params.channelRoute?.conversationId || params.sessionDelivery?.to || channelId && `channel:${channelId}` || directId && `user:${directId}`;
156
+ if (!to) return;
157
+ return {
158
+ surface,
159
+ to,
160
+ accountId: readString(context?.accountId) || findConversationSegmentValue(session, "account") || params.channelRoute?.accountId || params.sessionDelivery?.accountId,
161
+ replyTo: replyReference
162
+ };
163
+ }
164
+ case "slack": {
165
+ const directId = findConversationSegmentValue(session, "direct", "user", "member");
166
+ const channelId = findConversationSegmentValue(session, "channel", "group");
167
+ const to = readString(context?.conversationId) || params.channelRoute?.conversationId || params.sessionDelivery?.to || channelId || (directId ? `user:${directId}` : void 0);
168
+ if (!to) return;
169
+ return {
170
+ surface,
171
+ to,
172
+ accountId: readString(context?.accountId) || findConversationSegmentValue(session, "account") || params.channelRoute?.accountId || params.sessionDelivery?.accountId,
173
+ threadTs: readString(context?.threadTs) || replyReference || readString(params.sessionDelivery?.threadId)
174
+ };
175
+ }
176
+ case "telegram": {
177
+ const chatId = findConversationSegmentValue(session, "direct", "group", "channel", "chat", "room");
178
+ const to = readString(context?.conversationId) || params.channelRoute?.conversationId || params.sessionDelivery?.to || chatId;
179
+ if (!to) return;
180
+ return {
181
+ surface,
182
+ to,
183
+ accountId: readString(context?.accountId) || findConversationSegmentValue(session, "account") || params.channelRoute?.accountId || params.sessionDelivery?.accountId,
184
+ replyToMessageId: readInteger(replyReference),
185
+ messageThreadId: readInteger(context?.threadId) ?? (typeof params.sessionDelivery?.threadId === "number" ? params.sessionDelivery.threadId : readInteger(params.sessionDelivery?.threadId))
186
+ };
187
+ }
188
+ case "line": {
189
+ const directId = findConversationSegmentValue(session, "direct", "user", "member");
190
+ const groupId = findConversationSegmentValue(session, "group");
191
+ const roomId = findConversationSegmentValue(session, "room");
192
+ const chatId = findConversationSegmentValue(session, "channel", "chat", "space");
193
+ const to = readString(context?.conversationId) || params.channelRoute?.conversationId || params.sessionDelivery?.to || (groupId ? `line:group:${groupId}` : void 0) || (roomId ? `line:room:${roomId}` : void 0) || (directId ? `line:${directId}` : void 0) || (chatId ? `line:${chatId}` : void 0);
194
+ if (!to) return;
195
+ return {
196
+ surface,
197
+ to,
198
+ accountId: readString(context?.accountId) || findConversationSegmentValue(session, "account") || params.channelRoute?.accountId || params.sessionDelivery?.accountId,
199
+ replyToken: readString(context?.replyToken)
200
+ };
201
+ }
202
+ case "imessage":
203
+ case "signal":
204
+ case "whatsapp": {
205
+ const conversationId = findConversationSegmentValue(session, "direct", "group", "channel", "chat", "room");
206
+ const to = readString(context?.conversationId) || params.channelRoute?.conversationId || params.sessionDelivery?.to || conversationId;
207
+ if (!to) return;
208
+ return {
209
+ surface,
210
+ to,
211
+ accountId: readString(context?.accountId) || findConversationSegmentValue(session, "account") || params.channelRoute?.accountId || params.sessionDelivery?.accountId
212
+ };
213
+ }
214
+ }
215
+ }
216
+ function hasDeliveryResolutionHints(params) {
217
+ const context = isRecord(params.context) ? params.context : void 0;
218
+ if (normalizeDeliverySurface(context?.channelId) || normalizeDeliverySurface(context?.provider) || normalizeDeliverySurface(context?.surface) || normalizeDeliverySurface(params.channelRoute?.channelId) || parseConversationSessionKey(params.sessionKey)) return true;
219
+ return Boolean(readString(context?.conversationId) || readString(context?.threadId) || readString(context?.groupId) || readString(context?.messageId) || readString(context?.replyTo) || readString(context?.threadTs) || params.channelRoute?.conversationId);
220
+ }
221
+ function canSendMediaDirectly(target, mediaUrl) {
222
+ if (target.surface !== "line") return true;
223
+ return /^https?:\/\//i.test(mediaUrl.trim());
224
+ }
225
+ async function deliverChannelMessage(params) {
226
+ const sessionDelivery = await resolveSessionDelivery({
227
+ api: params.api,
228
+ sessionKey: params.sessionKey,
229
+ operationLabel: params.operationLabel
230
+ });
231
+ const target = resolveDeliveryTarget({
232
+ context: params.context,
233
+ sessionKey: params.sessionKey,
234
+ messages: params.messages,
235
+ channelRoute: params.channelRoute,
236
+ sessionDelivery
237
+ });
238
+ params.api.logger.debug?.(`knowhere: ${params.operationLabel} resolution sessionKey=${readString(params.sessionKey) ?? "(none)"} routeAccountId=${params.channelRoute?.accountId ?? "(none)"} routeConversationId=${params.channelRoute?.conversationId ?? "(none)"} sessionStoreAccountId=${sessionDelivery?.accountId ?? "(none)"} sessionStoreTo=${sessionDelivery?.to ?? "(none)"} resolvedSurface=${target?.surface ?? "(none)"} resolvedTo=${target?.to ?? "(none)"} resolvedAccountId=${target?.accountId ?? "(none)"}`);
239
+ if (!target) {
240
+ if (hasDeliveryResolutionHints({
241
+ context: params.context,
242
+ sessionKey: params.sessionKey,
243
+ channelRoute: params.channelRoute
244
+ })) {
245
+ const sessionKey = readString(params.sessionKey);
246
+ params.api.logger.warn(`knowhere: ${params.operationLabel} skipped because no target could be resolved${sessionKey ? ` sessionKey=${sessionKey}` : ""}`);
247
+ }
248
+ return { delivered: false };
249
+ }
250
+ const mediaUrl = readString(params.mediaUrl);
251
+ if (mediaUrl && !canSendMediaDirectly(target, mediaUrl)) {
252
+ params.api.logger.info(`knowhere: ${params.operationLabel} direct media delivery not supported for ${target.surface}; falling back`);
253
+ return {
254
+ delivered: false,
255
+ surface: target.surface,
256
+ to: target.to,
257
+ accountId: target.accountId
258
+ };
259
+ }
260
+ try {
261
+ params.api.logger.info(`knowhere: ${params.operationLabel} sending via ${target.surface} to ${target.to}${target.accountId ? ` accountId=${target.accountId}` : ""}${mediaUrl ? " media=yes" : " media=no"}`);
262
+ switch (target.surface) {
263
+ case "discord":
264
+ await params.api.runtime.channel.discord.sendMessageDiscord(target.to, params.text, {
265
+ accountId: target.accountId,
266
+ replyTo: target.replyTo,
267
+ ...mediaUrl ? { mediaUrl } : {},
268
+ ...params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}
269
+ });
270
+ break;
271
+ case "slack":
272
+ await params.api.runtime.channel.slack.sendMessageSlack(target.to, params.text, {
273
+ accountId: target.accountId,
274
+ threadTs: target.threadTs,
275
+ ...mediaUrl ? { mediaUrl } : {},
276
+ ...params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}
277
+ });
278
+ break;
279
+ case "telegram":
280
+ await params.api.runtime.channel.telegram.sendMessageTelegram(target.to, params.text, {
281
+ accountId: target.accountId,
282
+ messageThreadId: target.messageThreadId,
283
+ replyToMessageId: target.replyToMessageId,
284
+ ...mediaUrl ? { mediaUrl } : {},
285
+ ...params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}
286
+ });
287
+ break;
288
+ case "line":
289
+ await params.api.runtime.channel.line.sendMessageLine(target.to, params.text, {
290
+ accountId: target.accountId,
291
+ replyToken: target.replyToken,
292
+ verbose: false,
293
+ ...mediaUrl ? { mediaUrl } : {}
294
+ });
295
+ break;
296
+ case "whatsapp":
297
+ await params.api.runtime.channel.whatsapp.sendMessageWhatsApp(target.to, params.text, {
298
+ accountId: target.accountId,
299
+ verbose: false,
300
+ ...mediaUrl ? { mediaUrl } : {},
301
+ ...params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}
302
+ });
303
+ break;
304
+ case "signal":
305
+ await params.api.runtime.channel.signal.sendMessageSignal(target.to, params.text, {
306
+ accountId: target.accountId,
307
+ ...mediaUrl ? { mediaUrl } : {},
308
+ ...params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}
309
+ });
310
+ break;
311
+ case "imessage":
312
+ await params.api.runtime.channel.imessage.sendMessageIMessage(target.to, params.text, {
313
+ accountId: target.accountId,
314
+ ...mediaUrl ? { mediaUrl } : {},
315
+ ...params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}
316
+ });
317
+ break;
318
+ }
319
+ params.api.logger.info(`knowhere: ${params.operationLabel} sent via ${target.surface} to ${target.to}${target.accountId ? ` accountId=${target.accountId}` : ""}`);
320
+ return {
321
+ delivered: true,
322
+ surface: target.surface,
323
+ to: target.to,
324
+ accountId: target.accountId
325
+ };
326
+ } catch (error) {
327
+ params.api.logger.warn(`knowhere: ${params.operationLabel} send failed via ${target.surface} to ${target.to}${target.accountId ? ` accountId=${target.accountId}` : ""}. ${error instanceof Error ? error.message : String(error)}`);
328
+ return {
329
+ delivered: false,
330
+ surface: target.surface,
331
+ to: target.to,
332
+ accountId: target.accountId
333
+ };
334
+ }
335
+ }
336
+ //#endregion
337
+ export { deliverChannelMessage };
package/dist/config.d.ts CHANGED
@@ -3,4 +3,10 @@ import type { JsonSchemaObject, ResolvedKnowhereConfig } from "./types";
3
3
  export declare const DEFAULT_BASE_URL = "https://api.knowhereto.ai";
4
4
  export declare const knowherePluginConfigSchema: JsonSchemaObject;
5
5
  export declare function resolveKnowhereConfig(api: OpenClawPluginApi): ResolvedKnowhereConfig;
6
+ export declare const API_KEY_URL = "https://knowhereto.ai/api-keys";
7
+ export declare const PURCHASE_CREDITS_URL = "https://knowhereto.ai/usage?buy=true";
6
8
  export declare function assertKnowhereApiKey(config: ResolvedKnowhereConfig): void;
9
+ export declare function isPaymentRequiredError(error: unknown): boolean;
10
+ export declare function formatPaymentRequiredMessage(): string;
11
+ export declare function readPersistedApiKey(storageDir: string): Promise<string | null>;
12
+ export declare function persistApiKey(storageDir: string, apiKey: string): Promise<void>;