@vainplex/openclaw-knowledge-engine 0.1.2 → 0.1.4
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 +9 -9
- package/dist/index.d.ts +8 -4
- package/dist/index.js +31 -27
- package/dist/src/config-loader.d.ts +22 -0
- package/dist/src/config-loader.js +104 -0
- package/dist/src/config.d.ts +1 -1
- package/dist/src/config.js +3 -2
- package/openclaw.plugin.json +117 -113
- package/package.json +15 -5
- package/ARCHITECTURE.md +0 -374
- package/index.ts +0 -38
- package/src/config.ts +0 -180
- package/src/embeddings.ts +0 -82
- package/src/entity-extractor.ts +0 -137
- package/src/fact-store.ts +0 -260
- package/src/hooks.ts +0 -125
- package/src/http-client.ts +0 -74
- package/src/llm-enhancer.ts +0 -187
- package/src/maintenance.ts +0 -102
- package/src/patterns.ts +0 -90
- package/src/storage.ts +0 -122
- package/src/types.ts +0 -144
- package/test/config.test.ts +0 -152
- package/test/embeddings.test.ts +0 -118
- package/test/entity-extractor.test.ts +0 -121
- package/test/fact-store.test.ts +0 -266
- package/test/hooks.test.ts +0 -120
- package/test/http-client.test.ts +0 -68
- package/test/llm-enhancer.test.ts +0 -132
- package/test/maintenance.test.ts +0 -117
- package/test/patterns.test.ts +0 -123
- package/test/storage.test.ts +0 -86
- package/tsconfig.json +0 -26
package/test/config.test.ts
DELETED
|
@@ -1,152 +0,0 @@
|
|
|
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
|
-
});
|
package/test/embeddings.test.ts
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
// test/embeddings.test.ts
|
|
2
|
-
|
|
3
|
-
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
|
4
|
-
import * as assert from 'node:assert';
|
|
5
|
-
import { Embeddings } from '../src/embeddings.js';
|
|
6
|
-
import type { Fact, KnowledgeConfig, Logger } from '../src/types.js';
|
|
7
|
-
|
|
8
|
-
const createMockLogger = (): Logger => ({
|
|
9
|
-
info: () => {},
|
|
10
|
-
warn: () => {},
|
|
11
|
-
error: () => {},
|
|
12
|
-
debug: () => {},
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
const mockConfig: KnowledgeConfig['embeddings'] = {
|
|
16
|
-
enabled: true,
|
|
17
|
-
endpoint: 'http://localhost:8000/api/v1/collections/{name}/add',
|
|
18
|
-
collectionName: 'test-collection',
|
|
19
|
-
syncIntervalMinutes: 15,
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const createTestFacts = (count: number): Fact[] => {
|
|
23
|
-
return Array.from({ length: count }, (_, i) => ({
|
|
24
|
-
id: `fact-${i}`,
|
|
25
|
-
subject: `Subject ${i}`,
|
|
26
|
-
predicate: 'is-a-test',
|
|
27
|
-
object: `Object ${i}`,
|
|
28
|
-
relevance: 1.0,
|
|
29
|
-
createdAt: new Date().toISOString(),
|
|
30
|
-
lastAccessed: new Date().toISOString(),
|
|
31
|
-
source: 'ingested' as const,
|
|
32
|
-
}));
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
describe('Embeddings', () => {
|
|
36
|
-
let logger: Logger;
|
|
37
|
-
let embeddings: Embeddings;
|
|
38
|
-
|
|
39
|
-
beforeEach(() => {
|
|
40
|
-
logger = createMockLogger();
|
|
41
|
-
embeddings = new Embeddings(mockConfig, logger);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
afterEach(() => {
|
|
45
|
-
mock.reset();
|
|
46
|
-
mock.restoreAll();
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('should successfully sync a batch of facts', async () => {
|
|
50
|
-
// Mock the private buildEndpointUrl to use a testable URL
|
|
51
|
-
// and mock httpPost at the module level isn't feasible for ESM,
|
|
52
|
-
// so we mock the whole sync chain via the private method
|
|
53
|
-
const syncSpy = mock.method(
|
|
54
|
-
embeddings as unknown as Record<string, unknown>,
|
|
55
|
-
'buildEndpointUrl',
|
|
56
|
-
() => 'http://localhost:8000/api/v1/collections/test-collection/add'
|
|
57
|
-
);
|
|
58
|
-
|
|
59
|
-
// We can't easily mock httpPost (ESM), so use a real server approach
|
|
60
|
-
// Instead, let's test the constructChromaPayload method indirectly
|
|
61
|
-
// by mocking the internal flow
|
|
62
|
-
const facts = createTestFacts(3);
|
|
63
|
-
|
|
64
|
-
// For a proper test, mock at the transport level
|
|
65
|
-
// Use the instance method pattern: override the method that calls httpPost
|
|
66
|
-
let calledPayload: unknown = null;
|
|
67
|
-
const originalSync = embeddings.sync.bind(embeddings);
|
|
68
|
-
|
|
69
|
-
// Test via direct method mock
|
|
70
|
-
mock.method(embeddings, 'sync', async (facts: Fact[]) => {
|
|
71
|
-
calledPayload = facts;
|
|
72
|
-
return facts.length;
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
const syncedCount = await embeddings.sync(facts);
|
|
76
|
-
assert.strictEqual(syncedCount, 3);
|
|
77
|
-
assert.strictEqual((calledPayload as Fact[]).length, 3);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('should return 0 when disabled', async () => {
|
|
81
|
-
const disabledConfig = { ...mockConfig, enabled: false };
|
|
82
|
-
const disabled = new Embeddings(disabledConfig, logger);
|
|
83
|
-
const syncedCount = await disabled.sync(createTestFacts(1));
|
|
84
|
-
assert.strictEqual(syncedCount, 0);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('should return 0 for empty facts array', async () => {
|
|
88
|
-
const syncedCount = await embeddings.sync([]);
|
|
89
|
-
assert.strictEqual(syncedCount, 0);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('should correctly report enabled state', () => {
|
|
93
|
-
assert.strictEqual(embeddings.isEnabled(), true);
|
|
94
|
-
const disabled = new Embeddings({ ...mockConfig, enabled: false }, logger);
|
|
95
|
-
assert.strictEqual(disabled.isEnabled(), false);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('should construct valid ChromaDB payload', () => {
|
|
99
|
-
// Access private method for testing
|
|
100
|
-
const facts = createTestFacts(2);
|
|
101
|
-
const payload = (embeddings as unknown as Record<string, (f: Fact[]) => { ids: string[]; documents: string[]; metadatas: Record<string, string>[] }>)
|
|
102
|
-
.constructChromaPayload(facts);
|
|
103
|
-
|
|
104
|
-
assert.strictEqual(payload.ids.length, 2);
|
|
105
|
-
assert.strictEqual(payload.documents.length, 2);
|
|
106
|
-
assert.strictEqual(payload.metadatas.length, 2);
|
|
107
|
-
assert.strictEqual(payload.ids[0], 'fact-0');
|
|
108
|
-
assert.ok(payload.documents[0].includes('Subject 0'));
|
|
109
|
-
assert.strictEqual(payload.metadatas[0].subject, 'Subject 0');
|
|
110
|
-
assert.strictEqual(typeof payload.metadatas[0].source, 'string');
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('should substitute collection name in endpoint URL', () => {
|
|
114
|
-
const url = (embeddings as unknown as Record<string, () => string>).buildEndpointUrl();
|
|
115
|
-
assert.ok(url.includes('test-collection'));
|
|
116
|
-
assert.ok(!url.includes('{name}'));
|
|
117
|
-
});
|
|
118
|
-
});
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
// test/entity-extractor.test.ts
|
|
2
|
-
|
|
3
|
-
import { describe, it, beforeEach } from 'node:test';
|
|
4
|
-
import * as assert from 'node:assert';
|
|
5
|
-
import { EntityExtractor } from '../src/entity-extractor.js';
|
|
6
|
-
import type { Entity, Logger } from '../src/types.js';
|
|
7
|
-
|
|
8
|
-
const createMockLogger = (): Logger => ({
|
|
9
|
-
info: () => {},
|
|
10
|
-
warn: () => {},
|
|
11
|
-
error: () => {},
|
|
12
|
-
debug: () => {},
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
describe('EntityExtractor', () => {
|
|
16
|
-
let extractor: EntityExtractor;
|
|
17
|
-
let logger: Logger;
|
|
18
|
-
|
|
19
|
-
beforeEach(() => {
|
|
20
|
-
logger = createMockLogger();
|
|
21
|
-
extractor = new EntityExtractor(logger);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
describe('extract', () => {
|
|
25
|
-
it('should extract a simple email entity', () => {
|
|
26
|
-
const text = 'My email is test@example.com.';
|
|
27
|
-
const entities = extractor.extract(text);
|
|
28
|
-
assert.strictEqual(entities.length, 1);
|
|
29
|
-
const entity = entities[0];
|
|
30
|
-
assert.strictEqual(entity.type, 'email');
|
|
31
|
-
assert.strictEqual(entity.value, 'test@example.com');
|
|
32
|
-
assert.strictEqual(entity.id, 'email:test@example.com');
|
|
33
|
-
assert.deepStrictEqual(entity.mentions, ['test@example.com']);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('should extract multiple different entities', () => {
|
|
37
|
-
const text = 'Contact Atlas via atlas@acme.com on 2026-02-17.';
|
|
38
|
-
const entities = extractor.extract(text);
|
|
39
|
-
assert.strictEqual(entities.length, 3); // Atlas (proper_noun), email, date
|
|
40
|
-
|
|
41
|
-
const names = entities.map(e => e.value).sort();
|
|
42
|
-
assert.deepStrictEqual(names, ['2026-02-17', 'Atlas', 'atlas@acme.com']);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('should handle multiple mentions of the same entity', () => {
|
|
46
|
-
const text = 'Project OpenClaw is great. I love OpenClaw!';
|
|
47
|
-
const entities = extractor.extract(text);
|
|
48
|
-
assert.strictEqual(entities.length, 1);
|
|
49
|
-
const entity = entities[0];
|
|
50
|
-
assert.strictEqual(entity.type, 'unknown'); // From proper_noun
|
|
51
|
-
assert.strictEqual(entity.value, 'OpenClaw');
|
|
52
|
-
assert.strictEqual(entity.count, 2);
|
|
53
|
-
assert.deepStrictEqual(entity.mentions, ['OpenClaw']);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('should correctly identify and canonicalize an organization', () => {
|
|
57
|
-
const text = 'I work for Acme GmbH. It is a German company.';
|
|
58
|
-
const entities = extractor.extract(text);
|
|
59
|
-
const orgEntity = entities.find(e => e.type === 'organization');
|
|
60
|
-
|
|
61
|
-
assert.ok(orgEntity, 'Organization entity should be found');
|
|
62
|
-
assert.strictEqual(orgEntity.value, 'Acme'); // Canonicalized
|
|
63
|
-
assert.strictEqual(orgEntity.id, 'organization:acme');
|
|
64
|
-
assert.deepStrictEqual(orgEntity.mentions, ['Acme GmbH']);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('should extract dates in various formats', () => {
|
|
68
|
-
const text = 'Event dates: 2026-01-01, 02/03/2024, and 4. Mar 2025 is the German date.';
|
|
69
|
-
const entities = extractor.extract(text);
|
|
70
|
-
const dateEntities = entities.filter(e => e.type === 'date');
|
|
71
|
-
assert.strictEqual(dateEntities.length, 3, 'Should find three distinct dates');
|
|
72
|
-
|
|
73
|
-
const dateValues = dateEntities.map(e => e.value).sort();
|
|
74
|
-
assert.deepStrictEqual(dateValues, ['02/03/2024', '2026-01-01', '4. Mar 2025']);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('should return an empty array for text with no entities', () => {
|
|
78
|
-
const text = 'this is a plain sentence.';
|
|
79
|
-
const entities = extractor.extract(text);
|
|
80
|
-
assert.strictEqual(entities.length, 0);
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
describe('mergeEntities', () => {
|
|
85
|
-
it('should merge two disjoint lists of entities', () => {
|
|
86
|
-
const listA: Entity[] = [{ id: 'person:claude', type: 'person', value: 'Claude', count: 1, importance: 0.7, lastSeen: '2026-01-01', mentions: ['Claude'], source: ['regex'] }];
|
|
87
|
-
const listB: Entity[] = [{ id: 'org:acme', type: 'organization', value: 'Acme', count: 1, importance: 0.8, lastSeen: '2026-01-01', mentions: ['Acme'], source: ['llm'] }];
|
|
88
|
-
|
|
89
|
-
const merged = EntityExtractor.mergeEntities(listA, listB);
|
|
90
|
-
assert.strictEqual(merged.length, 2);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('should merge entities with the same ID', () => {
|
|
94
|
-
const date = new Date().toISOString();
|
|
95
|
-
const listA: Entity[] = [{ id: 'person:claude', type: 'person', value: 'Claude', count: 1, importance: 0.7, lastSeen: date, mentions: ['Claude'], source: ['regex'] }];
|
|
96
|
-
const listB: Entity[] = [{ id: 'person:claude', type: 'person', value: 'Claude', count: 2, importance: 0.85, lastSeen: date, mentions: ["claude's", "Claude"], source: ['llm'] }];
|
|
97
|
-
|
|
98
|
-
const merged = EntityExtractor.mergeEntities(listA, listB);
|
|
99
|
-
assert.strictEqual(merged.length, 1);
|
|
100
|
-
|
|
101
|
-
const entity = merged[0];
|
|
102
|
-
assert.strictEqual(entity.id, 'person:claude');
|
|
103
|
-
assert.strictEqual(entity.count, 3);
|
|
104
|
-
assert.strictEqual(entity.importance, 0.85); // Takes the max importance
|
|
105
|
-
assert.deepStrictEqual(entity.mentions.sort(), ["Claude", "claude's"].sort());
|
|
106
|
-
assert.deepStrictEqual(entity.source.sort(), ['llm', 'regex'].sort());
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('should handle an empty list', () => {
|
|
110
|
-
const listA: Entity[] = [{ id: 'person:claude', type: 'person', value: 'Claude', count: 1, importance: 0.7, lastSeen: '2026-01-01', mentions: ['Claude'], source: ['regex'] }];
|
|
111
|
-
const mergedA = EntityExtractor.mergeEntities(listA, []);
|
|
112
|
-
assert.deepStrictEqual(mergedA, listA);
|
|
113
|
-
|
|
114
|
-
const mergedB = EntityExtractor.mergeEntities([], listA);
|
|
115
|
-
assert.deepStrictEqual(mergedB, listA);
|
|
116
|
-
|
|
117
|
-
const mergedC = EntityExtractor.mergeEntities([], []);
|
|
118
|
-
assert.deepStrictEqual(mergedC, []);
|
|
119
|
-
});
|
|
120
|
-
});
|
|
121
|
-
});
|
package/test/fact-store.test.ts
DELETED
|
@@ -1,266 +0,0 @@
|
|
|
1
|
-
// test/fact-store.test.ts
|
|
2
|
-
|
|
3
|
-
import { describe, it, before, after, beforeEach } from 'node:test';
|
|
4
|
-
import * as assert from 'node:assert';
|
|
5
|
-
import * as fs from 'node:fs/promises';
|
|
6
|
-
import * as path from 'node:path';
|
|
7
|
-
import { FactStore } from '../src/fact-store.js';
|
|
8
|
-
import type { KnowledgeConfig, Logger, Fact } from '../src/types.js';
|
|
9
|
-
|
|
10
|
-
const createMockLogger = (): Logger => ({
|
|
11
|
-
info: () => {}, warn: () => {}, error: () => {}, debug: () => {},
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
const mockConfig: KnowledgeConfig['storage'] = {
|
|
15
|
-
maxEntities: 100, maxFacts: 10, writeDebounceMs: 0,
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
describe('FactStore', () => {
|
|
19
|
-
const testDir = path.join('/tmp', `fact-store-test-${Date.now()}`);
|
|
20
|
-
let factStore: FactStore;
|
|
21
|
-
|
|
22
|
-
before(async () => await fs.mkdir(testDir, { recursive: true }));
|
|
23
|
-
after(async () => await fs.rm(testDir, { recursive: true, force: true }));
|
|
24
|
-
|
|
25
|
-
beforeEach(async () => {
|
|
26
|
-
// Flush any pending debounced writes from the previous test
|
|
27
|
-
// to prevent stale data from bleeding across test boundaries.
|
|
28
|
-
if (factStore) {
|
|
29
|
-
await factStore.flush();
|
|
30
|
-
}
|
|
31
|
-
const filePath = path.join(testDir, 'facts.json');
|
|
32
|
-
try { await fs.unlink(filePath); } catch (e: unknown) {
|
|
33
|
-
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
|
|
34
|
-
}
|
|
35
|
-
factStore = new FactStore(testDir, mockConfig, createMockLogger());
|
|
36
|
-
await factStore.load();
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('should add a new fact to the store', () => {
|
|
40
|
-
factStore.addFact({ subject: 's1', predicate: 'p1', object: 'o1', source: 'extracted-llm' });
|
|
41
|
-
const facts = factStore.query({});
|
|
42
|
-
assert.strictEqual(facts.length, 1);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('should throw if addFact called before load', async () => {
|
|
46
|
-
const unloaded = new FactStore(testDir, mockConfig, createMockLogger());
|
|
47
|
-
assert.throws(() => {
|
|
48
|
-
unloaded.addFact({ subject: 's', predicate: 'p', object: 'o', source: 'ingested' });
|
|
49
|
-
}, /not been loaded/);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('should deduplicate identical facts by boosting relevance', () => {
|
|
53
|
-
const f1 = factStore.addFact({ subject: 's', predicate: 'p', object: 'o', source: 'ingested' });
|
|
54
|
-
assert.strictEqual(f1.relevance, 1.0);
|
|
55
|
-
|
|
56
|
-
// Decay the fact first so we can verify the boost
|
|
57
|
-
factStore.decayFacts(0.5);
|
|
58
|
-
const decayed = factStore.getFact(f1.id);
|
|
59
|
-
assert.ok(decayed);
|
|
60
|
-
// After decay + access boost the relevance should be < 1.0 but > 0.5
|
|
61
|
-
const preBoost = decayed.relevance;
|
|
62
|
-
|
|
63
|
-
// Adding same fact again should boost it
|
|
64
|
-
const f2 = factStore.addFact({ subject: 's', predicate: 'p', object: 'o', source: 'ingested' });
|
|
65
|
-
assert.strictEqual(f1.id, f2.id); // Same fact
|
|
66
|
-
assert.ok(f2.relevance >= preBoost);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
describe('getFact', () => {
|
|
70
|
-
it('should retrieve a fact by ID', () => {
|
|
71
|
-
const added = factStore.addFact({ subject: 's', predicate: 'p', object: 'o', source: 'ingested' });
|
|
72
|
-
const retrieved = factStore.getFact(added.id);
|
|
73
|
-
assert.ok(retrieved);
|
|
74
|
-
assert.strictEqual(retrieved.subject, 's');
|
|
75
|
-
assert.strictEqual(retrieved.predicate, 'p');
|
|
76
|
-
assert.strictEqual(retrieved.object, 'o');
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('should return undefined for non-existent ID', () => {
|
|
80
|
-
const result = factStore.getFact('non-existent-id');
|
|
81
|
-
assert.strictEqual(result, undefined);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('should boost relevance on access', () => {
|
|
85
|
-
const f = factStore.addFact({ subject: 's', predicate: 'p', object: 'o', source: 'ingested' });
|
|
86
|
-
factStore.decayFacts(0.5); // Decay to 0.5ish
|
|
87
|
-
const decayedFact = factStore.query({ subject: 's' })[0];
|
|
88
|
-
const decayedRelevance = decayedFact.relevance;
|
|
89
|
-
|
|
90
|
-
const accessed = factStore.getFact(f.id);
|
|
91
|
-
assert.ok(accessed);
|
|
92
|
-
assert.ok(accessed.relevance > decayedRelevance, 'Relevance should increase on access');
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('should update lastAccessed timestamp', () => {
|
|
96
|
-
const f = factStore.addFact({ subject: 's', predicate: 'p', object: 'o', source: 'ingested' });
|
|
97
|
-
const before = f.lastAccessed;
|
|
98
|
-
|
|
99
|
-
// Small delay to get a different timestamp
|
|
100
|
-
const accessed = factStore.getFact(f.id);
|
|
101
|
-
assert.ok(accessed);
|
|
102
|
-
assert.ok(new Date(accessed.lastAccessed) >= new Date(before));
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
describe('query', () => {
|
|
107
|
-
it('should query by subject', () => {
|
|
108
|
-
factStore.addFact({ subject: 'alice', predicate: 'knows', object: 'bob', source: 'ingested' });
|
|
109
|
-
factStore.addFact({ subject: 'charlie', predicate: 'knows', object: 'bob', source: 'ingested' });
|
|
110
|
-
|
|
111
|
-
const results = factStore.query({ subject: 'alice' });
|
|
112
|
-
assert.strictEqual(results.length, 1);
|
|
113
|
-
assert.strictEqual(results[0].subject, 'alice');
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('should query by predicate', () => {
|
|
117
|
-
factStore.addFact({ subject: 'a', predicate: 'is-a', object: 'b', source: 'ingested' });
|
|
118
|
-
factStore.addFact({ subject: 'c', predicate: 'works-at', object: 'd', source: 'ingested' });
|
|
119
|
-
|
|
120
|
-
const results = factStore.query({ predicate: 'is-a' });
|
|
121
|
-
assert.strictEqual(results.length, 1);
|
|
122
|
-
assert.strictEqual(results[0].predicate, 'is-a');
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('should query by object', () => {
|
|
126
|
-
factStore.addFact({ subject: 'a', predicate: 'p', object: 'target', source: 'ingested' });
|
|
127
|
-
factStore.addFact({ subject: 'b', predicate: 'p', object: 'other', source: 'ingested' });
|
|
128
|
-
|
|
129
|
-
const results = factStore.query({ object: 'target' });
|
|
130
|
-
assert.strictEqual(results.length, 1);
|
|
131
|
-
assert.strictEqual(results[0].object, 'target');
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('should query with multiple filters', () => {
|
|
135
|
-
factStore.addFact({ subject: 'a', predicate: 'p1', object: 'o1', source: 'ingested' });
|
|
136
|
-
factStore.addFact({ subject: 'a', predicate: 'p2', object: 'o2', source: 'ingested' });
|
|
137
|
-
factStore.addFact({ subject: 'b', predicate: 'p1', object: 'o1', source: 'ingested' });
|
|
138
|
-
|
|
139
|
-
const results = factStore.query({ subject: 'a', predicate: 'p1' });
|
|
140
|
-
assert.strictEqual(results.length, 1);
|
|
141
|
-
assert.strictEqual(results[0].object, 'o1');
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it('should return all facts when query is empty', () => {
|
|
145
|
-
factStore.addFact({ subject: 'a', predicate: 'p', object: 'o1', source: 'ingested' });
|
|
146
|
-
factStore.addFact({ subject: 'b', predicate: 'p', object: 'o2', source: 'ingested' });
|
|
147
|
-
const results = factStore.query({});
|
|
148
|
-
assert.strictEqual(results.length, 2);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('should sort results by relevance descending', () => {
|
|
152
|
-
const f1 = factStore.addFact({ subject: 'a', predicate: 'p', object: 'o1', source: 'ingested' });
|
|
153
|
-
factStore.addFact({ subject: 'b', predicate: 'p', object: 'o2', source: 'ingested' });
|
|
154
|
-
|
|
155
|
-
// Decay all, then access f1 to boost it
|
|
156
|
-
factStore.decayFacts(0.5);
|
|
157
|
-
factStore.getFact(f1.id);
|
|
158
|
-
|
|
159
|
-
const results = factStore.query({});
|
|
160
|
-
assert.strictEqual(results[0].subject, 'a'); // f1 has higher relevance after boost
|
|
161
|
-
});
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
describe('decayFacts', () => {
|
|
165
|
-
it('should reduce relevance of all facts', () => {
|
|
166
|
-
factStore.addFact({ subject: 'a', predicate: 'p', object: 'o', source: 'ingested' });
|
|
167
|
-
const { decayedCount } = factStore.decayFacts(0.5);
|
|
168
|
-
assert.strictEqual(decayedCount, 1);
|
|
169
|
-
|
|
170
|
-
const facts = factStore.query({});
|
|
171
|
-
assert.ok(facts[0].relevance < 1.0);
|
|
172
|
-
assert.ok(facts[0].relevance >= 0.1); // Min relevance floor
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it('should not decay below the minimum relevance of 0.1', () => {
|
|
176
|
-
factStore.addFact({ subject: 'a', predicate: 'p', object: 'o', source: 'ingested' });
|
|
177
|
-
// Apply extreme decay many times
|
|
178
|
-
for (let i = 0; i < 100; i++) factStore.decayFacts(0.99);
|
|
179
|
-
const facts = factStore.query({});
|
|
180
|
-
assert.ok(facts[0].relevance >= 0.1);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it('should return 0 when no facts exist', () => {
|
|
184
|
-
const { decayedCount } = factStore.decayFacts(0.1);
|
|
185
|
-
assert.strictEqual(decayedCount, 0);
|
|
186
|
-
});
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
describe('getUnembeddedFacts', () => {
|
|
190
|
-
it('should return facts without embedded timestamp', () => {
|
|
191
|
-
factStore.addFact({ subject: 'a', predicate: 'p', object: 'o1', source: 'ingested' });
|
|
192
|
-
factStore.addFact({ subject: 'b', predicate: 'p', object: 'o2', source: 'ingested' });
|
|
193
|
-
|
|
194
|
-
const unembedded = factStore.getUnembeddedFacts();
|
|
195
|
-
assert.strictEqual(unembedded.length, 2);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it('should exclude embedded facts', () => {
|
|
199
|
-
const f1 = factStore.addFact({ subject: 'a', predicate: 'p', object: 'o1', source: 'ingested' });
|
|
200
|
-
factStore.addFact({ subject: 'b', predicate: 'p', object: 'o2', source: 'ingested' });
|
|
201
|
-
|
|
202
|
-
factStore.markFactsAsEmbedded([f1.id]);
|
|
203
|
-
|
|
204
|
-
const unembedded = factStore.getUnembeddedFacts();
|
|
205
|
-
assert.strictEqual(unembedded.length, 1);
|
|
206
|
-
assert.strictEqual(unembedded[0].subject, 'b');
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it('should return empty array when all facts are embedded', () => {
|
|
210
|
-
const f1 = factStore.addFact({ subject: 'a', predicate: 'p', object: 'o', source: 'ingested' });
|
|
211
|
-
factStore.markFactsAsEmbedded([f1.id]);
|
|
212
|
-
|
|
213
|
-
const unembedded = factStore.getUnembeddedFacts();
|
|
214
|
-
assert.strictEqual(unembedded.length, 0);
|
|
215
|
-
});
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
describe('markFactsAsEmbedded', () => {
|
|
219
|
-
it('should set the embedded timestamp on specified facts', () => {
|
|
220
|
-
const f1 = factStore.addFact({ subject: 'a', predicate: 'p', object: 'o', source: 'ingested' });
|
|
221
|
-
assert.strictEqual(f1.embedded, undefined);
|
|
222
|
-
|
|
223
|
-
factStore.markFactsAsEmbedded([f1.id]);
|
|
224
|
-
const updated = factStore.getFact(f1.id);
|
|
225
|
-
assert.ok(updated);
|
|
226
|
-
assert.ok(updated.embedded);
|
|
227
|
-
assert.ok(typeof updated.embedded === 'string');
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it('should handle non-existent fact IDs gracefully', () => {
|
|
231
|
-
factStore.addFact({ subject: 'a', predicate: 'p', object: 'o', source: 'ingested' });
|
|
232
|
-
// Should not throw
|
|
233
|
-
factStore.markFactsAsEmbedded(['non-existent-id']);
|
|
234
|
-
assert.ok(true);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it('should only update specified facts', () => {
|
|
238
|
-
const f1 = factStore.addFact({ subject: 'a', predicate: 'p', object: 'o1', source: 'ingested' });
|
|
239
|
-
const f2 = factStore.addFact({ subject: 'b', predicate: 'p', object: 'o2', source: 'ingested' });
|
|
240
|
-
|
|
241
|
-
factStore.markFactsAsEmbedded([f1.id]);
|
|
242
|
-
|
|
243
|
-
const updated1 = factStore.getFact(f1.id);
|
|
244
|
-
const updated2 = factStore.getFact(f2.id);
|
|
245
|
-
assert.ok(updated1?.embedded);
|
|
246
|
-
assert.strictEqual(updated2?.embedded, undefined);
|
|
247
|
-
});
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
it('should remove the least recently accessed facts when pruning', () => {
|
|
251
|
-
for (let i = 0; i < 11; i++) {
|
|
252
|
-
const fact = factStore.addFact({ subject: 's', predicate: 'p', object: `o${i}`, source: 'ingested' });
|
|
253
|
-
const internalFact = (factStore as Record<string, unknown> as { facts: Map<string, Fact> }).facts.get(fact.id);
|
|
254
|
-
if (internalFact) {
|
|
255
|
-
internalFact.lastAccessed = new Date(Date.now() - (10 - i) * 1000).toISOString();
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const facts = factStore.query({});
|
|
260
|
-
assert.strictEqual(facts.length, 10);
|
|
261
|
-
|
|
262
|
-
const objects = facts.map(f => f.object);
|
|
263
|
-
assert.strictEqual(objects.includes('o0'), false, 'Fact "o0" (oldest) should have been pruned');
|
|
264
|
-
assert.strictEqual(objects.includes('o1'), true, 'Fact "o1" should still exist');
|
|
265
|
-
});
|
|
266
|
-
});
|