@soederpop/luca 0.1.3 → 0.2.1
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/.github/workflows/release.yaml +167 -0
- package/README.md +3 -0
- package/assistants/codingAssistant/ABOUT.md +3 -0
- package/assistants/codingAssistant/CORE.md +22 -17
- package/assistants/codingAssistant/hooks.ts +19 -2
- package/assistants/codingAssistant/tools.ts +1 -106
- package/assistants/inkbot/ABOUT.md +5 -0
- package/assistants/inkbot/CORE.md +2 -0
- package/bun.lock +20 -4
- package/commands/release.ts +75 -181
- package/docs/ideas/assistant-factory-pattern.md +142 -0
- package/package.json +3 -2
- package/src/agi/container.server.ts +10 -0
- package/src/agi/features/agent-memory.ts +694 -0
- package/src/agi/features/assistant.ts +1 -1
- package/src/agi/features/assistants-manager.ts +25 -0
- package/src/agi/features/browser-use.ts +30 -0
- package/src/agi/features/coding-tools.ts +175 -0
- package/src/agi/features/file-tools.ts +33 -26
- package/src/agi/features/skills-library.ts +28 -11
- package/src/bootstrap/generated.ts +1 -1
- package/src/cli/build-info.ts +2 -2
- package/src/clients/voicebox/index.ts +300 -0
- package/src/introspection/generated.agi.ts +1997 -789
- package/src/introspection/generated.node.ts +788 -736
- package/src/introspection/generated.web.ts +1 -1
- package/src/node/features/content-db.ts +54 -27
- package/src/node/features/process-manager.ts +50 -17
- package/src/python/generated.ts +1 -1
- package/src/scaffolds/generated.ts +1 -1
- package/test/assistant.test.ts +14 -5
- package/test-integration/memory.test.ts +204 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { setBuildTimeData, setContainerBuildTimeData } from './index.js';
|
|
2
2
|
|
|
3
3
|
// Auto-generated introspection registry data
|
|
4
|
-
// Generated at: 2026-04-
|
|
4
|
+
// Generated at: 2026-04-05T06:58:06.168Z
|
|
5
5
|
|
|
6
6
|
setBuildTimeData('features.containerLink', {
|
|
7
7
|
"id": "features.containerLink",
|
|
@@ -5,6 +5,7 @@ import { z } from 'zod'
|
|
|
5
5
|
import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
|
|
6
6
|
import { realpathSync } from 'node:fs'
|
|
7
7
|
import type { GrepOptions } from './grep.js'
|
|
8
|
+
import type { Helper } from '../../helper.js'
|
|
8
9
|
|
|
9
10
|
export const ContentDbStateSchema = FeatureStateSchema.extend({
|
|
10
11
|
loaded: z.boolean().default(false).describe('Whether the content collection has been loaded and parsed'),
|
|
@@ -49,67 +50,93 @@ export class ContentDb extends Feature<ContentDbState, ContentDbOptions> {
|
|
|
49
50
|
static override tools: Record<string, { schema: z.ZodType; handler?: Function }> = {
|
|
50
51
|
getCollectionOverview: {
|
|
51
52
|
schema: z.object({}).describe(
|
|
52
|
-
'Get a high-level overview of the document collection: models,
|
|
53
|
+
'Get a high-level overview of the document collection: what models exist, how many documents each has, the directory tree, and search index status. Call this FIRST to understand the collection before exploring individual documents.'
|
|
53
54
|
),
|
|
54
55
|
},
|
|
55
56
|
listDocuments: {
|
|
56
57
|
schema: z.object({
|
|
57
|
-
model: z.string().optional().describe('Filter to documents
|
|
58
|
-
glob: z.string().optional().describe('Glob pattern to filter document path
|
|
58
|
+
model: z.string().optional().describe('Filter to documents of this model type (e.g. "Plan", "Task"). Get model names from getCollectionOverview.'),
|
|
59
|
+
glob: z.string().optional().describe('Glob pattern to filter by document path (e.g. "guides/*", "apis/**/*.md")'),
|
|
59
60
|
}).describe(
|
|
60
|
-
'List
|
|
61
|
+
'List document IDs in the collection. Use this to browse what\'s available before reading. Filter by model or glob to narrow results.'
|
|
61
62
|
),
|
|
62
63
|
},
|
|
63
64
|
readDocument: {
|
|
64
65
|
schema: z.object({
|
|
65
|
-
id: z.string().describe('The document path ID
|
|
66
|
-
include: z.array(z.string()).optional().describe('Only return these
|
|
67
|
-
exclude: z.array(z.string()).optional().describe('
|
|
68
|
-
meta: z.boolean().optional().describe('Include YAML frontmatter in the output'),
|
|
66
|
+
id: z.string().describe('The document path ID (e.g. "guides/intro", "apis/auth"). Get valid IDs from listDocuments.'),
|
|
67
|
+
include: z.array(z.string()).optional().describe('Only return sections with these headings. Use to read specific parts of long documents without loading everything.'),
|
|
68
|
+
exclude: z.array(z.string()).optional().describe('Skip sections with these headings. Use to filter out irrelevant parts.'),
|
|
69
|
+
meta: z.boolean().optional().describe('Include the YAML frontmatter (title, status, tags, etc.) in the output. Useful for understanding document metadata.'),
|
|
69
70
|
}).describe(
|
|
70
|
-
'Read a
|
|
71
|
+
'Read a document by its path ID. Use include/exclude to request only the sections you need — don\'t load an entire document when you only need one section.'
|
|
71
72
|
),
|
|
72
73
|
},
|
|
73
74
|
readMultipleDocuments: {
|
|
74
75
|
schema: z.object({
|
|
75
|
-
ids: z.array(z.string()).describe('Array of document path IDs to read'),
|
|
76
|
-
include: z.array(z.string()).optional().describe('Only return these
|
|
77
|
-
exclude: z.array(z.string()).optional().describe('
|
|
78
|
-
meta: z.boolean().optional().describe('Include YAML frontmatter
|
|
76
|
+
ids: z.array(z.string()).describe('Array of document path IDs to read. Get valid IDs from listDocuments.'),
|
|
77
|
+
include: z.array(z.string()).optional().describe('Only return sections with these headings from each document.'),
|
|
78
|
+
exclude: z.array(z.string()).optional().describe('Skip sections with these headings from each document.'),
|
|
79
|
+
meta: z.boolean().optional().describe('Include YAML frontmatter for each document.'),
|
|
79
80
|
}).describe(
|
|
80
|
-
'Read multiple documents
|
|
81
|
+
'Read multiple documents in one call. More efficient than calling readDocument in a loop. Returns documents concatenated with dividers.'
|
|
81
82
|
),
|
|
82
83
|
},
|
|
83
84
|
queryDocuments: {
|
|
84
85
|
schema: z.object({
|
|
85
|
-
model: z.string().describe('The model name to query (e.g. "Plan", "Task")'),
|
|
86
|
-
where: z.string().optional().describe('
|
|
87
|
-
sort: z.string().optional().describe('Sort
|
|
88
|
-
limit: z.number().optional().describe('Maximum number of results
|
|
89
|
-
offset: z.number().optional().describe('
|
|
90
|
-
select: z.array(z.string()).optional().describe('
|
|
86
|
+
model: z.string().describe('The model name to query (e.g. "Plan", "Task", "Guide"). Must match a model defined in the collection — check getCollectionOverview.'),
|
|
87
|
+
where: z.string().optional().describe('MongoDB-style filter as a JSON string. Dot notation for nested fields. Examples: \'{"meta.status": "approved"}\', \'{"meta.priority": {"$gt": 3}}\', \'{"meta.tags": {"$in": ["urgent"]}}\''),
|
|
88
|
+
sort: z.string().optional().describe('Sort as a JSON string. Example: \'{"meta.priority": "desc"}\', \'{"meta.createdAt": "asc"}\''),
|
|
89
|
+
limit: z.number().optional().describe('Maximum number of results. Default: all matching documents.'),
|
|
90
|
+
offset: z.number().optional().describe('Skip this many results (for pagination).'),
|
|
91
|
+
select: z.array(z.string()).optional().describe('Only include these fields in output (e.g. ["id", "title", "meta.status"]). Reduces noise when you only need specific metadata.'),
|
|
91
92
|
}).describe(
|
|
92
|
-
'Query documents by model with
|
|
93
|
+
'Query documents by model with filtering, sorting, and pagination. Use this when you need to find documents matching specific criteria (status, priority, tags, dates) rather than browsing by name.'
|
|
93
94
|
),
|
|
94
95
|
},
|
|
95
96
|
searchContent: {
|
|
96
97
|
schema: z.object({
|
|
97
|
-
pattern: z.string().describe('Regex pattern to search for across all
|
|
98
|
-
caseSensitive: z.boolean().optional().describe('
|
|
98
|
+
pattern: z.string().describe('Regex pattern to search for across all document content. Examples: "TODO|FIXME", "authentication", "def.*handler"'),
|
|
99
|
+
caseSensitive: z.boolean().optional().describe('Case-sensitive search. Default: false (case insensitive).'),
|
|
99
100
|
}).describe(
|
|
100
|
-
'Text/regex search (grep) across all documents
|
|
101
|
+
'Text/regex search (grep) across all documents. Use for exact pattern matching, code references, or finding specific terms. Returns matching lines with file context. For natural language questions, use semanticSearch instead.'
|
|
101
102
|
),
|
|
102
103
|
},
|
|
103
104
|
semanticSearch: {
|
|
104
105
|
schema: z.object({
|
|
105
|
-
query: z.string().describe('
|
|
106
|
-
limit: z.number().optional().describe('Maximum
|
|
106
|
+
query: z.string().describe('A natural language question or topic description. Example: "how does the authentication flow work?" or "deployment configuration options"'),
|
|
107
|
+
limit: z.number().optional().describe('Maximum results to return. Default: 10.'),
|
|
107
108
|
}).describe(
|
|
108
|
-
'
|
|
109
|
+
'Search documents using natural language — combines keyword matching with semantic similarity. Best for questions and topic exploration. Falls back to text search if no vector index exists. For exact pattern matching, use searchContent instead.'
|
|
109
110
|
),
|
|
110
111
|
},
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
/**
|
|
115
|
+
* When an assistant uses contentDb, inject system prompt guidance
|
|
116
|
+
* about progressive document exploration.
|
|
117
|
+
*/
|
|
118
|
+
override setupToolsConsumer(consumer: Helper) {
|
|
119
|
+
if (typeof (consumer as any).addSystemPromptExtension === 'function') {
|
|
120
|
+
(consumer as any).addSystemPromptExtension('contentDb', [
|
|
121
|
+
'## Document Collection',
|
|
122
|
+
'',
|
|
123
|
+
'You have access to a structured document collection (markdown files with frontmatter, organized by model/type).',
|
|
124
|
+
'',
|
|
125
|
+
'**Progressive exploration — go broad to narrow:**',
|
|
126
|
+
'1. `getCollectionOverview` — start here. Shows models, document counts, and directory structure.',
|
|
127
|
+
'2. `listDocuments` — browse document IDs, optionally filtered by model or glob.',
|
|
128
|
+
'3. `readDocument` — read a specific document. Use `include`/`exclude` to skip irrelevant sections.',
|
|
129
|
+
'4. `queryDocuments` — filter documents by metadata (status, priority, tags, etc.) with MongoDB-style queries.',
|
|
130
|
+
'',
|
|
131
|
+
'**Searching:**',
|
|
132
|
+
'- `semanticSearch` — best for natural language questions ("how does authentication work?")',
|
|
133
|
+
'- `searchContent` — best for exact patterns, code references, or regex across all documents',
|
|
134
|
+
'',
|
|
135
|
+
'**Efficiency:** Don\'t read entire documents when you only need one section. Use `include` to request specific headings. Use `readMultipleDocuments` to batch reads instead of calling `readDocument` in a loop.',
|
|
136
|
+
].join('\n'))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
113
140
|
override get initialState(): ContentDbState {
|
|
114
141
|
return {
|
|
115
142
|
...super.initialState,
|
|
@@ -4,6 +4,7 @@ import { Feature } from '../feature.js'
|
|
|
4
4
|
import { State } from '../../state.js'
|
|
5
5
|
import { Bus, type EventMap } from '../../bus.js'
|
|
6
6
|
import type { ChildProcess } from './proc.js'
|
|
7
|
+
import type { Helper } from '../../helper.js'
|
|
7
8
|
|
|
8
9
|
// ─── Output Buffer ─────────────────────────────────────────────────────────
|
|
9
10
|
|
|
@@ -439,43 +440,43 @@ export class ProcessManager extends Feature {
|
|
|
439
440
|
static override tools: Record<string, { schema: z.ZodType; handler?: Function }> = {
|
|
440
441
|
spawnProcess: {
|
|
441
442
|
schema: z.object({
|
|
442
|
-
command: z.string().describe('The
|
|
443
|
-
args: z.string().optional().describe('
|
|
444
|
-
tag: z.string().optional().describe('A label for this process
|
|
445
|
-
cwd: z.string().optional().describe('Working directory for the process'),
|
|
443
|
+
command: z.string().describe('The executable to run (e.g. "node", "bun", "python"). NOT a shell command — use runCommand for shell syntax like pipes or &&.'),
|
|
444
|
+
args: z.string().optional().describe('Arguments as a single space-separated string. WARNING: spaces are used to split args, so paths with spaces will break. For complex argument quoting, prefer runCommand instead.'),
|
|
445
|
+
tag: z.string().optional().describe('A short, descriptive label for this process (e.g. "api-server", "file-watcher"). Always set a tag — it makes the process easy to find later with getProcessOutput and killProcess.'),
|
|
446
|
+
cwd: z.string().optional().describe('Working directory for the process. Defaults to the project root.'),
|
|
446
447
|
}).describe(
|
|
447
|
-
'
|
|
448
|
+
'Start a long-running background process (server, watcher, daemon). Returns immediately with a process ID — the process keeps running. Use this for anything that runs indefinitely. After spawning, call getProcessOutput to check if it started successfully. Always set a tag.'
|
|
448
449
|
),
|
|
449
450
|
},
|
|
450
451
|
runCommand: {
|
|
451
452
|
schema: z.object({
|
|
452
|
-
command: z.string().describe('
|
|
453
|
-
cwd: z.string().optional().describe('Working directory for the command'),
|
|
453
|
+
command: z.string().describe('A full shell command string (executed via sh -c). Supports pipes, &&, redirects, env vars, globs — anything you can type in a terminal. Examples: "bun test", "npm install && npm run build", "cat logs/*.txt | grep ERROR"'),
|
|
454
|
+
cwd: z.string().optional().describe('Working directory for the command. Defaults to the project root.'),
|
|
454
455
|
}).describe(
|
|
455
|
-
'Run a command and wait for it to complete. Returns
|
|
456
|
+
'Run a shell command and wait for it to complete. Returns stdout, stderr, and exit code. Use this for commands that finish on their own — builds, installs, tests, one-off scripts. For anything that runs forever (servers, watchers), use spawnProcess instead.'
|
|
456
457
|
),
|
|
457
458
|
},
|
|
458
459
|
listProcesses: {
|
|
459
460
|
schema: z.object({}).describe(
|
|
460
|
-
'List all tracked processes with their status, PID, command, uptime, and
|
|
461
|
+
'List all tracked background processes with their status, PID, command, uptime, and the last few lines of output. Call this to get an overview before deciding which process to inspect or kill.'
|
|
461
462
|
),
|
|
462
463
|
},
|
|
463
464
|
getProcessOutput: {
|
|
464
465
|
schema: z.object({
|
|
465
|
-
id: z.string().optional().describe('The process ID
|
|
466
|
-
tag: z.string().optional().describe('The tag
|
|
467
|
-
stream: z.string().optional().describe('
|
|
466
|
+
id: z.string().optional().describe('The process ID (returned by spawnProcess). Provide either id or tag, not both.'),
|
|
467
|
+
tag: z.string().optional().describe('The tag you assigned when spawning the process. Provide either id or tag, not both.'),
|
|
468
|
+
stream: z.string().optional().describe('"stdout" (default) or "stderr". Check stderr when a process crashes or behaves unexpectedly.'),
|
|
468
469
|
}).describe(
|
|
469
|
-
'
|
|
470
|
+
'Read a background process\'s buffered output — the first 20 lines (startup) and last 50 lines (recent activity). Call this after spawning to verify the process started correctly, and periodically to monitor its health.'
|
|
470
471
|
),
|
|
471
472
|
},
|
|
472
473
|
killProcess: {
|
|
473
474
|
schema: z.object({
|
|
474
|
-
id: z.string().optional().describe('The process ID to kill'),
|
|
475
|
-
tag: z.string().optional().describe('The tag of the process to kill'),
|
|
476
|
-
signal: z.string().optional().describe('
|
|
475
|
+
id: z.string().optional().describe('The process ID to kill. Provide either id or tag, not both.'),
|
|
476
|
+
tag: z.string().optional().describe('The tag of the process to kill. Provide either id or tag, not both.'),
|
|
477
|
+
signal: z.string().optional().describe('"SIGTERM" (default) for graceful shutdown, "SIGKILL" to force-kill a stuck process. Try SIGTERM first.'),
|
|
477
478
|
}).describe(
|
|
478
|
-
'
|
|
479
|
+
'Stop a running background process. Use SIGTERM (default) for graceful shutdown. If a process doesn\'t respond, follow up with SIGKILL. Always clean up processes you spawned when they\'re no longer needed.'
|
|
479
480
|
),
|
|
480
481
|
},
|
|
481
482
|
}
|
|
@@ -593,6 +594,38 @@ export class ProcessManager extends Feature {
|
|
|
593
594
|
return { id: handler.id, status: handler.status, signal, message: 'Process killed.' }
|
|
594
595
|
}
|
|
595
596
|
|
|
597
|
+
/**
|
|
598
|
+
* When an assistant uses processManager, inject system prompt guidance
|
|
599
|
+
* about how to manage processes safely and effectively.
|
|
600
|
+
*/
|
|
601
|
+
override setupToolsConsumer(consumer: Helper) {
|
|
602
|
+
if (typeof (consumer as any).addSystemPromptExtension === 'function') {
|
|
603
|
+
(consumer as any).addSystemPromptExtension('processManager', [
|
|
604
|
+
'## Process Management',
|
|
605
|
+
'',
|
|
606
|
+
'**Choosing the right tool:**',
|
|
607
|
+
'- `runCommand` — for anything that finishes on its own (builds, tests, installs, queries). Blocks until done.',
|
|
608
|
+
'- `spawnProcess` — for anything that runs indefinitely (servers, watchers, tails). Returns immediately.',
|
|
609
|
+
'- When in doubt: if you\'d press Ctrl-C to stop it, use `spawnProcess`. If you\'d wait for it, use `runCommand`.',
|
|
610
|
+
'',
|
|
611
|
+
'**After spawning a process:**',
|
|
612
|
+
'1. Always assign a descriptive `tag` so you can reference it later',
|
|
613
|
+
'2. Call `getProcessOutput` within a few seconds to verify it started correctly',
|
|
614
|
+
'3. Check `stderr` if the process crashes or output looks wrong',
|
|
615
|
+
'',
|
|
616
|
+
'**Monitoring:**',
|
|
617
|
+
'- Call `listProcesses` to see all running and finished processes at a glance',
|
|
618
|
+
'- Call `getProcessOutput` to read recent output — it keeps the first 20 and last 50 lines',
|
|
619
|
+
'- A process with status "crashed" exited with a non-zero code — check its stderr for the error',
|
|
620
|
+
'',
|
|
621
|
+
'**Cleanup:**',
|
|
622
|
+
'- Always `killProcess` background processes when they\'re no longer needed',
|
|
623
|
+
'- Use SIGTERM (default) first for graceful shutdown. Only use SIGKILL if SIGTERM doesn\'t work.',
|
|
624
|
+
'- If you spawned it, you\'re responsible for killing it',
|
|
625
|
+
].join('\n'))
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
596
629
|
// ─── Core API ───────────────────────────────────────────────────────────
|
|
597
630
|
|
|
598
631
|
/**
|
package/src/python/generated.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Auto-generated scaffold and MCP readme content
|
|
2
|
-
// Generated at: 2026-04-
|
|
2
|
+
// Generated at: 2026-04-05T06:58:07.148Z
|
|
3
3
|
// Source: docs/scaffolds/*.md, docs/examples/assistant/, and docs/mcp/readme.md
|
|
4
4
|
//
|
|
5
5
|
// Do not edit manually. Run: luca build-scaffolds
|
package/test/assistant.test.ts
CHANGED
|
@@ -15,18 +15,27 @@ describe('Assistant', () => {
|
|
|
15
15
|
expect(assistant.systemPrompt).toContain('coding assistant')
|
|
16
16
|
})
|
|
17
17
|
|
|
18
|
-
it('loads tools from
|
|
19
|
-
const assistant = container.feature('assistant', {
|
|
18
|
+
it('loads tools from codingTools feature after start', async () => {
|
|
19
|
+
const assistant = container.feature('assistant', {
|
|
20
|
+
folder: 'assistants/codingAssistant',
|
|
21
|
+
local: true,
|
|
22
|
+
model: 'qwen/qwen3-8b',
|
|
23
|
+
})
|
|
24
|
+
await assistant.start()
|
|
20
25
|
const tools = assistant.availableTools
|
|
21
26
|
expect(tools).toContain('rg')
|
|
22
27
|
expect(tools).toContain('ls')
|
|
23
28
|
expect(tools).toContain('cat')
|
|
24
|
-
expect(tools).toContain('pwd')
|
|
25
29
|
expect(tools.length).toBeGreaterThan(0)
|
|
26
30
|
})
|
|
27
31
|
|
|
28
|
-
it('tools have descriptions and parameter schemas', () => {
|
|
29
|
-
const assistant = container.feature('assistant', {
|
|
32
|
+
it('tools have descriptions and parameter schemas', async () => {
|
|
33
|
+
const assistant = container.feature('assistant', {
|
|
34
|
+
folder: 'assistants/codingAssistant',
|
|
35
|
+
local: true,
|
|
36
|
+
model: 'qwen/qwen3-8b',
|
|
37
|
+
})
|
|
38
|
+
await assistant.start()
|
|
30
39
|
const { rg, ls, cat } = assistant.tools
|
|
31
40
|
expect(rg.description.length).toBeGreaterThan(0)
|
|
32
41
|
expect(rg.parameters.type).toBe('object')
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import {
|
|
2
|
+
requireEnv,
|
|
3
|
+
describeWithRequirements,
|
|
4
|
+
createAGIContainer,
|
|
5
|
+
API_TIMEOUT,
|
|
6
|
+
} from './helpers'
|
|
7
|
+
import type { AGIContainer } from '../src/agi/container.server'
|
|
8
|
+
import type { Memory } from '../src/agi/features/agent-memory'
|
|
9
|
+
|
|
10
|
+
const openaiKey = requireEnv('OPENAI_API_KEY')
|
|
11
|
+
|
|
12
|
+
describeWithRequirements('Memory Integration', [openaiKey], () => {
|
|
13
|
+
let container: AGIContainer
|
|
14
|
+
let mem: Memory
|
|
15
|
+
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
container = createAGIContainer()
|
|
18
|
+
mem = container.feature('memory', { namespace: 'test-integration' }) as unknown as Memory
|
|
19
|
+
await mem.initDb()
|
|
20
|
+
await mem.wipeAll()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
afterAll(async () => {
|
|
24
|
+
await mem.wipeAll()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('Feature registration', () => {
|
|
28
|
+
it('registers in the container', () => {
|
|
29
|
+
expect(container.features.available).toContain('memory')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('has correct shortcut', () => {
|
|
33
|
+
const { Memory: MemClass } = require('../src/agi/features/agent-memory')
|
|
34
|
+
expect(MemClass.shortcut).toBe('features.memory')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('initializes database', () => {
|
|
38
|
+
expect(mem.state.get('dbReady')).toBe(true)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('CRUD operations', () => {
|
|
43
|
+
it('creates and retrieves a memory', async () => {
|
|
44
|
+
const m = await mem.create('facts', 'Jonathan is a software engineer based in Austin')
|
|
45
|
+
expect(m.id).toBeGreaterThan(0)
|
|
46
|
+
expect(m.category).toBe('facts')
|
|
47
|
+
expect(m.document).toBe('Jonathan is a software engineer based in Austin')
|
|
48
|
+
|
|
49
|
+
const fetched = await mem.get('facts', m.id)
|
|
50
|
+
expect(fetched).not.toBeNull()
|
|
51
|
+
expect(fetched!.document).toBe(m.document)
|
|
52
|
+
}, API_TIMEOUT)
|
|
53
|
+
|
|
54
|
+
it('updates a memory', async () => {
|
|
55
|
+
const m = await mem.create('facts', 'Prefers dark mode')
|
|
56
|
+
const updated = await mem.update('facts', m.id, {
|
|
57
|
+
text: 'Prefers dark mode, especially Dracula theme',
|
|
58
|
+
metadata: { updated: 'true' },
|
|
59
|
+
})
|
|
60
|
+
expect(updated!.document).toBe('Prefers dark mode, especially Dracula theme')
|
|
61
|
+
expect(updated!.metadata.updated).toBe('true')
|
|
62
|
+
}, API_TIMEOUT)
|
|
63
|
+
|
|
64
|
+
it('deletes a memory', async () => {
|
|
65
|
+
const m = await mem.create('scratch', 'temporary memory')
|
|
66
|
+
expect(await mem.delete('scratch', m.id)).toBe(true)
|
|
67
|
+
expect(await mem.get('scratch', m.id)).toBeNull()
|
|
68
|
+
}, API_TIMEOUT)
|
|
69
|
+
|
|
70
|
+
it('lists categories', async () => {
|
|
71
|
+
const cats = await mem.categories()
|
|
72
|
+
expect(cats).toContain('facts')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('counts memories', async () => {
|
|
76
|
+
const total = await mem.count()
|
|
77
|
+
expect(total).toBeGreaterThan(0)
|
|
78
|
+
const factCount = await mem.count('facts')
|
|
79
|
+
expect(factCount).toBeGreaterThan(0)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('gets all memories with filtering', async () => {
|
|
83
|
+
await mem.create('prefs', 'Likes iterative delivery', { source: 'onboarding' })
|
|
84
|
+
await mem.create('prefs', 'Prefers concise responses')
|
|
85
|
+
|
|
86
|
+
const onboarding = await mem.getAll('prefs', { filterMetadata: { source: 'onboarding' } })
|
|
87
|
+
expect(onboarding.length).toBeGreaterThan(0)
|
|
88
|
+
expect(onboarding.every(m => m.metadata.source === 'onboarding')).toBe(true)
|
|
89
|
+
}, API_TIMEOUT)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('Semantic search', () => {
|
|
93
|
+
it('finds relevant memories by query', async () => {
|
|
94
|
+
const results = await mem.search('facts', 'Where does the user live?', 3)
|
|
95
|
+
expect(results.length).toBeGreaterThan(0)
|
|
96
|
+
expect(results[0].distance).toBeLessThan(1)
|
|
97
|
+
}, API_TIMEOUT)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('Deduplication', () => {
|
|
101
|
+
it('blocks near-duplicate memories', async () => {
|
|
102
|
+
const before = await mem.count('facts')
|
|
103
|
+
const dup = await mem.createUnique('facts', 'Jonathan is a software engineer living in Austin, TX')
|
|
104
|
+
const after = await mem.count('facts')
|
|
105
|
+
expect(dup).toBeNull()
|
|
106
|
+
expect(after).toBe(before)
|
|
107
|
+
}, API_TIMEOUT)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('Epochs and events', () => {
|
|
111
|
+
it('tracks epoch state', () => {
|
|
112
|
+
expect(mem.getEpoch()).toBe(1)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('creates events and increments epoch', async () => {
|
|
116
|
+
await mem.createEvent('User started a new project')
|
|
117
|
+
await mem.incrementEpoch()
|
|
118
|
+
expect(mem.getEpoch()).toBe(2)
|
|
119
|
+
|
|
120
|
+
await mem.createEvent('User requested a test script')
|
|
121
|
+
|
|
122
|
+
const e1 = await mem.getEvents({ epoch: 1 })
|
|
123
|
+
const e2 = await mem.getEvents({ epoch: 2 })
|
|
124
|
+
expect(e1.length).toBeGreaterThan(0)
|
|
125
|
+
expect(e2.length).toBeGreaterThan(0)
|
|
126
|
+
}, API_TIMEOUT)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('Export and import', () => {
|
|
130
|
+
it('roundtrips through export/import', async () => {
|
|
131
|
+
const exported = await mem.exportToJson()
|
|
132
|
+
expect(exported.memories.length).toBeGreaterThan(0)
|
|
133
|
+
|
|
134
|
+
await mem.wipeAll()
|
|
135
|
+
expect(await mem.count()).toBe(0)
|
|
136
|
+
|
|
137
|
+
const imported = await mem.importFromJson(exported)
|
|
138
|
+
expect(imported).toBe(exported.memories.length)
|
|
139
|
+
expect(await mem.count()).toBe(exported.memories.length)
|
|
140
|
+
}, API_TIMEOUT * 3)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('Tool interface (toTools)', () => {
|
|
144
|
+
it('exposes tools via standard toTools() pattern', () => {
|
|
145
|
+
const { schemas, handlers } = mem.toTools()
|
|
146
|
+
expect(Object.keys(schemas)).toContain('remember')
|
|
147
|
+
expect(Object.keys(schemas)).toContain('recall')
|
|
148
|
+
expect(Object.keys(schemas)).toContain('forgetCategory')
|
|
149
|
+
expect(Object.keys(schemas)).toContain('listCategories')
|
|
150
|
+
expect(typeof handlers.remember).toBe('function')
|
|
151
|
+
expect(typeof handlers.recall).toBe('function')
|
|
152
|
+
expect(typeof handlers.forgetCategory).toBe('function')
|
|
153
|
+
expect(typeof handlers.listCategories).toBe('function')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('toTools only/except filtering works', () => {
|
|
157
|
+
const { schemas: onlySchemas } = mem.toTools({ only: ['remember', 'recall'] })
|
|
158
|
+
expect(Object.keys(onlySchemas)).toEqual(['remember', 'recall'])
|
|
159
|
+
|
|
160
|
+
const { schemas: exceptSchemas } = mem.toTools({ except: ['forgetCategory'] })
|
|
161
|
+
expect(Object.keys(exceptSchemas)).not.toContain('forgetCategory')
|
|
162
|
+
expect(Object.keys(exceptSchemas)).toContain('remember')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('remember tool stores and deduplicates', async () => {
|
|
166
|
+
const { handlers } = mem.toTools()
|
|
167
|
+
const result = await handlers.remember({ category: 'facts', text: 'Has a golden retriever named Max' })
|
|
168
|
+
expect(result.stored).toBe(true)
|
|
169
|
+
|
|
170
|
+
const dup = await handlers.remember({ category: 'facts', text: 'Has a golden retriever named Max' })
|
|
171
|
+
expect(dup.stored).toBe(false)
|
|
172
|
+
}, API_TIMEOUT)
|
|
173
|
+
|
|
174
|
+
it('recall tool searches memories', async () => {
|
|
175
|
+
const { handlers } = mem.toTools()
|
|
176
|
+
const results = await handlers.recall({ category: 'facts', query: 'pets and animals', n_results: 2 })
|
|
177
|
+
expect(results.length).toBeGreaterThan(0)
|
|
178
|
+
expect(results[0]).toHaveProperty('document')
|
|
179
|
+
expect(results[0]).toHaveProperty('distance')
|
|
180
|
+
}, API_TIMEOUT)
|
|
181
|
+
|
|
182
|
+
it('listCategories tool returns counts', async () => {
|
|
183
|
+
const { handlers } = mem.toTools()
|
|
184
|
+
const result = await handlers.listCategories()
|
|
185
|
+
expect(result.categories).toBeDefined()
|
|
186
|
+
expect(typeof result.categories.facts).toBe('number')
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('Wipe operations', () => {
|
|
191
|
+
it('wipeCategory removes only that category', async () => {
|
|
192
|
+
const before = await mem.count()
|
|
193
|
+
const deleted = await mem.wipeCategory('scratch')
|
|
194
|
+
const after = await mem.count()
|
|
195
|
+
expect(after).toBeLessThanOrEqual(before)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('wipeAll removes everything and resets epoch', async () => {
|
|
199
|
+
await mem.wipeAll()
|
|
200
|
+
expect(await mem.count()).toBe(0)
|
|
201
|
+
expect(mem.getEpoch()).toBe(1)
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
})
|