@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.
@@ -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-03T01:24:52.187Z
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, document counts, directory tree, and search index status. Call this first to understand what is available.'
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 belonging to this model name'),
58
- glob: z.string().optional().describe('Glob pattern to filter document path IDs (e.g. "guides/*", "apis/**")'),
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 available document IDs in the collection, optionally filtered by model or glob pattern.'
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 to read (e.g. "guides/intro")'),
66
- include: z.array(z.string()).optional().describe('Only return these section headings'),
67
- exclude: z.array(z.string()).optional().describe('Remove these section headings from the output'),
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 single document by its path ID. Use include/exclude to read only specific sections and avoid loading unnecessary content.'
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 section headings from each document'),
77
- exclude: z.array(z.string()).optional().describe('Remove these section headings from each document'),
78
- meta: z.boolean().optional().describe('Include YAML frontmatter in the output'),
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 at once, concatenated with dividers. Use include/exclude to focus on relevant sections.'
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('Filter conditions as JSON string, e.g. \'{ "meta.status": "approved" }\' or \'{ "meta.priority": { "$gt": 3 } }\''),
87
- sort: z.string().optional().describe('Sort specification as JSON string, e.g. \'{ "meta.priority": "desc" }\''),
88
- limit: z.number().optional().describe('Maximum number of results to return'),
89
- offset: z.number().optional().describe('Number of results to skip'),
90
- select: z.array(z.string()).optional().describe('Fields to include in output (e.g. ["id", "title", "meta.status"])'),
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 MongoDB-style filtering, sorting, and pagination. Returns serialized model instances.'
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 documents'),
98
- caseSensitive: z.boolean().optional().describe('Whether the search is case-sensitive (default: false)'),
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 in the collection. Returns matching lines with file context.'
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('Natural language search query'),
106
- limit: z.number().optional().describe('Maximum number of results (default: 10)'),
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
- 'Semantic search across documents using keyword + vector similarity. Falls back to text search if no search index exists.'
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 command to execute (e.g. "node", "bun", "python")'),
443
- args: z.string().optional().describe('Space-separated arguments to pass to the command'),
444
- tag: z.string().optional().describe('A label for this process so you can find it later'),
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
- 'Spawn a long-running process (server, watcher, daemon) that runs in the background. Returns immediately with a process ID you can use to check status or kill it later.'
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('The command to execute (e.g. "npm install", "bun test")'),
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 the full stdout/stderr output and exit code. Use this for commands you expect to finish (builds, installs, tests).'
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 a preview of recent output.'
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 to get output for'),
466
- tag: z.string().optional().describe('The tag of the process to get output for'),
467
- stream: z.string().optional().describe('Which stream to read: "stdout" (default) or "stderr"'),
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
- 'Peek at a process\'s buffered output — shows the first 20 lines and last 50 lines of stdout or stderr.'
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('Signal to send: "SIGTERM" (default, graceful) or "SIGKILL" (force)'),
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
- 'Kill a running process by ID or tag.'
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
  /**
@@ -1,5 +1,5 @@
1
1
  // Auto-generated Python bridge script
2
- // Generated at: 2026-04-03T01:24:54.795Z
2
+ // Generated at: 2026-04-05T06:58:08.827Z
3
3
  // Source: src/python/bridge.py
4
4
  //
5
5
  // Do not edit manually. Run: luca build-python-bridge
@@ -1,5 +1,5 @@
1
1
  // Auto-generated scaffold and MCP readme content
2
- // Generated at: 2026-04-03T01:24:53.146Z
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
@@ -15,18 +15,27 @@ describe('Assistant', () => {
15
15
  expect(assistant.systemPrompt).toContain('coding assistant')
16
16
  })
17
17
 
18
- it('loads tools from tools.ts via the VM', () => {
19
- const assistant = container.feature('assistant', { folder: 'assistants/codingAssistant' })
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', { folder: 'assistants/codingAssistant' })
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
+ })