@vainplex/openclaw-knowledge-engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/ARCHITECTURE.md +374 -0
  2. package/dist/index.d.ts +5 -0
  3. package/dist/index.js +29 -0
  4. package/dist/src/config.d.ts +15 -0
  5. package/dist/src/config.js +153 -0
  6. package/dist/src/embeddings.d.ts +23 -0
  7. package/dist/src/embeddings.js +63 -0
  8. package/dist/src/entity-extractor.d.ts +30 -0
  9. package/dist/src/entity-extractor.js +123 -0
  10. package/dist/src/fact-store.d.ts +77 -0
  11. package/dist/src/fact-store.js +222 -0
  12. package/dist/src/hooks.d.ts +24 -0
  13. package/dist/src/hooks.js +94 -0
  14. package/dist/src/http-client.d.ts +9 -0
  15. package/dist/src/http-client.js +58 -0
  16. package/dist/src/llm-enhancer.d.ts +44 -0
  17. package/dist/src/llm-enhancer.js +166 -0
  18. package/dist/src/maintenance.d.ts +26 -0
  19. package/dist/src/maintenance.js +87 -0
  20. package/dist/src/patterns.d.ts +5 -0
  21. package/dist/src/patterns.js +69 -0
  22. package/dist/src/storage.d.ts +41 -0
  23. package/dist/src/storage.js +110 -0
  24. package/dist/src/types.d.ts +122 -0
  25. package/dist/src/types.js +2 -0
  26. package/index.ts +38 -0
  27. package/openclaw.plugin.json +125 -0
  28. package/package.json +36 -0
  29. package/src/config.ts +180 -0
  30. package/src/embeddings.ts +82 -0
  31. package/src/entity-extractor.ts +137 -0
  32. package/src/fact-store.ts +260 -0
  33. package/src/hooks.ts +125 -0
  34. package/src/http-client.ts +74 -0
  35. package/src/llm-enhancer.ts +187 -0
  36. package/src/maintenance.ts +102 -0
  37. package/src/patterns.ts +90 -0
  38. package/src/storage.ts +122 -0
  39. package/src/types.ts +144 -0
  40. package/test/config.test.ts +152 -0
  41. package/test/embeddings.test.ts +118 -0
  42. package/test/entity-extractor.test.ts +121 -0
  43. package/test/fact-store.test.ts +266 -0
  44. package/test/hooks.test.ts +120 -0
  45. package/test/http-client.test.ts +68 -0
  46. package/test/llm-enhancer.test.ts +132 -0
  47. package/test/maintenance.test.ts +117 -0
  48. package/test/patterns.test.ts +123 -0
  49. package/test/storage.test.ts +86 -0
  50. package/tsconfig.json +26 -0
@@ -0,0 +1,102 @@
1
+ // src/maintenance.ts
2
+
3
+ import { Embeddings } from './embeddings.js';
4
+ import { FactStore } from './fact-store.js';
5
+ import { KnowledgeConfig, Logger } from './types.js';
6
+
7
+ /**
8
+ * Manages background maintenance tasks for the knowledge engine,
9
+ * such as decaying fact relevance and syncing embeddings.
10
+ */
11
+ export class Maintenance {
12
+ private readonly config: KnowledgeConfig;
13
+ private readonly logger: Logger;
14
+ private readonly factStore: FactStore;
15
+ private readonly embeddings?: Embeddings;
16
+
17
+ private decayTimer: NodeJS.Timeout | null = null;
18
+ private embeddingsTimer: NodeJS.Timeout | null = null;
19
+
20
+ constructor(
21
+ config: KnowledgeConfig,
22
+ logger: Logger,
23
+ factStore: FactStore,
24
+ embeddings?: Embeddings
25
+ ) {
26
+ this.config = config;
27
+ this.logger = logger;
28
+ this.factStore = factStore;
29
+ this.embeddings = embeddings;
30
+ }
31
+
32
+ /** Starts all configured maintenance timers. */
33
+ public start(): void {
34
+ this.logger.info('Starting maintenance service...');
35
+ this.stop();
36
+ this.startDecayTimer();
37
+ this.startEmbeddingsTimer();
38
+ }
39
+
40
+ /** Stops all running maintenance timers. */
41
+ public stop(): void {
42
+ if (this.decayTimer) {
43
+ clearInterval(this.decayTimer);
44
+ this.decayTimer = null;
45
+ }
46
+ if (this.embeddingsTimer) {
47
+ clearInterval(this.embeddingsTimer);
48
+ this.embeddingsTimer = null;
49
+ }
50
+ this.logger.info('Stopped maintenance service.');
51
+ }
52
+
53
+ private startDecayTimer(): void {
54
+ if (!this.config.decay.enabled) return;
55
+ const ms = this.config.decay.intervalHours * 60 * 60 * 1000;
56
+ this.decayTimer = setInterval(() => this.runDecay(), ms);
57
+ this.decayTimer.unref();
58
+ this.logger.info(`Scheduled fact decay every ${this.config.decay.intervalHours} hours.`);
59
+ }
60
+
61
+ private startEmbeddingsTimer(): void {
62
+ if (!this.embeddings?.isEnabled()) return;
63
+ const ms = this.config.embeddings.syncIntervalMinutes * 60 * 1000;
64
+ this.embeddingsTimer = setInterval(() => this.runEmbeddingsSync(), ms);
65
+ this.embeddingsTimer.unref();
66
+ this.logger.info(`Scheduled embeddings sync every ${this.config.embeddings.syncIntervalMinutes} min.`);
67
+ }
68
+
69
+ /** Executes the fact decay process. */
70
+ public runDecay(): void {
71
+ this.logger.info('Running scheduled fact decay...');
72
+ try {
73
+ const { decayedCount } = this.factStore.decayFacts(this.config.decay.rate);
74
+ this.logger.info(`Fact decay complete. Decayed ${decayedCount} facts.`);
75
+ } catch (err) {
76
+ this.logger.error('Error during fact decay.', err as Error);
77
+ }
78
+ }
79
+
80
+ /** Executes the embeddings synchronization process. */
81
+ public async runEmbeddingsSync(): Promise<void> {
82
+ if (!this.embeddings?.isEnabled()) return;
83
+
84
+ this.logger.info('Running scheduled embeddings sync...');
85
+ try {
86
+ const unembedded = this.factStore.getUnembeddedFacts();
87
+ if (unembedded.length === 0) {
88
+ this.logger.info('No new facts to sync for embeddings.');
89
+ return;
90
+ }
91
+
92
+ const synced = await this.embeddings.sync(unembedded);
93
+ if (synced > 0) {
94
+ const ids = unembedded.slice(0, synced).map(f => f.id);
95
+ this.factStore.markFactsAsEmbedded(ids);
96
+ this.logger.info(`Embeddings sync complete. Synced ${synced} facts.`);
97
+ }
98
+ } catch (err) {
99
+ this.logger.error('Error during embeddings sync.', err as Error);
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,90 @@
1
+ // src/patterns.ts
2
+
3
+ /**
4
+ * Common words that look like proper nouns (start of sentence) but are not.
5
+ */
6
+ const EXCLUDED_WORDS = [
7
+ 'A', 'An', 'The', 'Hello', 'My', 'This', 'Contact', 'He', 'She',
8
+ 'It', 'We', 'They', 'I', 'You', 'His', 'Her', 'Our', 'Your',
9
+ 'Their', 'Its', 'That', 'These', 'Those', 'What', 'Which', 'Who',
10
+ 'How', 'When', 'Where', 'Why', 'But', 'And', 'Or', 'So', 'Not',
11
+ 'No', 'Yes', 'Also', 'Just', 'For', 'From', 'With', 'About',
12
+ 'After', 'Before', 'Between', 'During', 'Into', 'Through',
13
+ 'Event', 'Talk', 'Project', 'Multiple', 'German',
14
+ 'Am', 'Are', 'Is', 'Was', 'Were', 'Has', 'Have',
15
+ 'Had', 'Do', 'Does', 'Did', 'Will', 'Would', 'Could', 'Should',
16
+ 'May', 'Might', 'Must', 'Can', 'Shall', 'If', 'Then',
17
+ ];
18
+
19
+ const EXCL = EXCLUDED_WORDS.map(w => `${w}\\b`).join('|');
20
+
21
+ /** Capitalized word: handles O'Malley, McDonald's, acronyms like USS */
22
+ const CAP = `(?:[A-Z][a-z']*(?:[A-Z][a-z']+)*|[A-Z]{2,})`;
23
+
24
+ const DE_MONTHS =
25
+ 'Januar|Februar|März|Mar|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember';
26
+
27
+ const EN_MONTHS =
28
+ 'January|February|March|April|May|June|July|August|September|October|November|December';
29
+
30
+ /** Proper noun: one or more cap-words with exclusion list applied per word. */
31
+ function properNounFactory(): RegExp {
32
+ return new RegExp(
33
+ `\\b(?!${EXCL})${CAP}(?:(?:-|\\s)(?!${EXCL})${CAP})*\\b`, 'g'
34
+ );
35
+ }
36
+
37
+ /** Product name: three branches for multi-word+Roman, word+version, camelCase. */
38
+ function productNameFactory(): RegExp {
39
+ return new RegExp(
40
+ `\\b(?:(?!${EXCL})[A-Z][a-zA-Z0-9]{2,}(?:\\s[a-zA-Z]+)*\\s[IVXLCDM]+` +
41
+ `|[a-zA-Z][a-zA-Z0-9-]{2,}[\\s-]v?\\d+(?:\\.\\d+)?` +
42
+ `|[a-zA-Z][a-zA-Z0-9]+[IVXLCDM]+)\\b`, 'g'
43
+ );
44
+ }
45
+
46
+ /** Creates a fresh RegExp factory for each pattern key. */
47
+ function buildPatterns(): Record<string, () => RegExp> {
48
+ return {
49
+ email: () => /\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b/g,
50
+ url: () => /\bhttps?:\/\/[^\s/$.?#].[^\s]*\b/g,
51
+ iso_date: () => /\b\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?Z?)?\b/g,
52
+ common_date: () => /\b(?:\d{1,2}\/\d{1,2}\/\d{2,4})|(?:\d{1,2}\.\d{1,2}\.\d{2,4})\b/g,
53
+ german_date: () => new RegExp(`\\b\\d{1,2}\\.\\s(?:${DE_MONTHS})\\s+\\d{4}\\b`, 'gi'),
54
+ english_date: () => new RegExp(`\\b(?:${EN_MONTHS})\\s+\\d{1,2}(?:st|nd|rd|th)?,\\s+\\d{4}\\b`, 'gi'),
55
+ proper_noun: properNounFactory,
56
+ product_name: productNameFactory,
57
+ organization_suffix: () => new RegExp(
58
+ '\\b(?:[A-Z][A-Za-z0-9]+(?:\\s[A-Z][A-Za-z0-9]+)*),?\\s?' +
59
+ '(?:Inc\\.|LLC|Corp\\.|GmbH|AG|Ltd\\.)', 'g'
60
+ ),
61
+ };
62
+ }
63
+
64
+ const PATTERN_FACTORIES = buildPatterns();
65
+
66
+ /**
67
+ * A collection of regular expression factories for extracting entities.
68
+ * Each property access creates a fresh RegExp to avoid /g state-bleed.
69
+ */
70
+ export const REGEX_PATTERNS: Record<string, RegExp> = new Proxy(
71
+ {} as Record<string, RegExp>,
72
+ {
73
+ get(_target, prop: string): RegExp | undefined {
74
+ const factory = PATTERN_FACTORIES[prop];
75
+ return factory ? factory() : undefined;
76
+ },
77
+ ownKeys(): string[] {
78
+ return Object.keys(PATTERN_FACTORIES);
79
+ },
80
+ getOwnPropertyDescriptor(_target, prop: string) {
81
+ if (prop in PATTERN_FACTORIES) {
82
+ return { configurable: true, enumerable: true, writable: false };
83
+ }
84
+ return undefined;
85
+ },
86
+ has(_target, prop: string): boolean {
87
+ return prop in PATTERN_FACTORIES;
88
+ },
89
+ }
90
+ );
package/src/storage.ts ADDED
@@ -0,0 +1,122 @@
1
+ // src/storage.ts
2
+
3
+ import * as fs from 'node:fs/promises';
4
+ import * as path from 'node:path';
5
+ import { IStorage, Logger } from './types.js';
6
+
7
+ /** Type guard for Node.js system errors with a `code` property. */
8
+ function isNodeError(err: unknown): err is NodeJS.ErrnoException {
9
+ return err instanceof Error && 'code' in err;
10
+ }
11
+
12
+ /**
13
+ * A utility class for performing atomic and durable file I/O operations.
14
+ * It writes to a temporary file first, then renames it to the final destination,
15
+ * which prevents data corruption in case of a crash during the write.
16
+ */
17
+ export class AtomicStorage implements IStorage {
18
+ private readonly storagePath: string;
19
+ private readonly logger: Logger;
20
+
21
+ /**
22
+ * Creates an instance of AtomicStorage.
23
+ * @param storagePath The base directory where files will be stored.
24
+ * @param logger A logger instance for logging errors.
25
+ */
26
+ constructor(storagePath: string, logger: Logger) {
27
+ this.storagePath = storagePath;
28
+ this.logger = logger;
29
+ }
30
+
31
+ /**
32
+ * Ensures that the storage directory exists.
33
+ */
34
+ public async init(): Promise<void> {
35
+ try {
36
+ await fs.mkdir(this.storagePath, { recursive: true });
37
+ } catch (err) {
38
+ this.logger.error(`Failed to create storage directory: ${this.storagePath}`, err as Error);
39
+ throw err;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Reads and parses a JSON file from the storage path.
45
+ * @param fileName The name of the file to read (e.g., "facts.json").
46
+ * @returns The parsed JSON object, or null if the file doesn't exist or is invalid.
47
+ */
48
+ async readJson<T>(fileName: string): Promise<T | null> {
49
+ const filePath = path.join(this.storagePath, fileName);
50
+ try {
51
+ const content = await fs.readFile(filePath, 'utf-8');
52
+ return JSON.parse(content) as T;
53
+ } catch (err) {
54
+ if (isNodeError(err) && err.code === 'ENOENT') {
55
+ return null;
56
+ }
57
+ this.logger.error(`Failed to read or parse JSON file: ${filePath}`, err as Error);
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Writes a JSON object to a file atomically.
64
+ * @param fileName The name of the file to write (e.g., "facts.json").
65
+ * @param data The JSON object to serialize and write.
66
+ */
67
+ async writeJson<T>(fileName: string, data: T): Promise<void> {
68
+ const filePath = path.join(this.storagePath, fileName);
69
+ const tempFilePath = `${filePath}.${Date.now()}.tmp`;
70
+
71
+ try {
72
+ const jsonString = JSON.stringify(data, null, 2);
73
+ await fs.writeFile(tempFilePath, jsonString, 'utf-8');
74
+ await fs.rename(tempFilePath, filePath);
75
+ } catch (err) {
76
+ this.logger.error(`Failed to write JSON file atomically: ${filePath}`, err as Error);
77
+ // Attempt to clean up the temporary file if it exists
78
+ try {
79
+ await fs.unlink(tempFilePath);
80
+ } catch (cleanupErr) {
81
+ if (!isNodeError(cleanupErr) || cleanupErr.code !== 'ENOENT') {
82
+ this.logger.warn(`Failed to clean up temporary file: ${tempFilePath}`);
83
+ }
84
+ }
85
+ throw err; // Re-throw the original error
86
+ }
87
+ }
88
+
89
+ /**
90
+ * A debouncer function to limit the rate at which a function is executed.
91
+ * This version is designed for async functions and returns a promise that
92
+ * resolves with the result of the last invocation.
93
+ * @param func The async function to debounce.
94
+ * @param delay The debounce delay in milliseconds.
95
+ * @returns A debounced version of the function that returns a promise.
96
+ */
97
+ static debounce<A extends unknown[], R>(
98
+ func: (...args: A) => Promise<R>,
99
+ delay: number
100
+ ): (...args: A) => Promise<R> {
101
+ let timeoutId: NodeJS.Timeout | null = null;
102
+ let resolvers: { resolve: (v: R) => void; reject: (e: unknown) => void }[] = [];
103
+
104
+ return (...args: A): Promise<R> => {
105
+ if (timeoutId) clearTimeout(timeoutId);
106
+
107
+ const promise = new Promise<R>((resolve, reject) => {
108
+ resolvers.push({ resolve, reject });
109
+ });
110
+
111
+ timeoutId = setTimeout(() => {
112
+ const current = resolvers;
113
+ resolvers = [];
114
+ func(...args)
115
+ .then(result => current.forEach(r => r.resolve(result)))
116
+ .catch(err => current.forEach(r => r.reject(err)));
117
+ }, delay);
118
+
119
+ return promise;
120
+ };
121
+ }
122
+ }
package/src/types.ts ADDED
@@ -0,0 +1,144 @@
1
+ // src/types.ts
2
+
3
+ /**
4
+ * The public API exposed by the OpenClaw host to the plugin.
5
+ * This is a subset of the full API, containing only what this plugin needs.
6
+ */
7
+ export interface OpenClawPluginApi {
8
+ pluginConfig: Record<string, unknown>;
9
+ logger: Logger;
10
+ on: (
11
+ event: string,
12
+ handler: (event: HookEvent, ctx: HookContext) => void,
13
+ options?: { priority: number }
14
+ ) => void;
15
+ }
16
+
17
+ /**
18
+ * A generic logger interface compatible with OpenClaw's logger.
19
+ */
20
+ export interface Logger {
21
+ info: (msg: string) => void;
22
+ warn: (msg: string) => void;
23
+ error: (msg: string, err?: Error) => void;
24
+ debug: (msg: string) => void;
25
+ }
26
+
27
+ /**
28
+ * Represents the data payload for an OpenClaw hook.
29
+ * It's a generic shape, as different hooks have different payloads.
30
+ */
31
+ export interface HookEvent {
32
+ content?: string;
33
+ message?: string;
34
+ text?: string;
35
+ from?: string;
36
+ sender?: string;
37
+ role?: "user" | "assistant";
38
+ [key: string]: unknown;
39
+ }
40
+
41
+ /**
42
+ * Represents the context object passed with each hook event.
43
+ */
44
+ export interface HookContext {
45
+ workspace: string; // Absolute path to the OpenClaw workspace
46
+ }
47
+
48
+ /**
49
+ * The fully resolved and validated plugin configuration object.
50
+ */
51
+ export interface KnowledgeConfig {
52
+ enabled: boolean;
53
+ workspace: string;
54
+ extraction: {
55
+ regex: {
56
+ enabled: boolean;
57
+ };
58
+ llm: {
59
+ enabled: boolean;
60
+ model: string;
61
+ endpoint: string;
62
+ batchSize: number;
63
+ cooldownMs: number;
64
+ };
65
+ };
66
+ decay: {
67
+ enabled: boolean;
68
+ intervalHours: number;
69
+ rate: number; // e.g., 0.05 for 5% decay per interval
70
+ };
71
+ embeddings: {
72
+ enabled: boolean;
73
+ endpoint: string;
74
+ collectionName: string;
75
+ syncIntervalMinutes: number;
76
+ };
77
+ storage: {
78
+ maxEntities: number;
79
+ maxFacts: number;
80
+ writeDebounceMs: number;
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Represents an extracted entity.
86
+ */
87
+ export interface Entity {
88
+ id: string; // e.g., "person:claude"
89
+ type:
90
+ | "person"
91
+ | "location"
92
+ | "organization"
93
+ | "date"
94
+ | "product"
95
+ | "concept"
96
+ | "email"
97
+ | "url"
98
+ | "unknown";
99
+ value: string; // The canonical value, e.g., "Claude"
100
+ mentions: string[]; // Different ways it was mentioned, e.g., ["claude", "Claude's"]
101
+ count: number;
102
+ importance: number; // 0.0 to 1.0
103
+ lastSeen: string; // ISO 8601 timestamp
104
+ source: ("regex" | "llm")[];
105
+ }
106
+
107
+ /**
108
+ * Represents a structured fact (a triple).
109
+ */
110
+ export interface Fact {
111
+ id: string; // UUID v4
112
+ subject: string; // Entity ID
113
+ predicate: string; // e.g., "is-a", "has-property", "works-at"
114
+ object: string; // Entity ID or literal value
115
+ relevance: number; // 0.0 to 1.0, subject to decay
116
+ createdAt: string; // ISO 8601 timestamp
117
+ lastAccessed: string; // ISO 8601 timestamp
118
+ source: "ingested" | "extracted-regex" | "extracted-llm";
119
+ embedded?: string; // ISO 8601 timestamp of last embedding sync
120
+ }
121
+
122
+ /**
123
+ * The data structure for the entities.json file.
124
+ */
125
+ export interface EntitiesData {
126
+ updated: string;
127
+ entities: Entity[];
128
+ }
129
+
130
+ /**
131
+ * The data structure for the facts.json file.
132
+ */
133
+ export interface FactsData {
134
+ updated: string;
135
+ facts: Fact[];
136
+ }
137
+
138
+ /**
139
+ * Interface for a generic file storage utility.
140
+ */
141
+ export interface IStorage {
142
+ readJson<T>(fileName: string): Promise<T | null>;
143
+ writeJson<T>(fileName: string, data: T): Promise<void>;
144
+ }
@@ -0,0 +1,152 @@
1
+ // test/config.test.ts
2
+
3
+ import { describe, it, beforeEach } from 'node:test';
4
+ import * as assert from 'node:assert';
5
+ import * as path from 'node:path';
6
+ import { resolveConfig, DEFAULT_CONFIG } from '../src/config.js';
7
+ import type { Logger, KnowledgeConfig } from '../src/types.js';
8
+
9
+ const createMockLogger = (): Logger & { logs: { level: string; msg: string }[] } => {
10
+ const logs: { level: string; msg:string }[] = [];
11
+ return {
12
+ logs,
13
+ info: (msg: string) => logs.push({ level: 'info', msg }),
14
+ warn: (msg: string) => logs.push({ level: 'warn', msg }),
15
+ error: (msg: string) => logs.push({ level: 'error', msg }),
16
+ debug: (msg: string) => logs.push({ level: 'debug', msg }),
17
+ };
18
+ };
19
+
20
+ describe('resolveConfig', () => {
21
+ let logger: ReturnType<typeof createMockLogger>;
22
+ const openClawWorkspace = '/home/user/.clawd';
23
+
24
+ beforeEach(() => {
25
+ logger = createMockLogger();
26
+ });
27
+
28
+ it('should return the default configuration when user config is empty', () => {
29
+ const userConfig = {};
30
+ const expectedConfig = {
31
+ ...DEFAULT_CONFIG,
32
+ workspace: path.join(openClawWorkspace, 'knowledge-engine'),
33
+ };
34
+ const resolved = resolveConfig(userConfig, logger, openClawWorkspace);
35
+ assert.deepStrictEqual(resolved, expectedConfig);
36
+ });
37
+
38
+ it('should merge user-provided values with defaults', () => {
39
+ const userConfig = {
40
+ extraction: {
41
+ llm: {
42
+ enabled: false,
43
+ model: 'custom-model',
44
+ },
45
+ },
46
+ storage: {
47
+ writeDebounceMs: 5000,
48
+ },
49
+ };
50
+ const resolved = resolveConfig(userConfig, logger, openClawWorkspace) as KnowledgeConfig;
51
+ assert.strictEqual(resolved.extraction.llm.enabled, false);
52
+ assert.strictEqual(resolved.extraction.llm.model, 'custom-model');
53
+ assert.strictEqual(resolved.extraction.llm.batchSize, DEFAULT_CONFIG.extraction.llm.batchSize); // Should remain default
54
+ assert.strictEqual(resolved.storage.writeDebounceMs, 5000);
55
+ assert.strictEqual(resolved.decay.rate, DEFAULT_CONFIG.decay.rate); // Should remain default
56
+ });
57
+
58
+ it('should resolve the workspace path correctly', () => {
59
+ const userConfig = { workspace: '/custom/path' };
60
+ const resolved = resolveConfig(userConfig, logger, openClawWorkspace);
61
+ assert.strictEqual(resolved?.workspace, '/custom/path');
62
+ });
63
+
64
+ it('should resolve a tilde in the workspace path', () => {
65
+ const homeDir = process.env.HOME || '/home/user';
66
+ process.env.HOME = homeDir; // Ensure HOME is set for the test
67
+ const userConfig = { workspace: '~/.my-knowledge' };
68
+ const resolved = resolveConfig(userConfig, logger, openClawWorkspace);
69
+ assert.strictEqual(resolved?.workspace, path.join(homeDir, '.my-knowledge'));
70
+ });
71
+
72
+ it('should use the default workspace path if user path is not provided', () => {
73
+ const userConfig = {};
74
+ const resolved = resolveConfig(userConfig, logger, openClawWorkspace);
75
+ assert.strictEqual(resolved?.workspace, path.join(openClawWorkspace, 'knowledge-engine'));
76
+ });
77
+
78
+ describe('Validation', () => {
79
+ it('should return null and log errors for an invalid LLM endpoint URL', () => {
80
+ const userConfig = {
81
+ extraction: {
82
+ llm: {
83
+ enabled: true,
84
+ endpoint: 'not-a-valid-url',
85
+ },
86
+ },
87
+ };
88
+ const resolved = resolveConfig(userConfig, logger, openClawWorkspace);
89
+ assert.strictEqual(resolved, null);
90
+ assert.strictEqual(logger.logs.length, 1);
91
+ assert.strictEqual(logger.logs[0].level, 'error');
92
+ assert.ok(logger.logs[0].msg.includes('"extraction.llm.endpoint" must be a valid HTTP/S URL'));
93
+ });
94
+
95
+ it('should return null and log errors for an invalid decay rate', () => {
96
+ const userConfig = {
97
+ decay: {
98
+ rate: 1.5, // > 1
99
+ },
100
+ };
101
+ const resolved = resolveConfig(userConfig, logger, openClawWorkspace);
102
+ assert.strictEqual(resolved, null);
103
+ assert.strictEqual(logger.logs.length, 1);
104
+ assert.ok(logger.logs[0].msg.includes('"decay.rate" must be between 0 and 1'));
105
+ });
106
+
107
+ it('should return null and log errors for a non-positive decay interval', () => {
108
+ const userConfig = {
109
+ decay: {
110
+ intervalHours: 0,
111
+ },
112
+ };
113
+ const resolved = resolveConfig(userConfig, logger, openClawWorkspace);
114
+ assert.strictEqual(resolved, null);
115
+ assert.strictEqual(logger.logs.length, 1);
116
+ assert.ok(logger.logs[0].msg.includes('"decay.intervalHours" must be greater than 0'));
117
+ });
118
+
119
+ it('should allow a valid configuration to pass', () => {
120
+ const userConfig = {
121
+ enabled: true,
122
+ workspace: '/tmp/test',
123
+ extraction: {
124
+ llm: {
125
+ endpoint: 'https://api.example.com',
126
+ },
127
+ },
128
+ decay: {
129
+ rate: 0.1,
130
+ },
131
+ embeddings: {
132
+ enabled: true,
133
+ endpoint: 'http://localhost:8000',
134
+ },
135
+ };
136
+ const resolved = resolveConfig(userConfig, logger, openClawWorkspace);
137
+ assert.ok(resolved);
138
+ assert.strictEqual(logger.logs.filter(l => l.level === 'error').length, 0);
139
+ });
140
+
141
+ it('should handle deeply nested partial configurations', () => {
142
+ const userConfig = {
143
+ extraction: {
144
+ regex: { enabled: false },
145
+ },
146
+ };
147
+ const resolved = resolveConfig(userConfig, logger, openClawWorkspace) as KnowledgeConfig;
148
+ assert.strictEqual(resolved.extraction.regex.enabled, false);
149
+ assert.strictEqual(resolved.extraction.llm.enabled, DEFAULT_CONFIG.extraction.llm.enabled);
150
+ });
151
+ });
152
+ });