@openviking/openclaw-plugin 2026.5.21 → 2026.5.26

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/client.ts CHANGED
@@ -1,939 +1,939 @@
1
- import { randomUUID } from "node:crypto";
2
- import { once } from "node:events";
3
- import { createWriteStream } from "node:fs";
4
- import { mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises";
5
- import { tmpdir } from "node:os";
6
- import { basename, dirname, join, relative } from "node:path";
7
-
8
- import { Zip, ZipDeflate } from "fflate";
9
-
10
- export type FindResultItem = {
11
- uri: string;
12
- level?: number;
13
- abstract?: string;
14
- overview?: string;
15
- category?: string;
16
- score?: number;
17
- match_reason?: string;
18
- };
19
-
20
- export type FindResult = {
21
- memories?: FindResultItem[];
22
- resources?: FindResultItem[];
23
- skills?: FindResultItem[];
24
- total?: number;
25
- };
26
-
27
- export type CaptureMode = "semantic" | "keyword";
28
- export type ScopeName = "user" | "agent";
29
- export type AgentScopeMode = "user_agent" | "agent";
30
- export type RuntimeIdentity = {
31
- userId: string;
32
- agentId: string;
33
- };
34
- export type CommitSessionResult = {
35
- session_id: string;
36
- /** "accepted" (async), "completed", "failed", or "timeout" (wait mode). */
37
- status: string;
38
- task_id?: string;
39
- archive_uri?: string;
40
- archived?: boolean;
41
- /** Present when wait=true and extraction completed. Keyed by category. */
42
- memories_extracted?: Record<string, number>;
43
- error?: string;
44
- trace_id?: string;
45
- };
46
-
47
- export type TaskResult = {
48
- task_id: string;
49
- task_type: string;
50
- status: string;
51
- created_at: number;
52
- updated_at: number;
53
- resource_id?: string;
54
- result?: Record<string, unknown>;
55
- error?: string;
56
- };
57
-
58
- export type OVMessagePart = {
59
- type: string;
60
- text?: string;
61
- uri?: string;
62
- abstract?: string;
63
- context_type?: string;
64
- tool_id?: string;
65
- tool_name?: string;
66
- tool_input?: unknown;
67
- tool_output?: string;
68
- tool_status?: string;
69
- skill_uri?: string;
70
- duration_ms?: number;
71
- prompt_tokens?: number;
72
- completion_tokens?: number;
73
- tool_output_ref?: string;
74
- tool_output_truncated?: boolean;
75
- tool_output_original_chars?: number;
76
- tool_output_preview_chars?: number;
77
- tool_output_sha256?: string;
78
- tool_output_storage_uri?: string;
79
- tool_output_mime_type?: string;
80
- tool_output_source_ref?: string;
81
- tool_output_source_offset?: number;
82
- tool_output_source_limit?: number;
83
- tool_output_externalization_error?: string;
84
- tool_output_group_id?: string;
85
- tool_output_externalized_reason?: string;
86
- tool_output_group_original_chars?: number;
87
- tool_output_group_budget_chars?: number;
88
- };
89
-
90
- export type OVMessage = {
91
- id: string;
92
- role: string;
93
- parts: OVMessagePart[];
94
- created_at: string;
95
- };
96
-
97
- export type PreArchiveAbstract = {
98
- archive_id: string;
99
- abstract: string;
100
- };
101
-
102
- export type SessionContextResult = {
103
- latest_archive_overview: string;
104
- pre_archive_abstracts: PreArchiveAbstract[];
105
- messages: OVMessage[];
106
- estimatedTokens: number;
107
- stats: {
108
- totalArchives: number;
109
- includedArchives: number;
110
- droppedArchives: number;
111
- failedArchives: number;
112
- activeTokens: number;
113
- archiveTokens: number;
114
- };
115
- };
116
-
117
- export type ToolResultReadResult = {
118
- tool_result_id: string;
119
- content: string;
120
- offset: number;
121
- limit: number;
122
- offset_unit: "unicode_code_point";
123
- total_chars: number;
124
- has_more: boolean;
125
- metadata?: Record<string, unknown>;
126
- };
127
-
128
- export type ToolResultSearchResult = {
129
- tool_result_id: string;
130
- matches: Array<{
131
- offset: number;
132
- offset_unit: "unicode_code_point";
133
- snippet: string;
134
- }>;
135
- };
136
-
137
- export type ToolResultListResult = {
138
- tool_results: Array<Record<string, unknown>>;
139
- };
140
-
141
- export type SessionArchiveResult = {
142
- archive_id: string;
143
- abstract: string;
144
- overview: string;
145
- messages: OVMessage[];
146
- };
147
-
148
- export type AddResourceInput = {
149
- pathOrUrl: string;
150
- to?: string;
151
- parent?: string;
152
- reason?: string;
153
- instruction?: string;
154
- wait?: boolean;
155
- timeout?: number;
156
- strict?: boolean;
157
- ignoreDirs?: string;
158
- include?: string;
159
- exclude?: string;
160
- preserveStructure?: boolean;
161
- };
162
-
163
- export type AddResourceResult = {
164
- status?: string;
165
- root_uri?: string;
166
- temp_uri?: string;
167
- source_path?: string;
168
- warnings?: string[];
169
- errors?: string[];
170
- queue_status?: unknown;
171
- meta?: unknown;
172
- };
173
-
174
- export type AddSkillInput = {
175
- path?: string;
176
- data?: unknown;
177
- wait?: boolean;
178
- timeout?: number;
179
- };
180
-
181
- export type AddSkillResult = {
182
- status?: string;
183
- uri?: string;
184
- name?: string;
185
- auxiliary_files?: number;
186
- queue_status?: unknown;
187
- };
188
-
189
- const DEFAULT_WAIT_REQUEST_TIMEOUT_MS = 120_000;
190
- export const DEFAULT_PHASE2_POLL_TIMEOUT_MS = 300_000;
191
- const WAIT_REQUEST_TIMEOUT_BUFFER_MS = 5_000;
192
-
193
- function sleep(ms: number): Promise<void> {
194
- return new Promise((resolve) => setTimeout(resolve, ms));
195
- }
196
-
197
- const MEMORY_URI_PATTERNS = [
198
- /^viking:\/\/user\/(?:[^/]+(?:\/agent\/[^/]+)?\/)?memories(?:\/|$)/,
199
- /^viking:\/\/agent\/(?:[^/]+(?:\/user\/[^/]+)?\/)?memories(?:\/|$)/,
200
- ];
201
- const USER_STRUCTURE_DIRS = new Set(["memories", "profile.md", ".abstract.md", ".overview.md"]);
202
- const AGENT_STRUCTURE_DIRS = new Set([
203
- "memories",
204
- "skills",
205
- "instructions",
206
- "workspaces",
207
- ".abstract.md",
208
- ".overview.md",
209
- ]);
210
- const REMOTE_RESOURCE_PREFIXES = ["http://", "https://", "git@", "ssh://", "git://"];
211
-
212
- export function isMemoryUri(uri: string): boolean {
213
- return MEMORY_URI_PATTERNS.some((pattern) => pattern.test(uri));
214
- }
215
-
216
- function isRemoteResourceSource(source: string): boolean {
217
- return REMOTE_RESOURCE_PREFIXES.some((prefix) => source.startsWith(prefix));
218
- }
219
-
220
- function toBlobPart(value: Buffer): ArrayBuffer {
221
- return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) as ArrayBuffer;
222
- }
223
-
224
- function resolveWaitRequestTimeoutMs(defaultTimeoutMs: number, waitTimeoutSeconds?: number): number {
225
- const requestedMs =
226
- typeof waitTimeoutSeconds === "number" && Number.isFinite(waitTimeoutSeconds) && waitTimeoutSeconds > 0
227
- ? Math.ceil(waitTimeoutSeconds * 1000) + WAIT_REQUEST_TIMEOUT_BUFFER_MS
228
- : DEFAULT_WAIT_REQUEST_TIMEOUT_MS;
229
- return Math.max(defaultTimeoutMs, requestedMs);
230
- }
231
-
232
- async function cleanupUploadTempPath(path?: string): Promise<void> {
233
- if (!path) {
234
- return;
235
- }
236
- await rm(path, { force: true }).catch(() => undefined);
237
- await rm(dirname(path), { recursive: true, force: true }).catch(() => undefined);
238
- }
239
-
240
- export class OpenVikingClient {
241
- private identityCache = new Map<string, RuntimeIdentity>();
242
-
243
- constructor(
244
- private readonly baseUrl: string,
245
- private readonly apiKey: string,
246
- private readonly defaultAgentId: string,
247
- private readonly timeoutMs: number,
248
- /** When set, sent so ROOT keys or trusted deployments can select tenant identity. */
249
- private readonly accountId: string = "",
250
- private readonly userId: string = "",
251
- /** When set, logs routing for find + session writes (tenant headers + paths; never apiKey). */
252
- private readonly routingDebugLog?: (message: string) => void,
253
- private readonly isolateUserScopeByAgent = false,
254
- private readonly isolateAgentScopeByUser = true,
255
- ) {}
256
-
257
- getDefaultAgentId(): string {
258
- return this.defaultAgentId;
259
- }
260
-
261
- private resolveEffectiveAgentId(agentId?: string): string {
262
- const explicit = agentId?.trim();
263
- if (explicit) {
264
- return explicit;
265
- }
266
- const prefix = this.defaultAgentId.trim();
267
- return prefix ? `${prefix}_main` : "main";
268
- }
269
-
270
- async getResolvedIdentity(agentId?: string): Promise<RuntimeIdentity> {
271
- return this.getRuntimeIdentity(agentId);
272
- }
273
-
274
- private resolveTenantHeaders():
275
- | { apiKey?: string; accountId?: string; userId?: string }
276
- {
277
- const apiKey = this.apiKey.trim();
278
- const accountId = this.accountId.trim();
279
- const userId = this.userId.trim();
280
- return {
281
- ...(apiKey ? { apiKey } : {}),
282
- ...(accountId ? { accountId } : {}),
283
- ...(userId ? { userId } : {}),
284
- };
285
- }
286
-
287
- private async emitRoutingDebug(
288
- label: string,
289
- detail: Record<string, unknown>,
290
- agentId?: string,
291
- ): Promise<void> {
292
- if (!this.routingDebugLog) {
293
- return;
294
- }
295
- const effectiveAgentId = this.resolveEffectiveAgentId(agentId);
296
- const identity = await this.getRuntimeIdentity(agentId);
297
- const tenantHeaders = this.resolveTenantHeaders();
298
- this.routingDebugLog(
299
- `openviking: ${label} ` +
300
- JSON.stringify({
301
- ...detail,
302
- X_OpenViking_Agent: effectiveAgentId,
303
- X_OpenViking_Account: tenantHeaders.accountId ?? null,
304
- X_OpenViking_User: tenantHeaders.userId ?? null,
305
- resolved_user_id: identity.userId,
306
- session_vfs_hint: detail.sessionId
307
- ? `viking://session/${String(detail.sessionId)}`
308
- : undefined,
309
- }),
310
- );
311
- }
312
-
313
- private async request<T>(
314
- path: string,
315
- init: RequestInit = {},
316
- agentId?: string,
317
- requestTimeoutMs?: number,
318
- ): Promise<T> {
319
- const effectiveAgentId = this.resolveEffectiveAgentId(agentId);
320
- const controller = new AbortController();
321
- const timer = setTimeout(() => controller.abort(), requestTimeoutMs ?? this.timeoutMs);
322
- try {
323
- const headers = new Headers(init.headers ?? {});
324
- const tenantHeaders = this.resolveTenantHeaders();
325
- if (tenantHeaders.apiKey) {
326
- headers.set("X-API-Key", tenantHeaders.apiKey);
327
- }
328
- if (tenantHeaders.accountId) {
329
- headers.set("X-OpenViking-Account", tenantHeaders.accountId);
330
- }
331
- if (tenantHeaders.userId) {
332
- headers.set("X-OpenViking-User", tenantHeaders.userId);
333
- }
334
- if (effectiveAgentId) {
335
- headers.set("X-OpenViking-Agent", effectiveAgentId);
336
- }
337
- if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) {
338
- headers.set("Content-Type", "application/json");
339
- }
340
-
341
- const response = await fetch(`${this.baseUrl}${path}`, {
342
- ...init,
343
- headers,
344
- signal: controller.signal,
345
- });
346
-
347
- const payload = (await response.json().catch(() => ({}))) as {
348
- status?: string;
349
- result?: T;
350
- error?: { code?: string; message?: string };
351
- };
352
-
353
- if (!response.ok || payload.status === "error") {
354
- const code = payload.error?.code ? ` [${payload.error.code}]` : "";
355
- const message = payload.error?.message ?? `HTTP ${response.status}`;
356
- throw new Error(`OpenViking request failed${code}: ${message}`);
357
- }
358
-
359
- return (payload.result ?? payload) as T;
360
- } finally {
361
- clearTimeout(timer);
362
- }
363
- }
364
-
365
- async healthCheck(): Promise<void> {
366
- await this.request<{ status: string }>("/health");
367
- }
368
-
369
- private async getRuntimeIdentity(agentId?: string): Promise<RuntimeIdentity> {
370
- const effectiveAgentId = this.resolveEffectiveAgentId(agentId);
371
- const cached = this.identityCache.get(effectiveAgentId);
372
- if (cached) {
373
- return cached;
374
- }
375
- const fallback: RuntimeIdentity = { userId: "default", agentId: effectiveAgentId };
376
- try {
377
- const status = await this.request<{ user?: unknown }>("/api/v1/system/status", {}, agentId);
378
- const userId =
379
- typeof status.user === "string" && status.user.trim() ? status.user.trim() : "default";
380
- const identity: RuntimeIdentity = { userId, agentId: effectiveAgentId };
381
- this.identityCache.set(effectiveAgentId, identity);
382
- return identity;
383
- } catch {
384
- this.identityCache.set(effectiveAgentId, fallback);
385
- return fallback;
386
- }
387
- }
388
-
389
- private async buildCanonicalRoot(scope: ScopeName, agentId?: string): Promise<string> {
390
- const identity = await this.getRuntimeIdentity(agentId);
391
- if (scope === "user") {
392
- const root = this.isolateUserScopeByAgent
393
- ? `viking://user/${identity.userId}/agent/${identity.agentId}`
394
- : `viking://user/${identity.userId}`;
395
- return root;
396
- }
397
- const root = this.isolateAgentScopeByUser
398
- ? `viking://agent/${identity.agentId}/user/${identity.userId}`
399
- : `viking://agent/${identity.agentId}`;
400
- return root;
401
- }
402
-
403
- private async normalizeTargetUri(targetUri: string, agentId?: string): Promise<string> {
404
- const trimmed = targetUri.trim().replace(/\/+$/, "");
405
- const match = trimmed.match(/^viking:\/\/(user|agent)(?:\/(.*))?$/);
406
- if (!match) {
407
- return trimmed;
408
- }
409
- const scope = match[1] as ScopeName;
410
- const rawRest = (match[2] ?? "").trim();
411
- if (!rawRest) {
412
- return trimmed;
413
- }
414
- const parts = rawRest.split("/").filter(Boolean);
415
- if (parts.length === 0) {
416
- return trimmed;
417
- }
418
-
419
- const reservedDirs = scope === "user" ? USER_STRUCTURE_DIRS : AGENT_STRUCTURE_DIRS;
420
- if (!reservedDirs.has(parts[0]!)) {
421
- return trimmed;
422
- }
423
-
424
- const root = await this.buildCanonicalRoot(scope, agentId);
425
- return `${root}/${parts.join("/")}`;
426
- }
427
-
428
- async find(
429
- query: string,
430
- options: {
431
- targetUri: string;
432
- limit: number;
433
- scoreThreshold?: number;
434
- },
435
- agentId?: string,
436
- ): Promise<FindResult> {
437
- const normalizedTargetUri = await this.normalizeTargetUri(options.targetUri, agentId);
438
- const body = {
439
- query,
440
- target_uri: normalizedTargetUri,
441
- limit: options.limit,
442
- score_threshold: options.scoreThreshold,
443
- };
444
- const effectiveAgentId = this.resolveEffectiveAgentId(agentId);
445
- const identity = await this.getRuntimeIdentity(agentId);
446
- const tenantHeaders = this.resolveTenantHeaders();
447
- this.routingDebugLog?.(
448
- `openviking: find POST ${this.baseUrl}/api/v1/search/find ` +
449
- JSON.stringify({
450
- X_OpenViking_Agent: effectiveAgentId,
451
- X_OpenViking_Account: tenantHeaders.accountId ?? null,
452
- X_OpenViking_User: tenantHeaders.userId ?? null,
453
- resolved_user_id: identity.userId,
454
- target_uri: normalizedTargetUri,
455
- target_uri_input: options.targetUri,
456
- query:
457
- query.length > 4000
458
- ? `${query.slice(0, 4000)}…(+${query.length - 4000} more chars)`
459
- : query,
460
- limit: body.limit,
461
- score_threshold: body.score_threshold ?? null,
462
- }),
463
- );
464
- return this.request<FindResult>("/api/v1/search/find", {
465
- method: "POST",
466
- body: JSON.stringify(body),
467
- }, agentId);
468
- }
469
-
470
- async read(uri: string, agentId?: string): Promise<string> {
471
- return this.request<string>(
472
- `/api/v1/content/read?uri=${encodeURIComponent(uri)}`,
473
- {},
474
- agentId,
475
- );
476
- }
477
-
478
- async readToolResult(
479
- sessionId: string,
480
- toolResultId: string,
481
- options?: { offset?: number; limit?: number; includeMetadata?: boolean },
482
- agentId?: string,
483
- ): Promise<ToolResultReadResult> {
484
- const params = new URLSearchParams();
485
- if (options?.offset !== undefined) params.set("offset", String(options.offset));
486
- if (options?.limit !== undefined) params.set("limit", String(options.limit));
487
- if (options?.includeMetadata !== undefined) {
488
- params.set("include_metadata", String(options.includeMetadata));
489
- }
490
- const query = params.toString();
491
- return this.request<ToolResultReadResult>(
492
- `/api/v1/sessions/${encodeURIComponent(sessionId)}/tool-results/${encodeURIComponent(toolResultId)}${query ? `?${query}` : ""}`,
493
- {},
494
- agentId,
495
- );
496
- }
497
-
498
- async searchToolResult(
499
- sessionId: string,
500
- toolResultId: string,
501
- queryText: string,
502
- options?: { limit?: number; contextChars?: number },
503
- agentId?: string,
504
- ): Promise<ToolResultSearchResult> {
505
- const params = new URLSearchParams({ q: queryText });
506
- if (options?.limit !== undefined) params.set("limit", String(options.limit));
507
- if (options?.contextChars !== undefined) {
508
- params.set("context_chars", String(options.contextChars));
509
- }
510
- return this.request<ToolResultSearchResult>(
511
- `/api/v1/sessions/${encodeURIComponent(sessionId)}/tool-results/${encodeURIComponent(toolResultId)}/search?${params.toString()}`,
512
- {},
513
- agentId,
514
- );
515
- }
516
-
517
- async listToolResults(
518
- sessionId: string,
519
- options?: { toolName?: string; limit?: number },
520
- agentId?: string,
521
- ): Promise<ToolResultListResult> {
522
- const params = new URLSearchParams();
523
- if (options?.toolName) params.set("tool_name", options.toolName);
524
- if (options?.limit !== undefined) params.set("limit", String(options.limit));
525
- const query = params.toString();
526
- return this.request<ToolResultListResult>(
527
- `/api/v1/sessions/${encodeURIComponent(sessionId)}/tool-results${query ? `?${query}` : ""}`,
528
- {},
529
- agentId,
530
- );
531
- }
532
-
533
- async uploadTempFile(filePath: string, agentId?: string): Promise<string> {
534
- const fileBytes = await readFile(filePath);
535
- const form = new FormData();
536
- form.append(
537
- "file",
538
- new Blob([toBlobPart(fileBytes)], { type: "application/octet-stream" }),
539
- basename(filePath),
540
- );
541
- const result = await this.request<{ temp_file_id: string }>(
542
- "/api/v1/resources/temp_upload",
543
- { method: "POST", body: form },
544
- agentId,
545
- );
546
- if (!result.temp_file_id) {
547
- throw new Error("OpenViking temp upload did not return temp_file_id");
548
- }
549
- return result.temp_file_id;
550
- }
551
-
552
- async zipDirectoryForUpload(dirPath: string): Promise<string> {
553
- const rootStats = await stat(dirPath);
554
- if (!rootStats.isDirectory()) {
555
- throw new Error(`Not a directory: ${dirPath}`);
556
- }
557
-
558
- const zipDir = await mkdtemp(join(tmpdir(), "openviking-openclaw-upload-"));
559
- const zipPath = join(zipDir, `${basename(dirPath).replace(/[^a-zA-Z0-9._-]/g, "_")}-${randomUUID()}.zip`);
560
- const output = createWriteStream(zipPath);
561
- const outputClosed = once(output, "close");
562
- const outputErrored = once(output, "error").then(([err]) => Promise.reject(err));
563
- const zip = new Zip((err, chunk, final) => {
564
- if (err) {
565
- output.destroy(err);
566
- return;
567
- }
568
- if (chunk?.length) {
569
- output.write(Buffer.from(chunk));
570
- }
571
- if (final) {
572
- output.end();
573
- }
574
- });
575
-
576
- const walk = async (currentDir: string) => {
577
- const entries = await readdir(currentDir, { withFileTypes: true });
578
- for (const entry of entries) {
579
- const fullPath = join(currentDir, entry.name);
580
- if (entry.isDirectory()) {
581
- await walk(fullPath);
582
- continue;
583
- }
584
- if (!entry.isFile()) {
585
- continue;
586
- }
587
- const relPath = relative(dirPath, fullPath).replace(/\\/g, "/");
588
- if (!relPath || relPath.startsWith("../") || relPath.includes("/../")) {
589
- throw new Error(`Unsafe relative path while zipping: ${relPath}`);
590
- }
591
- const file = new ZipDeflate(relPath);
592
- zip.add(file);
593
- file.push(new Uint8Array(await readFile(fullPath)), true);
594
- }
595
- };
596
- try {
597
- await walk(dirPath);
598
- zip.end();
599
- await Promise.race([outputClosed, outputErrored]);
600
- } catch (err) {
601
- zip.terminate();
602
- output.destroy(err as Error);
603
- await cleanupUploadTempPath(zipPath);
604
- throw err;
605
- }
606
- return zipPath;
607
- }
608
-
609
- async addResource(input: AddResourceInput, agentId?: string): Promise<AddResourceResult> {
610
- const pathOrUrl = input.pathOrUrl.trim();
611
- if (!pathOrUrl) {
612
- throw new Error("pathOrUrl is required");
613
- }
614
- if (input.to && input.parent) {
615
- throw new Error("Cannot specify both 'to' and 'parent'.");
616
- }
617
-
618
- const body: Record<string, unknown> = {
619
- to: input.to,
620
- parent: input.parent,
621
- reason: input.reason ?? "",
622
- instruction: input.instruction ?? "",
623
- wait: input.wait ?? false,
624
- timeout: input.timeout,
625
- strict: input.strict ?? false,
626
- ignore_dirs: input.ignoreDirs,
627
- include: input.include,
628
- exclude: input.exclude,
629
- };
630
- if (typeof input.preserveStructure === "boolean") {
631
- body.preserve_structure = input.preserveStructure;
632
- }
633
-
634
- let cleanupPath: string | undefined;
635
- const requestTimeoutMs =
636
- input.wait ? resolveWaitRequestTimeoutMs(this.timeoutMs, input.timeout) : undefined;
637
- try {
638
- if (isRemoteResourceSource(pathOrUrl)) {
639
- body.path = pathOrUrl;
640
- } else {
641
- const localStats = await stat(pathOrUrl);
642
- let uploadPath = pathOrUrl;
643
- if (localStats.isDirectory()) {
644
- uploadPath = await this.zipDirectoryForUpload(pathOrUrl);
645
- cleanupPath = uploadPath;
646
- body.source_name = basename(pathOrUrl);
647
- } else if (!localStats.isFile()) {
648
- throw new Error(`Path is not a file or directory: ${pathOrUrl}`);
649
- }
650
- body.temp_file_id = await this.uploadTempFile(uploadPath, agentId);
651
- }
652
- return this.request<AddResourceResult>(
653
- "/api/v1/resources",
654
- { method: "POST", body: JSON.stringify(body) },
655
- agentId,
656
- requestTimeoutMs,
657
- );
658
- } finally {
659
- await cleanupUploadTempPath(cleanupPath);
660
- }
661
- }
662
-
663
- async addSkill(input: AddSkillInput, agentId?: string): Promise<AddSkillResult> {
664
- const hasPath = typeof input.path === "string" && input.path.trim().length > 0;
665
- const hasData = input.data !== undefined && input.data !== null;
666
- if (hasPath === hasData) {
667
- throw new Error("Provide exactly one of 'path' or 'data' for skill import.");
668
- }
669
-
670
- const body: Record<string, unknown> = {
671
- wait: input.wait ?? false,
672
- timeout: input.timeout,
673
- };
674
- let cleanupPath: string | undefined;
675
- const requestTimeoutMs =
676
- input.wait ? resolveWaitRequestTimeoutMs(this.timeoutMs, input.timeout) : undefined;
677
- try {
678
- if (hasPath) {
679
- const skillPath = input.path!.trim();
680
- const localStats = await stat(skillPath);
681
- let uploadPath = skillPath;
682
- if (localStats.isDirectory()) {
683
- uploadPath = await this.zipDirectoryForUpload(skillPath);
684
- cleanupPath = uploadPath;
685
- } else if (!localStats.isFile()) {
686
- throw new Error(`Path is not a file or directory: ${skillPath}`);
687
- }
688
- body.temp_file_id = await this.uploadTempFile(uploadPath, agentId);
689
- } else {
690
- body.data = input.data;
691
- }
692
- return this.request<AddSkillResult>(
693
- "/api/v1/skills",
694
- { method: "POST", body: JSON.stringify(body) },
695
- agentId,
696
- requestTimeoutMs,
697
- );
698
- } finally {
699
- await cleanupUploadTempPath(cleanupPath);
700
- }
701
- }
702
-
703
- async addSessionMessage(
704
- sessionId: string,
705
- role: string,
706
- parts: Array<{
707
- type: "text" | "tool" | "context";
708
- text?: string;
709
- tool_name?: string;
710
- tool_output?: string;
711
- tool_status?: string;
712
- tool_input?: Record<string, unknown>;
713
- tool_id?: string;
714
- tool_output_ref?: string;
715
- tool_output_truncated?: boolean;
716
- tool_output_original_chars?: number;
717
- tool_output_preview_chars?: number;
718
- tool_output_sha256?: string;
719
- tool_output_storage_uri?: string;
720
- tool_output_mime_type?: string;
721
- tool_output_source_ref?: string;
722
- tool_output_source_offset?: number;
723
- tool_output_source_limit?: number;
724
- tool_output_group_id?: string;
725
- tool_output_externalized_reason?: string;
726
- tool_output_group_original_chars?: number;
727
- tool_output_group_budget_chars?: number;
728
- uri?: string;
729
- abstract?: string;
730
- context_type?: "memory" | "resource" | "skill";
731
- }>,
732
- agentId?: string,
733
- createdAt?: string,
734
- roleId?: string,
735
- ): Promise<void> {
736
- const body: {
737
- role: string;
738
- role_id?: string;
739
- parts: typeof parts;
740
- created_at?: string;
741
- } = { role, parts };
742
- if (createdAt) {
743
- body.created_at = createdAt;
744
- }
745
- if (roleId) {
746
- body.role_id = roleId;
747
- }
748
- await this.emitRoutingDebug(
749
- "session message POST (with parts)",
750
- {
751
- path: `/api/v1/sessions/${encodeURIComponent(sessionId)}/messages`,
752
- sessionId,
753
- role,
754
- role_id: roleId ?? null,
755
- partCount: parts.length,
756
- created_at: createdAt ?? null,
757
- },
758
- agentId,
759
- );
760
- await this.request<{ session_id: string }>(
761
- `/api/v1/sessions/${encodeURIComponent(sessionId)}/messages`,
762
- {
763
- method: "POST",
764
- body: JSON.stringify(body),
765
- },
766
- agentId,
767
- );
768
- }
769
-
770
- /** GET session — server auto-creates if absent; returns session meta including message stats and token usage. */
771
- async getSession(sessionId: string, agentId?: string): Promise<{
772
- message_count?: number;
773
- commit_count?: number;
774
- last_commit_at?: string;
775
- pending_tokens?: number;
776
- llm_token_usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
777
- }> {
778
- return this.request<{
779
- message_count?: number;
780
- commit_count?: number;
781
- last_commit_at?: string;
782
- pending_tokens?: number;
783
- llm_token_usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
784
- }>(
785
- `/api/v1/sessions/${encodeURIComponent(sessionId)}`,
786
- { method: "GET" },
787
- agentId,
788
- );
789
- }
790
-
791
- /**
792
- * Commit a session: archive (Phase 1) and extract memories (Phase 2).
793
- *
794
- * wait=false (default): returns immediately after Phase 1 with task_id.
795
- * wait=true: after Phase 1, polls GET /tasks/{task_id} until Phase 2
796
- * completes (or times out), then returns the merged result.
797
- */
798
- async commitSession(
799
- sessionId: string,
800
- options?: {
801
- wait?: boolean;
802
- timeoutMs?: number;
803
- agentId?: string;
804
- /**
805
- * WM v2: number of most-recent messages to keep live after commit.
806
- * Forwarded as `keep_recent_count` in the POST body. 0 (default)
807
- * preserves the pre-v2 "archive everything" behavior.
808
- */
809
- keepRecentCount?: number;
810
- },
811
- ): Promise<CommitSessionResult> {
812
- const keepRecentCount =
813
- options?.keepRecentCount != null && Number.isFinite(options.keepRecentCount)
814
- ? Math.max(0, Math.floor(options.keepRecentCount))
815
- : 0;
816
- await this.emitRoutingDebug(
817
- "session commit POST (archive + memory extraction)",
818
- {
819
- path: `/api/v1/sessions/${encodeURIComponent(sessionId)}/commit`,
820
- sessionId,
821
- wait: options?.wait ?? false,
822
- keepRecentCount,
823
- },
824
- options?.agentId,
825
- );
826
- const body: Record<string, unknown> = {};
827
- if (keepRecentCount > 0) {
828
- body.keep_recent_count = keepRecentCount;
829
- }
830
- const result = await this.request<CommitSessionResult>(
831
- `/api/v1/sessions/${encodeURIComponent(sessionId)}/commit`,
832
- { method: "POST", body: JSON.stringify(body) },
833
- options?.agentId,
834
- );
835
-
836
- if (!options?.wait || !result.task_id) {
837
- return result;
838
- }
839
-
840
- // Client-side poll until Phase 2 finishes
841
- const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_PHASE2_POLL_TIMEOUT_MS);
842
- const pollInterval = 500;
843
- while (Date.now() < deadline) {
844
- await sleep(pollInterval);
845
- const task = await this.getTask(result.task_id, options.agentId).catch(() => null);
846
- if (!task) break;
847
- if (task.status === "completed") {
848
- const taskResult = (task.result ?? {}) as Record<string, unknown>;
849
- const memoriesExtracted = (taskResult.memories_extracted ?? {}) as Record<string, number>;
850
- result.status = "completed";
851
- result.memories_extracted = memoriesExtracted;
852
- return result;
853
- }
854
- if (task.status === "failed") {
855
- result.status = "failed";
856
- result.error = task.error;
857
- return result;
858
- }
859
- }
860
- result.status = "timeout";
861
- return result;
862
- }
863
-
864
- /** Poll a background task by ID. */
865
- async getTask(taskId: string, agentId?: string): Promise<TaskResult> {
866
- return this.request<TaskResult>(
867
- `/api/v1/tasks/${encodeURIComponent(taskId)}`,
868
- { method: "GET" },
869
- agentId,
870
- );
871
- }
872
-
873
- async getSessionContext(
874
- sessionId: string,
875
- tokenBudget: number = 128_000,
876
- agentId?: string,
877
- ): Promise<SessionContextResult> {
878
- return this.request(
879
- `/api/v1/sessions/${encodeURIComponent(sessionId)}/context?token_budget=${tokenBudget}`,
880
- { method: "GET" },
881
- agentId,
882
- );
883
- }
884
-
885
- async getSessionArchive(
886
- sessionId: string,
887
- archiveId: string,
888
- agentId?: string,
889
- ): Promise<SessionArchiveResult> {
890
- return this.request(
891
- `/api/v1/sessions/${encodeURIComponent(sessionId)}/archives/${encodeURIComponent(archiveId)}`,
892
- { method: "GET" },
893
- agentId,
894
- );
895
- }
896
-
897
- async grepSessionArchives(
898
- sessionId: string,
899
- pattern: string,
900
- options: {
901
- archiveId?: string;
902
- caseInsensitive?: boolean;
903
- nodeLimit?: number;
904
- levelLimit?: number;
905
- agentId?: string;
906
- } = {},
907
- ): Promise<{
908
- matches: Array<{ line: number; uri: string; content: string }>;
909
- count: number;
910
- match_count?: number;
911
- files_scanned?: number;
912
- }> {
913
- const baseUri = `viking://session/${sessionId}/history`;
914
- const uri = options.archiveId ? `${baseUri}/${options.archiveId}` : baseUri;
915
- return this.request(
916
- "/api/v1/search/grep",
917
- {
918
- method: "POST",
919
- body: JSON.stringify({
920
- uri,
921
- pattern,
922
- case_insensitive: options.caseInsensitive ?? true,
923
- ...(options.nodeLimit !== undefined ? { node_limit: options.nodeLimit } : {}),
924
- ...(options.levelLimit !== undefined ? { level_limit: options.levelLimit } : {}),
925
- }),
926
- },
927
- options.agentId,
928
- );
929
- }
930
-
931
- async deleteSession(sessionId: string, agentId?: string): Promise<void> {
932
- await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" }, agentId);
933
- }
934
- async deleteUri(uri: string, agentId?: string): Promise<void> {
935
- await this.request(`/api/v1/fs?uri=${encodeURIComponent(uri)}&recursive=false`, {
936
- method: "DELETE",
937
- }, agentId);
938
- }
939
- }
1
+ import { randomUUID } from "node:crypto";
2
+ import { once } from "node:events";
3
+ import { createWriteStream } from "node:fs";
4
+ import { mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { basename, dirname, join, relative } from "node:path";
7
+
8
+ import { Zip, ZipDeflate } from "fflate";
9
+
10
+ export type FindResultItem = {
11
+ uri: string;
12
+ level?: number;
13
+ abstract?: string;
14
+ overview?: string;
15
+ category?: string;
16
+ score?: number;
17
+ match_reason?: string;
18
+ };
19
+
20
+ export type FindResult = {
21
+ memories?: FindResultItem[];
22
+ resources?: FindResultItem[];
23
+ skills?: FindResultItem[];
24
+ total?: number;
25
+ };
26
+
27
+ export type CaptureMode = "semantic" | "keyword";
28
+ export type ScopeName = "user" | "agent";
29
+ export type AgentScopeMode = "user_agent" | "agent";
30
+ export type RuntimeIdentity = {
31
+ userId: string;
32
+ agentId: string;
33
+ };
34
+ export type CommitSessionResult = {
35
+ session_id: string;
36
+ /** "accepted" (async), "completed", "failed", or "timeout" (wait mode). */
37
+ status: string;
38
+ task_id?: string;
39
+ archive_uri?: string;
40
+ archived?: boolean;
41
+ /** Present when wait=true and extraction completed. Keyed by category. */
42
+ memories_extracted?: Record<string, number>;
43
+ error?: string;
44
+ trace_id?: string;
45
+ };
46
+
47
+ export type TaskResult = {
48
+ task_id: string;
49
+ task_type: string;
50
+ status: string;
51
+ created_at: number;
52
+ updated_at: number;
53
+ resource_id?: string;
54
+ result?: Record<string, unknown>;
55
+ error?: string;
56
+ };
57
+
58
+ export type OVMessagePart = {
59
+ type: string;
60
+ text?: string;
61
+ uri?: string;
62
+ abstract?: string;
63
+ context_type?: string;
64
+ tool_id?: string;
65
+ tool_name?: string;
66
+ tool_input?: unknown;
67
+ tool_output?: string;
68
+ tool_status?: string;
69
+ skill_uri?: string;
70
+ duration_ms?: number;
71
+ prompt_tokens?: number;
72
+ completion_tokens?: number;
73
+ tool_output_ref?: string;
74
+ tool_output_truncated?: boolean;
75
+ tool_output_original_chars?: number;
76
+ tool_output_preview_chars?: number;
77
+ tool_output_sha256?: string;
78
+ tool_output_storage_uri?: string;
79
+ tool_output_mime_type?: string;
80
+ tool_output_source_ref?: string;
81
+ tool_output_source_offset?: number;
82
+ tool_output_source_limit?: number;
83
+ tool_output_externalization_error?: string;
84
+ tool_output_group_id?: string;
85
+ tool_output_externalized_reason?: string;
86
+ tool_output_group_original_chars?: number;
87
+ tool_output_group_budget_chars?: number;
88
+ };
89
+
90
+ export type OVMessage = {
91
+ id: string;
92
+ role: string;
93
+ parts: OVMessagePart[];
94
+ created_at: string;
95
+ };
96
+
97
+ export type PreArchiveAbstract = {
98
+ archive_id: string;
99
+ abstract: string;
100
+ };
101
+
102
+ export type SessionContextResult = {
103
+ latest_archive_overview: string;
104
+ pre_archive_abstracts: PreArchiveAbstract[];
105
+ messages: OVMessage[];
106
+ estimatedTokens: number;
107
+ stats: {
108
+ totalArchives: number;
109
+ includedArchives: number;
110
+ droppedArchives: number;
111
+ failedArchives: number;
112
+ activeTokens: number;
113
+ archiveTokens: number;
114
+ };
115
+ };
116
+
117
+ export type ToolResultReadResult = {
118
+ tool_result_id: string;
119
+ content: string;
120
+ offset: number;
121
+ limit: number;
122
+ offset_unit: "unicode_code_point";
123
+ total_chars: number;
124
+ has_more: boolean;
125
+ metadata?: Record<string, unknown>;
126
+ };
127
+
128
+ export type ToolResultSearchResult = {
129
+ tool_result_id: string;
130
+ matches: Array<{
131
+ offset: number;
132
+ offset_unit: "unicode_code_point";
133
+ snippet: string;
134
+ }>;
135
+ };
136
+
137
+ export type ToolResultListResult = {
138
+ tool_results: Array<Record<string, unknown>>;
139
+ };
140
+
141
+ export type SessionArchiveResult = {
142
+ archive_id: string;
143
+ abstract: string;
144
+ overview: string;
145
+ messages: OVMessage[];
146
+ };
147
+
148
+ export type AddResourceInput = {
149
+ pathOrUrl: string;
150
+ to?: string;
151
+ parent?: string;
152
+ reason?: string;
153
+ instruction?: string;
154
+ wait?: boolean;
155
+ timeout?: number;
156
+ strict?: boolean;
157
+ ignoreDirs?: string;
158
+ include?: string;
159
+ exclude?: string;
160
+ preserveStructure?: boolean;
161
+ };
162
+
163
+ export type AddResourceResult = {
164
+ status?: string;
165
+ root_uri?: string;
166
+ temp_uri?: string;
167
+ source_path?: string;
168
+ warnings?: string[];
169
+ errors?: string[];
170
+ queue_status?: unknown;
171
+ meta?: unknown;
172
+ };
173
+
174
+ export type AddSkillInput = {
175
+ path?: string;
176
+ data?: unknown;
177
+ wait?: boolean;
178
+ timeout?: number;
179
+ };
180
+
181
+ export type AddSkillResult = {
182
+ status?: string;
183
+ uri?: string;
184
+ name?: string;
185
+ auxiliary_files?: number;
186
+ queue_status?: unknown;
187
+ };
188
+
189
+ const DEFAULT_WAIT_REQUEST_TIMEOUT_MS = 120_000;
190
+ export const DEFAULT_PHASE2_POLL_TIMEOUT_MS = 300_000;
191
+ const WAIT_REQUEST_TIMEOUT_BUFFER_MS = 5_000;
192
+
193
+ function sleep(ms: number): Promise<void> {
194
+ return new Promise((resolve) => setTimeout(resolve, ms));
195
+ }
196
+
197
+ const MEMORY_URI_PATTERNS = [
198
+ /^viking:\/\/user\/(?:[^/]+(?:\/agent\/[^/]+)?\/)?memories(?:\/|$)/,
199
+ /^viking:\/\/agent\/(?:[^/]+(?:\/user\/[^/]+)?\/)?memories(?:\/|$)/,
200
+ ];
201
+ const USER_STRUCTURE_DIRS = new Set(["memories", "profile.md", ".abstract.md", ".overview.md"]);
202
+ const AGENT_STRUCTURE_DIRS = new Set([
203
+ "memories",
204
+ "skills",
205
+ "instructions",
206
+ "workspaces",
207
+ ".abstract.md",
208
+ ".overview.md",
209
+ ]);
210
+ const REMOTE_RESOURCE_PREFIXES = ["http://", "https://", "git@", "ssh://", "git://"];
211
+
212
+ export function isMemoryUri(uri: string): boolean {
213
+ return MEMORY_URI_PATTERNS.some((pattern) => pattern.test(uri));
214
+ }
215
+
216
+ function isRemoteResourceSource(source: string): boolean {
217
+ return REMOTE_RESOURCE_PREFIXES.some((prefix) => source.startsWith(prefix));
218
+ }
219
+
220
+ function toBlobPart(value: Buffer): ArrayBuffer {
221
+ return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) as ArrayBuffer;
222
+ }
223
+
224
+ function resolveWaitRequestTimeoutMs(defaultTimeoutMs: number, waitTimeoutSeconds?: number): number {
225
+ const requestedMs =
226
+ typeof waitTimeoutSeconds === "number" && Number.isFinite(waitTimeoutSeconds) && waitTimeoutSeconds > 0
227
+ ? Math.ceil(waitTimeoutSeconds * 1000) + WAIT_REQUEST_TIMEOUT_BUFFER_MS
228
+ : DEFAULT_WAIT_REQUEST_TIMEOUT_MS;
229
+ return Math.max(defaultTimeoutMs, requestedMs);
230
+ }
231
+
232
+ async function cleanupUploadTempPath(path?: string): Promise<void> {
233
+ if (!path) {
234
+ return;
235
+ }
236
+ await rm(path, { force: true }).catch(() => undefined);
237
+ await rm(dirname(path), { recursive: true, force: true }).catch(() => undefined);
238
+ }
239
+
240
+ export class OpenVikingClient {
241
+ private identityCache = new Map<string, RuntimeIdentity>();
242
+
243
+ constructor(
244
+ private readonly baseUrl: string,
245
+ private readonly apiKey: string,
246
+ private readonly defaultAgentId: string,
247
+ private readonly timeoutMs: number,
248
+ /** When set, sent so ROOT keys or trusted deployments can select tenant identity. */
249
+ private readonly accountId: string = "",
250
+ private readonly userId: string = "",
251
+ /** When set, logs routing for find + session writes (tenant headers + paths; never apiKey). */
252
+ private readonly routingDebugLog?: (message: string) => void,
253
+ private readonly isolateUserScopeByAgent = false,
254
+ private readonly isolateAgentScopeByUser = true,
255
+ ) {}
256
+
257
+ getDefaultAgentId(): string {
258
+ return this.defaultAgentId;
259
+ }
260
+
261
+ private resolveEffectiveAgentId(agentId?: string): string {
262
+ const explicit = agentId?.trim();
263
+ if (explicit) {
264
+ return explicit;
265
+ }
266
+ const prefix = this.defaultAgentId.trim();
267
+ return prefix ? `${prefix}_main` : "main";
268
+ }
269
+
270
+ async getResolvedIdentity(agentId?: string): Promise<RuntimeIdentity> {
271
+ return this.getRuntimeIdentity(agentId);
272
+ }
273
+
274
+ private resolveTenantHeaders():
275
+ | { apiKey?: string; accountId?: string; userId?: string }
276
+ {
277
+ const apiKey = this.apiKey.trim();
278
+ const accountId = this.accountId.trim();
279
+ const userId = this.userId.trim();
280
+ return {
281
+ ...(apiKey ? { apiKey } : {}),
282
+ ...(accountId ? { accountId } : {}),
283
+ ...(userId ? { userId } : {}),
284
+ };
285
+ }
286
+
287
+ private async emitRoutingDebug(
288
+ label: string,
289
+ detail: Record<string, unknown>,
290
+ agentId?: string,
291
+ ): Promise<void> {
292
+ if (!this.routingDebugLog) {
293
+ return;
294
+ }
295
+ const effectiveAgentId = this.resolveEffectiveAgentId(agentId);
296
+ const identity = await this.getRuntimeIdentity(agentId);
297
+ const tenantHeaders = this.resolveTenantHeaders();
298
+ this.routingDebugLog(
299
+ `openviking: ${label} ` +
300
+ JSON.stringify({
301
+ ...detail,
302
+ X_OpenViking_Agent: effectiveAgentId,
303
+ X_OpenViking_Account: tenantHeaders.accountId ?? null,
304
+ X_OpenViking_User: tenantHeaders.userId ?? null,
305
+ resolved_user_id: identity.userId,
306
+ session_vfs_hint: detail.sessionId
307
+ ? `viking://session/${String(detail.sessionId)}`
308
+ : undefined,
309
+ }),
310
+ );
311
+ }
312
+
313
+ private async request<T>(
314
+ path: string,
315
+ init: RequestInit = {},
316
+ agentId?: string,
317
+ requestTimeoutMs?: number,
318
+ ): Promise<T> {
319
+ const effectiveAgentId = this.resolveEffectiveAgentId(agentId);
320
+ const controller = new AbortController();
321
+ const timer = setTimeout(() => controller.abort(), requestTimeoutMs ?? this.timeoutMs);
322
+ try {
323
+ const headers = new Headers(init.headers ?? {});
324
+ const tenantHeaders = this.resolveTenantHeaders();
325
+ if (tenantHeaders.apiKey) {
326
+ headers.set("X-API-Key", tenantHeaders.apiKey);
327
+ }
328
+ if (tenantHeaders.accountId) {
329
+ headers.set("X-OpenViking-Account", tenantHeaders.accountId);
330
+ }
331
+ if (tenantHeaders.userId) {
332
+ headers.set("X-OpenViking-User", tenantHeaders.userId);
333
+ }
334
+ if (effectiveAgentId) {
335
+ headers.set("X-OpenViking-Agent", effectiveAgentId);
336
+ }
337
+ if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) {
338
+ headers.set("Content-Type", "application/json");
339
+ }
340
+
341
+ const response = await fetch(`${this.baseUrl}${path}`, {
342
+ ...init,
343
+ headers,
344
+ signal: controller.signal,
345
+ });
346
+
347
+ const payload = (await response.json().catch(() => ({}))) as {
348
+ status?: string;
349
+ result?: T;
350
+ error?: { code?: string; message?: string };
351
+ };
352
+
353
+ if (!response.ok || payload.status === "error") {
354
+ const code = payload.error?.code ? ` [${payload.error.code}]` : "";
355
+ const message = payload.error?.message ?? `HTTP ${response.status}`;
356
+ throw new Error(`OpenViking request failed${code}: ${message}`);
357
+ }
358
+
359
+ return (payload.result ?? payload) as T;
360
+ } finally {
361
+ clearTimeout(timer);
362
+ }
363
+ }
364
+
365
+ async healthCheck(): Promise<void> {
366
+ await this.request<{ status: string }>("/health");
367
+ }
368
+
369
+ private async getRuntimeIdentity(agentId?: string): Promise<RuntimeIdentity> {
370
+ const effectiveAgentId = this.resolveEffectiveAgentId(agentId);
371
+ const cached = this.identityCache.get(effectiveAgentId);
372
+ if (cached) {
373
+ return cached;
374
+ }
375
+ const fallback: RuntimeIdentity = { userId: "default", agentId: effectiveAgentId };
376
+ try {
377
+ const status = await this.request<{ user?: unknown }>("/api/v1/system/status", {}, agentId);
378
+ const userId =
379
+ typeof status.user === "string" && status.user.trim() ? status.user.trim() : "default";
380
+ const identity: RuntimeIdentity = { userId, agentId: effectiveAgentId };
381
+ this.identityCache.set(effectiveAgentId, identity);
382
+ return identity;
383
+ } catch {
384
+ this.identityCache.set(effectiveAgentId, fallback);
385
+ return fallback;
386
+ }
387
+ }
388
+
389
+ private async buildCanonicalRoot(scope: ScopeName, agentId?: string): Promise<string> {
390
+ const identity = await this.getRuntimeIdentity(agentId);
391
+ if (scope === "user") {
392
+ const root = this.isolateUserScopeByAgent
393
+ ? `viking://user/${identity.userId}/agent/${identity.agentId}`
394
+ : `viking://user/${identity.userId}`;
395
+ return root;
396
+ }
397
+ const root = this.isolateAgentScopeByUser
398
+ ? `viking://agent/${identity.agentId}/user/${identity.userId}`
399
+ : `viking://agent/${identity.agentId}`;
400
+ return root;
401
+ }
402
+
403
+ private async normalizeTargetUri(targetUri: string, agentId?: string): Promise<string> {
404
+ const trimmed = targetUri.trim().replace(/\/+$/, "");
405
+ const match = trimmed.match(/^viking:\/\/(user|agent)(?:\/(.*))?$/);
406
+ if (!match) {
407
+ return trimmed;
408
+ }
409
+ const scope = match[1] as ScopeName;
410
+ const rawRest = (match[2] ?? "").trim();
411
+ if (!rawRest) {
412
+ return trimmed;
413
+ }
414
+ const parts = rawRest.split("/").filter(Boolean);
415
+ if (parts.length === 0) {
416
+ return trimmed;
417
+ }
418
+
419
+ const reservedDirs = scope === "user" ? USER_STRUCTURE_DIRS : AGENT_STRUCTURE_DIRS;
420
+ if (!reservedDirs.has(parts[0]!)) {
421
+ return trimmed;
422
+ }
423
+
424
+ const root = await this.buildCanonicalRoot(scope, agentId);
425
+ return `${root}/${parts.join("/")}`;
426
+ }
427
+
428
+ async find(
429
+ query: string,
430
+ options: {
431
+ targetUri: string;
432
+ limit: number;
433
+ scoreThreshold?: number;
434
+ },
435
+ agentId?: string,
436
+ ): Promise<FindResult> {
437
+ const normalizedTargetUri = await this.normalizeTargetUri(options.targetUri, agentId);
438
+ const body = {
439
+ query,
440
+ target_uri: normalizedTargetUri,
441
+ limit: options.limit,
442
+ score_threshold: options.scoreThreshold,
443
+ };
444
+ const effectiveAgentId = this.resolveEffectiveAgentId(agentId);
445
+ const identity = await this.getRuntimeIdentity(agentId);
446
+ const tenantHeaders = this.resolveTenantHeaders();
447
+ this.routingDebugLog?.(
448
+ `openviking: find POST ${this.baseUrl}/api/v1/search/find ` +
449
+ JSON.stringify({
450
+ X_OpenViking_Agent: effectiveAgentId,
451
+ X_OpenViking_Account: tenantHeaders.accountId ?? null,
452
+ X_OpenViking_User: tenantHeaders.userId ?? null,
453
+ resolved_user_id: identity.userId,
454
+ target_uri: normalizedTargetUri,
455
+ target_uri_input: options.targetUri,
456
+ query:
457
+ query.length > 4000
458
+ ? `${query.slice(0, 4000)}…(+${query.length - 4000} more chars)`
459
+ : query,
460
+ limit: body.limit,
461
+ score_threshold: body.score_threshold ?? null,
462
+ }),
463
+ );
464
+ return this.request<FindResult>("/api/v1/search/find", {
465
+ method: "POST",
466
+ body: JSON.stringify(body),
467
+ }, agentId);
468
+ }
469
+
470
+ async read(uri: string, agentId?: string): Promise<string> {
471
+ return this.request<string>(
472
+ `/api/v1/content/read?uri=${encodeURIComponent(uri)}`,
473
+ {},
474
+ agentId,
475
+ );
476
+ }
477
+
478
+ async readToolResult(
479
+ sessionId: string,
480
+ toolResultId: string,
481
+ options?: { offset?: number; limit?: number; includeMetadata?: boolean },
482
+ agentId?: string,
483
+ ): Promise<ToolResultReadResult> {
484
+ const params = new URLSearchParams();
485
+ if (options?.offset !== undefined) params.set("offset", String(options.offset));
486
+ if (options?.limit !== undefined) params.set("limit", String(options.limit));
487
+ if (options?.includeMetadata !== undefined) {
488
+ params.set("include_metadata", String(options.includeMetadata));
489
+ }
490
+ const query = params.toString();
491
+ return this.request<ToolResultReadResult>(
492
+ `/api/v1/sessions/${encodeURIComponent(sessionId)}/tool-results/${encodeURIComponent(toolResultId)}${query ? `?${query}` : ""}`,
493
+ {},
494
+ agentId,
495
+ );
496
+ }
497
+
498
+ async searchToolResult(
499
+ sessionId: string,
500
+ toolResultId: string,
501
+ queryText: string,
502
+ options?: { limit?: number; contextChars?: number },
503
+ agentId?: string,
504
+ ): Promise<ToolResultSearchResult> {
505
+ const params = new URLSearchParams({ q: queryText });
506
+ if (options?.limit !== undefined) params.set("limit", String(options.limit));
507
+ if (options?.contextChars !== undefined) {
508
+ params.set("context_chars", String(options.contextChars));
509
+ }
510
+ return this.request<ToolResultSearchResult>(
511
+ `/api/v1/sessions/${encodeURIComponent(sessionId)}/tool-results/${encodeURIComponent(toolResultId)}/search?${params.toString()}`,
512
+ {},
513
+ agentId,
514
+ );
515
+ }
516
+
517
+ async listToolResults(
518
+ sessionId: string,
519
+ options?: { toolName?: string; limit?: number },
520
+ agentId?: string,
521
+ ): Promise<ToolResultListResult> {
522
+ const params = new URLSearchParams();
523
+ if (options?.toolName) params.set("tool_name", options.toolName);
524
+ if (options?.limit !== undefined) params.set("limit", String(options.limit));
525
+ const query = params.toString();
526
+ return this.request<ToolResultListResult>(
527
+ `/api/v1/sessions/${encodeURIComponent(sessionId)}/tool-results${query ? `?${query}` : ""}`,
528
+ {},
529
+ agentId,
530
+ );
531
+ }
532
+
533
+ async uploadTempFile(filePath: string, agentId?: string): Promise<string> {
534
+ const fileBytes = await readFile(filePath);
535
+ const form = new FormData();
536
+ form.append(
537
+ "file",
538
+ new Blob([toBlobPart(fileBytes)], { type: "application/octet-stream" }),
539
+ basename(filePath),
540
+ );
541
+ const result = await this.request<{ temp_file_id: string }>(
542
+ "/api/v1/resources/temp_upload",
543
+ { method: "POST", body: form },
544
+ agentId,
545
+ );
546
+ if (!result.temp_file_id) {
547
+ throw new Error("OpenViking temp upload did not return temp_file_id");
548
+ }
549
+ return result.temp_file_id;
550
+ }
551
+
552
+ async zipDirectoryForUpload(dirPath: string): Promise<string> {
553
+ const rootStats = await stat(dirPath);
554
+ if (!rootStats.isDirectory()) {
555
+ throw new Error(`Not a directory: ${dirPath}`);
556
+ }
557
+
558
+ const zipDir = await mkdtemp(join(tmpdir(), "openviking-openclaw-upload-"));
559
+ const zipPath = join(zipDir, `${basename(dirPath).replace(/[^a-zA-Z0-9._-]/g, "_")}-${randomUUID()}.zip`);
560
+ const output = createWriteStream(zipPath);
561
+ const outputClosed = once(output, "close");
562
+ const outputErrored = once(output, "error").then(([err]) => Promise.reject(err));
563
+ const zip = new Zip((err, chunk, final) => {
564
+ if (err) {
565
+ output.destroy(err);
566
+ return;
567
+ }
568
+ if (chunk?.length) {
569
+ output.write(Buffer.from(chunk));
570
+ }
571
+ if (final) {
572
+ output.end();
573
+ }
574
+ });
575
+
576
+ const walk = async (currentDir: string) => {
577
+ const entries = await readdir(currentDir, { withFileTypes: true });
578
+ for (const entry of entries) {
579
+ const fullPath = join(currentDir, entry.name);
580
+ if (entry.isDirectory()) {
581
+ await walk(fullPath);
582
+ continue;
583
+ }
584
+ if (!entry.isFile()) {
585
+ continue;
586
+ }
587
+ const relPath = relative(dirPath, fullPath).replace(/\\/g, "/");
588
+ if (!relPath || relPath.startsWith("../") || relPath.includes("/../")) {
589
+ throw new Error(`Unsafe relative path while zipping: ${relPath}`);
590
+ }
591
+ const file = new ZipDeflate(relPath);
592
+ zip.add(file);
593
+ file.push(new Uint8Array(await readFile(fullPath)), true);
594
+ }
595
+ };
596
+ try {
597
+ await walk(dirPath);
598
+ zip.end();
599
+ await Promise.race([outputClosed, outputErrored]);
600
+ } catch (err) {
601
+ zip.terminate();
602
+ output.destroy(err as Error);
603
+ await cleanupUploadTempPath(zipPath);
604
+ throw err;
605
+ }
606
+ return zipPath;
607
+ }
608
+
609
+ async addResource(input: AddResourceInput, agentId?: string): Promise<AddResourceResult> {
610
+ const pathOrUrl = input.pathOrUrl.trim();
611
+ if (!pathOrUrl) {
612
+ throw new Error("pathOrUrl is required");
613
+ }
614
+ if (input.to && input.parent) {
615
+ throw new Error("Cannot specify both 'to' and 'parent'.");
616
+ }
617
+
618
+ const body: Record<string, unknown> = {
619
+ to: input.to,
620
+ parent: input.parent,
621
+ reason: input.reason ?? "",
622
+ instruction: input.instruction ?? "",
623
+ wait: input.wait ?? false,
624
+ timeout: input.timeout,
625
+ strict: input.strict ?? false,
626
+ ignore_dirs: input.ignoreDirs,
627
+ include: input.include,
628
+ exclude: input.exclude,
629
+ };
630
+ if (typeof input.preserveStructure === "boolean") {
631
+ body.preserve_structure = input.preserveStructure;
632
+ }
633
+
634
+ let cleanupPath: string | undefined;
635
+ const requestTimeoutMs =
636
+ input.wait ? resolveWaitRequestTimeoutMs(this.timeoutMs, input.timeout) : undefined;
637
+ try {
638
+ if (isRemoteResourceSource(pathOrUrl)) {
639
+ body.path = pathOrUrl;
640
+ } else {
641
+ const localStats = await stat(pathOrUrl);
642
+ let uploadPath = pathOrUrl;
643
+ if (localStats.isDirectory()) {
644
+ uploadPath = await this.zipDirectoryForUpload(pathOrUrl);
645
+ cleanupPath = uploadPath;
646
+ body.source_name = basename(pathOrUrl);
647
+ } else if (!localStats.isFile()) {
648
+ throw new Error(`Path is not a file or directory: ${pathOrUrl}`);
649
+ }
650
+ body.temp_file_id = await this.uploadTempFile(uploadPath, agentId);
651
+ }
652
+ return this.request<AddResourceResult>(
653
+ "/api/v1/resources",
654
+ { method: "POST", body: JSON.stringify(body) },
655
+ agentId,
656
+ requestTimeoutMs,
657
+ );
658
+ } finally {
659
+ await cleanupUploadTempPath(cleanupPath);
660
+ }
661
+ }
662
+
663
+ async addSkill(input: AddSkillInput, agentId?: string): Promise<AddSkillResult> {
664
+ const hasPath = typeof input.path === "string" && input.path.trim().length > 0;
665
+ const hasData = input.data !== undefined && input.data !== null;
666
+ if (hasPath === hasData) {
667
+ throw new Error("Provide exactly one of 'path' or 'data' for skill import.");
668
+ }
669
+
670
+ const body: Record<string, unknown> = {
671
+ wait: input.wait ?? false,
672
+ timeout: input.timeout,
673
+ };
674
+ let cleanupPath: string | undefined;
675
+ const requestTimeoutMs =
676
+ input.wait ? resolveWaitRequestTimeoutMs(this.timeoutMs, input.timeout) : undefined;
677
+ try {
678
+ if (hasPath) {
679
+ const skillPath = input.path!.trim();
680
+ const localStats = await stat(skillPath);
681
+ let uploadPath = skillPath;
682
+ if (localStats.isDirectory()) {
683
+ uploadPath = await this.zipDirectoryForUpload(skillPath);
684
+ cleanupPath = uploadPath;
685
+ } else if (!localStats.isFile()) {
686
+ throw new Error(`Path is not a file or directory: ${skillPath}`);
687
+ }
688
+ body.temp_file_id = await this.uploadTempFile(uploadPath, agentId);
689
+ } else {
690
+ body.data = input.data;
691
+ }
692
+ return this.request<AddSkillResult>(
693
+ "/api/v1/skills",
694
+ { method: "POST", body: JSON.stringify(body) },
695
+ agentId,
696
+ requestTimeoutMs,
697
+ );
698
+ } finally {
699
+ await cleanupUploadTempPath(cleanupPath);
700
+ }
701
+ }
702
+
703
+ async addSessionMessage(
704
+ sessionId: string,
705
+ role: string,
706
+ parts: Array<{
707
+ type: "text" | "tool" | "context";
708
+ text?: string;
709
+ tool_name?: string;
710
+ tool_output?: string;
711
+ tool_status?: string;
712
+ tool_input?: Record<string, unknown>;
713
+ tool_id?: string;
714
+ tool_output_ref?: string;
715
+ tool_output_truncated?: boolean;
716
+ tool_output_original_chars?: number;
717
+ tool_output_preview_chars?: number;
718
+ tool_output_sha256?: string;
719
+ tool_output_storage_uri?: string;
720
+ tool_output_mime_type?: string;
721
+ tool_output_source_ref?: string;
722
+ tool_output_source_offset?: number;
723
+ tool_output_source_limit?: number;
724
+ tool_output_group_id?: string;
725
+ tool_output_externalized_reason?: string;
726
+ tool_output_group_original_chars?: number;
727
+ tool_output_group_budget_chars?: number;
728
+ uri?: string;
729
+ abstract?: string;
730
+ context_type?: "memory" | "resource" | "skill";
731
+ }>,
732
+ agentId?: string,
733
+ createdAt?: string,
734
+ roleId?: string,
735
+ ): Promise<void> {
736
+ const body: {
737
+ role: string;
738
+ role_id?: string;
739
+ parts: typeof parts;
740
+ created_at?: string;
741
+ } = { role, parts };
742
+ if (createdAt) {
743
+ body.created_at = createdAt;
744
+ }
745
+ if (roleId) {
746
+ body.role_id = roleId;
747
+ }
748
+ await this.emitRoutingDebug(
749
+ "session message POST (with parts)",
750
+ {
751
+ path: `/api/v1/sessions/${encodeURIComponent(sessionId)}/messages`,
752
+ sessionId,
753
+ role,
754
+ role_id: roleId ?? null,
755
+ partCount: parts.length,
756
+ created_at: createdAt ?? null,
757
+ },
758
+ agentId,
759
+ );
760
+ await this.request<{ session_id: string }>(
761
+ `/api/v1/sessions/${encodeURIComponent(sessionId)}/messages`,
762
+ {
763
+ method: "POST",
764
+ body: JSON.stringify(body),
765
+ },
766
+ agentId,
767
+ );
768
+ }
769
+
770
+ /** GET session — server auto-creates if absent; returns session meta including message stats and token usage. */
771
+ async getSession(sessionId: string, agentId?: string): Promise<{
772
+ message_count?: number;
773
+ commit_count?: number;
774
+ last_commit_at?: string;
775
+ pending_tokens?: number;
776
+ llm_token_usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
777
+ }> {
778
+ return this.request<{
779
+ message_count?: number;
780
+ commit_count?: number;
781
+ last_commit_at?: string;
782
+ pending_tokens?: number;
783
+ llm_token_usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
784
+ }>(
785
+ `/api/v1/sessions/${encodeURIComponent(sessionId)}`,
786
+ { method: "GET" },
787
+ agentId,
788
+ );
789
+ }
790
+
791
+ /**
792
+ * Commit a session: archive (Phase 1) and extract memories (Phase 2).
793
+ *
794
+ * wait=false (default): returns immediately after Phase 1 with task_id.
795
+ * wait=true: after Phase 1, polls GET /tasks/{task_id} until Phase 2
796
+ * completes (or times out), then returns the merged result.
797
+ */
798
+ async commitSession(
799
+ sessionId: string,
800
+ options?: {
801
+ wait?: boolean;
802
+ timeoutMs?: number;
803
+ agentId?: string;
804
+ /**
805
+ * WM v2: number of most-recent messages to keep live after commit.
806
+ * Forwarded as `keep_recent_count` in the POST body. 0 (default)
807
+ * preserves the pre-v2 "archive everything" behavior.
808
+ */
809
+ keepRecentCount?: number;
810
+ },
811
+ ): Promise<CommitSessionResult> {
812
+ const keepRecentCount =
813
+ options?.keepRecentCount != null && Number.isFinite(options.keepRecentCount)
814
+ ? Math.max(0, Math.floor(options.keepRecentCount))
815
+ : 0;
816
+ await this.emitRoutingDebug(
817
+ "session commit POST (archive + memory extraction)",
818
+ {
819
+ path: `/api/v1/sessions/${encodeURIComponent(sessionId)}/commit`,
820
+ sessionId,
821
+ wait: options?.wait ?? false,
822
+ keepRecentCount,
823
+ },
824
+ options?.agentId,
825
+ );
826
+ const body: Record<string, unknown> = {};
827
+ if (keepRecentCount > 0) {
828
+ body.keep_recent_count = keepRecentCount;
829
+ }
830
+ const result = await this.request<CommitSessionResult>(
831
+ `/api/v1/sessions/${encodeURIComponent(sessionId)}/commit`,
832
+ { method: "POST", body: JSON.stringify(body) },
833
+ options?.agentId,
834
+ );
835
+
836
+ if (!options?.wait || !result.task_id) {
837
+ return result;
838
+ }
839
+
840
+ // Client-side poll until Phase 2 finishes
841
+ const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_PHASE2_POLL_TIMEOUT_MS);
842
+ const pollInterval = 500;
843
+ while (Date.now() < deadline) {
844
+ await sleep(pollInterval);
845
+ const task = await this.getTask(result.task_id, options.agentId).catch(() => null);
846
+ if (!task) break;
847
+ if (task.status === "completed") {
848
+ const taskResult = (task.result ?? {}) as Record<string, unknown>;
849
+ const memoriesExtracted = (taskResult.memories_extracted ?? {}) as Record<string, number>;
850
+ result.status = "completed";
851
+ result.memories_extracted = memoriesExtracted;
852
+ return result;
853
+ }
854
+ if (task.status === "failed") {
855
+ result.status = "failed";
856
+ result.error = task.error;
857
+ return result;
858
+ }
859
+ }
860
+ result.status = "timeout";
861
+ return result;
862
+ }
863
+
864
+ /** Poll a background task by ID. */
865
+ async getTask(taskId: string, agentId?: string): Promise<TaskResult> {
866
+ return this.request<TaskResult>(
867
+ `/api/v1/tasks/${encodeURIComponent(taskId)}`,
868
+ { method: "GET" },
869
+ agentId,
870
+ );
871
+ }
872
+
873
+ async getSessionContext(
874
+ sessionId: string,
875
+ tokenBudget: number = 128_000,
876
+ agentId?: string,
877
+ ): Promise<SessionContextResult> {
878
+ return this.request(
879
+ `/api/v1/sessions/${encodeURIComponent(sessionId)}/context?token_budget=${tokenBudget}`,
880
+ { method: "GET" },
881
+ agentId,
882
+ );
883
+ }
884
+
885
+ async getSessionArchive(
886
+ sessionId: string,
887
+ archiveId: string,
888
+ agentId?: string,
889
+ ): Promise<SessionArchiveResult> {
890
+ return this.request(
891
+ `/api/v1/sessions/${encodeURIComponent(sessionId)}/archives/${encodeURIComponent(archiveId)}`,
892
+ { method: "GET" },
893
+ agentId,
894
+ );
895
+ }
896
+
897
+ async grepSessionArchives(
898
+ sessionId: string,
899
+ pattern: string,
900
+ options: {
901
+ archiveId?: string;
902
+ caseInsensitive?: boolean;
903
+ nodeLimit?: number;
904
+ levelLimit?: number;
905
+ agentId?: string;
906
+ } = {},
907
+ ): Promise<{
908
+ matches: Array<{ line: number; uri: string; content: string }>;
909
+ count: number;
910
+ match_count?: number;
911
+ files_scanned?: number;
912
+ }> {
913
+ const baseUri = `viking://session/${sessionId}/history`;
914
+ const uri = options.archiveId ? `${baseUri}/${options.archiveId}` : baseUri;
915
+ return this.request(
916
+ "/api/v1/search/grep",
917
+ {
918
+ method: "POST",
919
+ body: JSON.stringify({
920
+ uri,
921
+ pattern,
922
+ case_insensitive: options.caseInsensitive ?? true,
923
+ ...(options.nodeLimit !== undefined ? { node_limit: options.nodeLimit } : {}),
924
+ ...(options.levelLimit !== undefined ? { level_limit: options.levelLimit } : {}),
925
+ }),
926
+ },
927
+ options.agentId,
928
+ );
929
+ }
930
+
931
+ async deleteSession(sessionId: string, agentId?: string): Promise<void> {
932
+ await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" }, agentId);
933
+ }
934
+ async deleteUri(uri: string, agentId?: string): Promise<void> {
935
+ await this.request(`/api/v1/fs?uri=${encodeURIComponent(uri)}&recursive=false`, {
936
+ method: "DELETE",
937
+ }, agentId);
938
+ }
939
+ }