@raviolelabs/engram-mcp 0.2.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.
- package/CLAUDE.md +232 -0
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/SKILL.md +299 -0
- package/dist/cloud/auth.d.ts +29 -0
- package/dist/cloud/auth.d.ts.map +1 -0
- package/dist/cloud/auth.js +132 -0
- package/dist/cloud/auth.js.map +1 -0
- package/dist/cloud/bridge-client.d.ts +10 -0
- package/dist/cloud/bridge-client.d.ts.map +1 -0
- package/dist/cloud/bridge-client.js +167 -0
- package/dist/cloud/bridge-client.js.map +1 -0
- package/dist/cloud/crypto.d.ts +42 -0
- package/dist/cloud/crypto.d.ts.map +1 -0
- package/dist/cloud/crypto.js +146 -0
- package/dist/cloud/crypto.js.map +1 -0
- package/dist/cloud/endpoints.d.ts +26 -0
- package/dist/cloud/endpoints.d.ts.map +1 -0
- package/dist/cloud/endpoints.js +26 -0
- package/dist/cloud/endpoints.js.map +1 -0
- package/dist/cloud/pairing.d.ts +30 -0
- package/dist/cloud/pairing.d.ts.map +1 -0
- package/dist/cloud/pairing.js +157 -0
- package/dist/cloud/pairing.js.map +1 -0
- package/dist/cloud/transit-poller.d.ts +35 -0
- package/dist/cloud/transit-poller.d.ts.map +1 -0
- package/dist/cloud/transit-poller.js +281 -0
- package/dist/cloud/transit-poller.js.map +1 -0
- package/dist/config/index.d.ts +3 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +24 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/schema.d.ts +466 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +171 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/core/db/index.d.ts +7 -0
- package/dist/core/db/index.d.ts.map +1 -0
- package/dist/core/db/index.js +273 -0
- package/dist/core/db/index.js.map +1 -0
- package/dist/core/logger.d.ts +19 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +223 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/server/http.d.ts +15 -0
- package/dist/core/server/http.d.ts.map +1 -0
- package/dist/core/server/http.js +76 -0
- package/dist/core/server/http.js.map +1 -0
- package/dist/core/server/instructions.d.ts +2 -0
- package/dist/core/server/instructions.d.ts.map +1 -0
- package/dist/core/server/instructions.js +36 -0
- package/dist/core/server/instructions.js.map +1 -0
- package/dist/core/server/mcp-handler.d.ts +39 -0
- package/dist/core/server/mcp-handler.d.ts.map +1 -0
- package/dist/core/server/mcp-handler.js +204 -0
- package/dist/core/server/mcp-handler.js.map +1 -0
- package/dist/core/server/mcp-http.d.ts +4 -0
- package/dist/core/server/mcp-http.d.ts.map +1 -0
- package/dist/core/server/mcp-http.js +56 -0
- package/dist/core/server/mcp-http.js.map +1 -0
- package/dist/core/server/tool-router.d.ts +9 -0
- package/dist/core/server/tool-router.d.ts.map +1 -0
- package/dist/core/server/tool-router.js +25 -0
- package/dist/core/server/tool-router.js.map +1 -0
- package/dist/core/server/websocket.d.ts +4 -0
- package/dist/core/server/websocket.d.ts.map +1 -0
- package/dist/core/server/websocket.js +25 -0
- package/dist/core/server/websocket.js.map +1 -0
- package/dist/db/index.d.ts +2 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +3 -0
- package/dist/db/index.js.map +1 -0
- package/dist/embeddings/index.d.ts +24 -0
- package/dist/embeddings/index.d.ts.map +1 -0
- package/dist/embeddings/index.js +86 -0
- package/dist/embeddings/index.js.map +1 -0
- package/dist/embeddings/providers/engram.d.ts +7 -0
- package/dist/embeddings/providers/engram.d.ts.map +1 -0
- package/dist/embeddings/providers/engram.js +67 -0
- package/dist/embeddings/providers/engram.js.map +1 -0
- package/dist/embeddings/providers/ollama.d.ts +3 -0
- package/dist/embeddings/providers/ollama.d.ts.map +1 -0
- package/dist/embeddings/providers/ollama.js +9 -0
- package/dist/embeddings/providers/ollama.js.map +1 -0
- package/dist/embeddings/providers/openai-compat.d.ts +7 -0
- package/dist/embeddings/providers/openai-compat.d.ts.map +1 -0
- package/dist/embeddings/providers/openai-compat.js +27 -0
- package/dist/embeddings/providers/openai-compat.js.map +1 -0
- package/dist/embeddings/providers/openai.d.ts +3 -0
- package/dist/embeddings/providers/openai.d.ts.map +1 -0
- package/dist/embeddings/providers/openai.js +12 -0
- package/dist/embeddings/providers/openai.js.map +1 -0
- package/dist/embeddings/providers/voyage.d.ts +3 -0
- package/dist/embeddings/providers/voyage.d.ts.map +1 -0
- package/dist/embeddings/providers/voyage.js +12 -0
- package/dist/embeddings/providers/voyage.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest/jobs.d.ts +29 -0
- package/dist/ingest/jobs.d.ts.map +1 -0
- package/dist/ingest/jobs.js +131 -0
- package/dist/ingest/jobs.js.map +1 -0
- package/dist/logger.d.ts +2 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +3 -0
- package/dist/logger.js.map +1 -0
- package/dist/mcp-server/server.d.ts +2 -0
- package/dist/mcp-server/server.d.ts.map +1 -0
- package/dist/mcp-server/server.js +3 -0
- package/dist/mcp-server/server.js.map +1 -0
- package/dist/mcp-server/tests/mcp-e2e.test.d.ts +2 -0
- package/dist/mcp-server/tests/mcp-e2e.test.d.ts.map +1 -0
- package/dist/mcp-server/tests/mcp-e2e.test.js +157 -0
- package/dist/mcp-server/tests/mcp-e2e.test.js.map +1 -0
- package/dist/mcp-server/tool-router.d.ts +2 -0
- package/dist/mcp-server/tool-router.d.ts.map +1 -0
- package/dist/mcp-server/tool-router.js +3 -0
- package/dist/mcp-server/tool-router.js.map +1 -0
- package/dist/memory/admin/tools.d.ts +6 -0
- package/dist/memory/admin/tools.d.ts.map +1 -0
- package/dist/memory/admin/tools.js +134 -0
- package/dist/memory/admin/tools.js.map +1 -0
- package/dist/memory/core/chunker.d.ts +6 -0
- package/dist/memory/core/chunker.d.ts.map +1 -0
- package/dist/memory/core/chunker.js +49 -0
- package/dist/memory/core/chunker.js.map +1 -0
- package/dist/memory/core/module-interface.d.ts +23 -0
- package/dist/memory/core/module-interface.d.ts.map +1 -0
- package/dist/memory/core/module-interface.js +2 -0
- package/dist/memory/core/module-interface.js.map +1 -0
- package/dist/memory/core/module-registry.d.ts +14 -0
- package/dist/memory/core/module-registry.d.ts.map +1 -0
- package/dist/memory/core/module-registry.js +45 -0
- package/dist/memory/core/module-registry.js.map +1 -0
- package/dist/memory/core/property-extractor.d.ts +6 -0
- package/dist/memory/core/property-extractor.d.ts.map +1 -0
- package/dist/memory/core/property-extractor.js +90 -0
- package/dist/memory/core/property-extractor.js.map +1 -0
- package/dist/memory/core/reindex.d.ts +11 -0
- package/dist/memory/core/reindex.d.ts.map +1 -0
- package/dist/memory/core/reindex.js +55 -0
- package/dist/memory/core/reindex.js.map +1 -0
- package/dist/memory/core/source-registry.d.ts +42 -0
- package/dist/memory/core/source-registry.d.ts.map +1 -0
- package/dist/memory/core/source-registry.js +86 -0
- package/dist/memory/core/source-registry.js.map +1 -0
- package/dist/memory/core/store.d.ts +40 -0
- package/dist/memory/core/store.d.ts.map +1 -0
- package/dist/memory/core/store.js +257 -0
- package/dist/memory/core/store.js.map +1 -0
- package/dist/memory/core/wikilinks.d.ts +13 -0
- package/dist/memory/core/wikilinks.d.ts.map +1 -0
- package/dist/memory/core/wikilinks.js +25 -0
- package/dist/memory/core/wikilinks.js.map +1 -0
- package/dist/memory/modules/_custom/generic-module.d.ts +7 -0
- package/dist/memory/modules/_custom/generic-module.d.ts.map +1 -0
- package/dist/memory/modules/_custom/generic-module.js +108 -0
- package/dist/memory/modules/_custom/generic-module.js.map +1 -0
- package/dist/memory/modules/_custom/persistence.d.ts +15 -0
- package/dist/memory/modules/_custom/persistence.d.ts.map +1 -0
- package/dist/memory/modules/_custom/persistence.js +47 -0
- package/dist/memory/modules/_custom/persistence.js.map +1 -0
- package/dist/memory/modules/_custom/tests/custom-types.test.d.ts +2 -0
- package/dist/memory/modules/_custom/tests/custom-types.test.d.ts.map +1 -0
- package/dist/memory/modules/_custom/tests/custom-types.test.js +89 -0
- package/dist/memory/modules/_custom/tests/custom-types.test.js.map +1 -0
- package/dist/memory/modules/_custom/tools.d.ts +7 -0
- package/dist/memory/modules/_custom/tools.d.ts.map +1 -0
- package/dist/memory/modules/_custom/tools.js +72 -0
- package/dist/memory/modules/_custom/tools.js.map +1 -0
- package/dist/memory/modules/audio/ingest.d.ts +9 -0
- package/dist/memory/modules/audio/ingest.d.ts.map +1 -0
- package/dist/memory/modules/audio/ingest.js +32 -0
- package/dist/memory/modules/audio/ingest.js.map +1 -0
- package/dist/memory/modules/audio/module.d.ts +6 -0
- package/dist/memory/modules/audio/module.d.ts.map +1 -0
- package/dist/memory/modules/audio/module.js +18 -0
- package/dist/memory/modules/audio/module.js.map +1 -0
- package/dist/memory/modules/audio/tests/audio.test.d.ts +2 -0
- package/dist/memory/modules/audio/tests/audio.test.d.ts.map +1 -0
- package/dist/memory/modules/audio/tests/audio.test.js +57 -0
- package/dist/memory/modules/audio/tests/audio.test.js.map +1 -0
- package/dist/memory/modules/audio/tests/transcriber.test.d.ts +2 -0
- package/dist/memory/modules/audio/tests/transcriber.test.d.ts.map +1 -0
- package/dist/memory/modules/audio/tests/transcriber.test.js +27 -0
- package/dist/memory/modules/audio/tests/transcriber.test.js.map +1 -0
- package/dist/memory/modules/audio/tools.d.ts +5 -0
- package/dist/memory/modules/audio/tools.d.ts.map +1 -0
- package/dist/memory/modules/audio/tools.js +60 -0
- package/dist/memory/modules/audio/tools.js.map +1 -0
- package/dist/memory/modules/audio/transcriber.d.ts +15 -0
- package/dist/memory/modules/audio/transcriber.d.ts.map +1 -0
- package/dist/memory/modules/audio/transcriber.js +177 -0
- package/dist/memory/modules/audio/transcriber.js.map +1 -0
- package/dist/memory/modules/conversations/ingest.d.ts +10 -0
- package/dist/memory/modules/conversations/ingest.d.ts.map +1 -0
- package/dist/memory/modules/conversations/ingest.js +38 -0
- package/dist/memory/modules/conversations/ingest.js.map +1 -0
- package/dist/memory/modules/conversations/module.d.ts +6 -0
- package/dist/memory/modules/conversations/module.d.ts.map +1 -0
- package/dist/memory/modules/conversations/module.js +43 -0
- package/dist/memory/modules/conversations/module.js.map +1 -0
- package/dist/memory/modules/conversations/tests/conversations.test.d.ts +2 -0
- package/dist/memory/modules/conversations/tests/conversations.test.d.ts.map +1 -0
- package/dist/memory/modules/conversations/tests/conversations.test.js +70 -0
- package/dist/memory/modules/conversations/tests/conversations.test.js.map +1 -0
- package/dist/memory/modules/conversations/tools.d.ts +5 -0
- package/dist/memory/modules/conversations/tools.d.ts.map +1 -0
- package/dist/memory/modules/conversations/tools.js +75 -0
- package/dist/memory/modules/conversations/tools.js.map +1 -0
- package/dist/memory/modules/drive/connector.d.ts +19 -0
- package/dist/memory/modules/drive/connector.d.ts.map +1 -0
- package/dist/memory/modules/drive/connector.js +52 -0
- package/dist/memory/modules/drive/connector.js.map +1 -0
- package/dist/memory/modules/drive/ingest.d.ts +9 -0
- package/dist/memory/modules/drive/ingest.d.ts.map +1 -0
- package/dist/memory/modules/drive/ingest.js +27 -0
- package/dist/memory/modules/drive/ingest.js.map +1 -0
- package/dist/memory/modules/drive/module.d.ts +6 -0
- package/dist/memory/modules/drive/module.d.ts.map +1 -0
- package/dist/memory/modules/drive/module.js +31 -0
- package/dist/memory/modules/drive/module.js.map +1 -0
- package/dist/memory/modules/drive/oauth.d.ts +14 -0
- package/dist/memory/modules/drive/oauth.d.ts.map +1 -0
- package/dist/memory/modules/drive/oauth.js +130 -0
- package/dist/memory/modules/drive/oauth.js.map +1 -0
- package/dist/memory/modules/drive/tests/drive.test.d.ts +2 -0
- package/dist/memory/modules/drive/tests/drive.test.d.ts.map +1 -0
- package/dist/memory/modules/drive/tests/drive.test.js +66 -0
- package/dist/memory/modules/drive/tests/drive.test.js.map +1 -0
- package/dist/memory/modules/drive/tools.d.ts +5 -0
- package/dist/memory/modules/drive/tools.d.ts.map +1 -0
- package/dist/memory/modules/drive/tools.js +131 -0
- package/dist/memory/modules/drive/tools.js.map +1 -0
- package/dist/memory/modules/drive/watcher.d.ts +5 -0
- package/dist/memory/modules/drive/watcher.d.ts.map +1 -0
- package/dist/memory/modules/drive/watcher.js +46 -0
- package/dist/memory/modules/drive/watcher.js.map +1 -0
- package/dist/memory/modules/notes/ingest.d.ts +3 -0
- package/dist/memory/modules/notes/ingest.d.ts.map +1 -0
- package/dist/memory/modules/notes/ingest.js +30 -0
- package/dist/memory/modules/notes/ingest.js.map +1 -0
- package/dist/memory/modules/notes/module.d.ts +5 -0
- package/dist/memory/modules/notes/module.d.ts.map +1 -0
- package/dist/memory/modules/notes/module.js +28 -0
- package/dist/memory/modules/notes/module.js.map +1 -0
- package/dist/memory/modules/notes/tests/notes.test.d.ts +2 -0
- package/dist/memory/modules/notes/tests/notes.test.d.ts.map +1 -0
- package/dist/memory/modules/notes/tests/notes.test.js +59 -0
- package/dist/memory/modules/notes/tests/notes.test.js.map +1 -0
- package/dist/memory/modules/notes/tools.d.ts +5 -0
- package/dist/memory/modules/notes/tools.d.ts.map +1 -0
- package/dist/memory/modules/notes/tools.js +69 -0
- package/dist/memory/modules/notes/tools.js.map +1 -0
- package/dist/memory/modules/notion/connector.d.ts +10 -0
- package/dist/memory/modules/notion/connector.d.ts.map +1 -0
- package/dist/memory/modules/notion/connector.js +112 -0
- package/dist/memory/modules/notion/connector.js.map +1 -0
- package/dist/memory/modules/notion/ingest.d.ts +9 -0
- package/dist/memory/modules/notion/ingest.d.ts.map +1 -0
- package/dist/memory/modules/notion/ingest.js +24 -0
- package/dist/memory/modules/notion/ingest.js.map +1 -0
- package/dist/memory/modules/notion/module.d.ts +6 -0
- package/dist/memory/modules/notion/module.d.ts.map +1 -0
- package/dist/memory/modules/notion/module.js +31 -0
- package/dist/memory/modules/notion/module.js.map +1 -0
- package/dist/memory/modules/notion/oauth.d.ts +19 -0
- package/dist/memory/modules/notion/oauth.d.ts.map +1 -0
- package/dist/memory/modules/notion/oauth.js +117 -0
- package/dist/memory/modules/notion/oauth.js.map +1 -0
- package/dist/memory/modules/notion/tests/notion.test.d.ts +2 -0
- package/dist/memory/modules/notion/tests/notion.test.d.ts.map +1 -0
- package/dist/memory/modules/notion/tests/notion.test.js +53 -0
- package/dist/memory/modules/notion/tests/notion.test.js.map +1 -0
- package/dist/memory/modules/notion/tools.d.ts +5 -0
- package/dist/memory/modules/notion/tools.d.ts.map +1 -0
- package/dist/memory/modules/notion/tools.js +116 -0
- package/dist/memory/modules/notion/tools.js.map +1 -0
- package/dist/memory/modules/notion/watcher.d.ts +5 -0
- package/dist/memory/modules/notion/watcher.d.ts.map +1 -0
- package/dist/memory/modules/notion/watcher.js +41 -0
- package/dist/memory/modules/notion/watcher.js.map +1 -0
- package/dist/memory/modules/obsidian/ingest.d.ts +9 -0
- package/dist/memory/modules/obsidian/ingest.d.ts.map +1 -0
- package/dist/memory/modules/obsidian/ingest.js +80 -0
- package/dist/memory/modules/obsidian/ingest.js.map +1 -0
- package/dist/memory/modules/obsidian/module.d.ts +6 -0
- package/dist/memory/modules/obsidian/module.d.ts.map +1 -0
- package/dist/memory/modules/obsidian/module.js +31 -0
- package/dist/memory/modules/obsidian/module.js.map +1 -0
- package/dist/memory/modules/obsidian/tests/obsidian.test.d.ts +2 -0
- package/dist/memory/modules/obsidian/tests/obsidian.test.d.ts.map +1 -0
- package/dist/memory/modules/obsidian/tests/obsidian.test.js +65 -0
- package/dist/memory/modules/obsidian/tests/obsidian.test.js.map +1 -0
- package/dist/memory/modules/obsidian/tests/vault-reader.test.d.ts +2 -0
- package/dist/memory/modules/obsidian/tests/vault-reader.test.d.ts.map +1 -0
- package/dist/memory/modules/obsidian/tests/vault-reader.test.js +37 -0
- package/dist/memory/modules/obsidian/tests/vault-reader.test.js.map +1 -0
- package/dist/memory/modules/obsidian/tools.d.ts +5 -0
- package/dist/memory/modules/obsidian/tools.d.ts.map +1 -0
- package/dist/memory/modules/obsidian/tools.js +101 -0
- package/dist/memory/modules/obsidian/tools.js.map +1 -0
- package/dist/memory/modules/obsidian/vault-reader.d.ts +8 -0
- package/dist/memory/modules/obsidian/vault-reader.d.ts.map +1 -0
- package/dist/memory/modules/obsidian/vault-reader.js +82 -0
- package/dist/memory/modules/obsidian/vault-reader.js.map +1 -0
- package/dist/memory/modules/obsidian/watcher.d.ts +5 -0
- package/dist/memory/modules/obsidian/watcher.d.ts.map +1 -0
- package/dist/memory/modules/obsidian/watcher.js +83 -0
- package/dist/memory/modules/obsidian/watcher.js.map +1 -0
- package/dist/memory/modules/youtube/ingest.d.ts +20 -0
- package/dist/memory/modules/youtube/ingest.d.ts.map +1 -0
- package/dist/memory/modules/youtube/ingest.js +49 -0
- package/dist/memory/modules/youtube/ingest.js.map +1 -0
- package/dist/memory/modules/youtube/module.d.ts +11 -0
- package/dist/memory/modules/youtube/module.d.ts.map +1 -0
- package/dist/memory/modules/youtube/module.js +26 -0
- package/dist/memory/modules/youtube/module.js.map +1 -0
- package/dist/memory/modules/youtube/tests/channel.test.d.ts +2 -0
- package/dist/memory/modules/youtube/tests/channel.test.d.ts.map +1 -0
- package/dist/memory/modules/youtube/tests/channel.test.js +61 -0
- package/dist/memory/modules/youtube/tests/channel.test.js.map +1 -0
- package/dist/memory/modules/youtube/tests/transcript-fetcher.test.d.ts +2 -0
- package/dist/memory/modules/youtube/tests/transcript-fetcher.test.d.ts.map +1 -0
- package/dist/memory/modules/youtube/tests/transcript-fetcher.test.js +23 -0
- package/dist/memory/modules/youtube/tests/transcript-fetcher.test.js.map +1 -0
- package/dist/memory/modules/youtube/tests/youtube.test.d.ts +2 -0
- package/dist/memory/modules/youtube/tests/youtube.test.d.ts.map +1 -0
- package/dist/memory/modules/youtube/tests/youtube.test.js +52 -0
- package/dist/memory/modules/youtube/tests/youtube.test.js.map +1 -0
- package/dist/memory/modules/youtube/tools.d.ts +5 -0
- package/dist/memory/modules/youtube/tools.d.ts.map +1 -0
- package/dist/memory/modules/youtube/tools.js +182 -0
- package/dist/memory/modules/youtube/tools.js.map +1 -0
- package/dist/memory/modules/youtube/transcript-fetcher.d.ts +17 -0
- package/dist/memory/modules/youtube/transcript-fetcher.d.ts.map +1 -0
- package/dist/memory/modules/youtube/transcript-fetcher.js +178 -0
- package/dist/memory/modules/youtube/transcript-fetcher.js.map +1 -0
- package/dist/memory/modules/youtube/watcher.d.ts +30 -0
- package/dist/memory/modules/youtube/watcher.d.ts.map +1 -0
- package/dist/memory/modules/youtube/watcher.js +198 -0
- package/dist/memory/modules/youtube/watcher.js.map +1 -0
- package/dist/memory/public/tools.d.ts +5 -0
- package/dist/memory/public/tools.d.ts.map +1 -0
- package/dist/memory/public/tools.js +1761 -0
- package/dist/memory/public/tools.js.map +1 -0
- package/dist/private/algorithms/chunker-semantic.d.ts +3 -0
- package/dist/private/algorithms/chunker-semantic.d.ts.map +1 -0
- package/dist/private/algorithms/chunker-semantic.js +70 -0
- package/dist/private/algorithms/chunker-semantic.js.map +1 -0
- package/dist/private/algorithms/find-related-smart.d.ts +4 -0
- package/dist/private/algorithms/find-related-smart.d.ts.map +1 -0
- package/dist/private/algorithms/find-related-smart.js +52 -0
- package/dist/private/algorithms/find-related-smart.js.map +1 -0
- package/dist/private/algorithms/graph-semantic-edges.d.ts +4 -0
- package/dist/private/algorithms/graph-semantic-edges.d.ts.map +1 -0
- package/dist/private/algorithms/graph-semantic-edges.js +38 -0
- package/dist/private/algorithms/graph-semantic-edges.js.map +1 -0
- package/dist/private/algorithms/search-all-smart.d.ts +9 -0
- package/dist/private/algorithms/search-all-smart.d.ts.map +1 -0
- package/dist/private/algorithms/search-all-smart.js +62 -0
- package/dist/private/algorithms/search-all-smart.js.map +1 -0
- package/dist/private/index.d.ts +7 -0
- package/dist/private/index.d.ts.map +1 -0
- package/dist/private/index.js +39 -0
- package/dist/private/index.js.map +1 -0
- package/dist/private/prompts/extraction-system.d.ts +2 -0
- package/dist/private/prompts/extraction-system.d.ts.map +1 -0
- package/dist/private/prompts/extraction-system.js +15 -0
- package/dist/private/prompts/extraction-system.js.map +1 -0
- package/dist/private/prompts/suggest-properties.d.ts +2 -0
- package/dist/private/prompts/suggest-properties.d.ts.map +1 -0
- package/dist/private/prompts/suggest-properties.js +18 -0
- package/dist/private/prompts/suggest-properties.js.map +1 -0
- package/dist/private/tests/find-related-smart.test.d.ts +2 -0
- package/dist/private/tests/find-related-smart.test.d.ts.map +1 -0
- package/dist/private/tests/find-related-smart.test.js +86 -0
- package/dist/private/tests/find-related-smart.test.js.map +1 -0
- package/dist/private/tests/property-extractor-smart.test.d.ts +2 -0
- package/dist/private/tests/property-extractor-smart.test.d.ts.map +1 -0
- package/dist/private/tests/property-extractor-smart.test.js +26 -0
- package/dist/private/tests/property-extractor-smart.test.js.map +1 -0
- package/dist/scripts/install-ollama.d.ts +3 -0
- package/dist/scripts/install-ollama.d.ts.map +1 -0
- package/dist/scripts/install-ollama.js +78 -0
- package/dist/scripts/install-ollama.js.map +1 -0
- package/dist/scripts/install.d.ts +3 -0
- package/dist/scripts/install.d.ts.map +1 -0
- package/dist/scripts/install.js +191 -0
- package/dist/scripts/install.js.map +1 -0
- package/dist/scripts/pair.d.ts +3 -0
- package/dist/scripts/pair.d.ts.map +1 -0
- package/dist/scripts/pair.js +78 -0
- package/dist/scripts/pair.js.map +1 -0
- package/dist/scripts/rebuild.d.ts +20 -0
- package/dist/scripts/rebuild.d.ts.map +1 -0
- package/dist/scripts/rebuild.js +171 -0
- package/dist/scripts/rebuild.js.map +1 -0
- package/dist/scripts/reindex.d.ts +3 -0
- package/dist/scripts/reindex.d.ts.map +1 -0
- package/dist/scripts/reindex.js +23 -0
- package/dist/scripts/reindex.js.map +1 -0
- package/dist/scripts/serve.d.ts +3 -0
- package/dist/scripts/serve.d.ts.map +1 -0
- package/dist/scripts/serve.js +57 -0
- package/dist/scripts/serve.js.map +1 -0
- package/dist/scripts/service.d.ts +19 -0
- package/dist/scripts/service.d.ts.map +1 -0
- package/dist/scripts/service.js +257 -0
- package/dist/scripts/service.js.map +1 -0
- package/dist/server/api/daily.d.ts +3 -0
- package/dist/server/api/daily.d.ts.map +1 -0
- package/dist/server/api/daily.js +44 -0
- package/dist/server/api/daily.js.map +1 -0
- package/dist/server/api/graph.d.ts +26 -0
- package/dist/server/api/graph.d.ts.map +1 -0
- package/dist/server/api/graph.js +80 -0
- package/dist/server/api/graph.js.map +1 -0
- package/dist/server/api/integrations.d.ts +4 -0
- package/dist/server/api/integrations.d.ts.map +1 -0
- package/dist/server/api/integrations.js +228 -0
- package/dist/server/api/integrations.js.map +1 -0
- package/dist/server/api/memories.d.ts +4 -0
- package/dist/server/api/memories.d.ts.map +1 -0
- package/dist/server/api/memories.js +267 -0
- package/dist/server/api/memories.js.map +1 -0
- package/dist/server/api/reindex.d.ts +3 -0
- package/dist/server/api/reindex.d.ts.map +1 -0
- package/dist/server/api/reindex.js +18 -0
- package/dist/server/api/reindex.js.map +1 -0
- package/dist/server/api/settings.d.ts +3 -0
- package/dist/server/api/settings.d.ts.map +1 -0
- package/dist/server/api/settings.js +24 -0
- package/dist/server/api/settings.js.map +1 -0
- package/dist/server/api/sources.d.ts +4 -0
- package/dist/server/api/sources.d.ts.map +1 -0
- package/dist/server/api/sources.js +45 -0
- package/dist/server/api/sources.js.map +1 -0
- package/dist/server/api/sync-status.d.ts +3 -0
- package/dist/server/api/sync-status.d.ts.map +1 -0
- package/dist/server/api/sync-status.js +43 -0
- package/dist/server/api/sync-status.js.map +1 -0
- package/dist/server/api/types.d.ts +3 -0
- package/dist/server/api/types.d.ts.map +1 -0
- package/dist/server/api/types.js +20 -0
- package/dist/server/api/types.js.map +1 -0
- package/dist/server/api/views.d.ts +25 -0
- package/dist/server/api/views.d.ts.map +1 -0
- package/dist/server/api/views.js +54 -0
- package/dist/server/api/views.js.map +1 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +3 -0
- package/dist/server/index.js.map +1 -0
- package/dist/sync/apply.d.ts +55 -0
- package/dist/sync/apply.d.ts.map +1 -0
- package/dist/sync/apply.js +277 -0
- package/dist/sync/apply.js.map +1 -0
- package/dist/sync/channel-client.d.ts +27 -0
- package/dist/sync/channel-client.d.ts.map +1 -0
- package/dist/sync/channel-client.js +154 -0
- package/dist/sync/channel-client.js.map +1 -0
- package/dist/sync/cloud-saves.d.ts +49 -0
- package/dist/sync/cloud-saves.d.ts.map +1 -0
- package/dist/sync/cloud-saves.js +182 -0
- package/dist/sync/cloud-saves.js.map +1 -0
- package/dist/sync/ed25519.d.ts +54 -0
- package/dist/sync/ed25519.d.ts.map +1 -0
- package/dist/sync/ed25519.js +136 -0
- package/dist/sync/ed25519.js.map +1 -0
- package/dist/sync/ops-log.d.ts +43 -0
- package/dist/sync/ops-log.d.ts.map +1 -0
- package/dist/sync/ops-log.js +153 -0
- package/dist/sync/ops-log.js.map +1 -0
- package/dist/sync/recovery-setup.d.ts +26 -0
- package/dist/sync/recovery-setup.d.ts.map +1 -0
- package/dist/sync/recovery-setup.js +113 -0
- package/dist/sync/recovery-setup.js.map +1 -0
- package/dist/sync/replay.d.ts +19 -0
- package/dist/sync/replay.d.ts.map +1 -0
- package/dist/sync/replay.js +59 -0
- package/dist/sync/replay.js.map +1 -0
- package/dist/sync/shamir.d.ts +22 -0
- package/dist/sync/shamir.d.ts.map +1 -0
- package/dist/sync/shamir.js +109 -0
- package/dist/sync/shamir.js.map +1 -0
- package/dist/sync/tests/apply.test.d.ts +4 -0
- package/dist/sync/tests/apply.test.d.ts.map +1 -0
- package/dist/sync/tests/apply.test.js +119 -0
- package/dist/sync/tests/apply.test.js.map +1 -0
- package/dist/sync/tests/ops-log.test.d.ts +2 -0
- package/dist/sync/tests/ops-log.test.d.ts.map +1 -0
- package/dist/sync/tests/ops-log.test.js +105 -0
- package/dist/sync/tests/ops-log.test.js.map +1 -0
- package/dist/sync/tests/two-device-sync.test.d.ts +2 -0
- package/dist/sync/tests/two-device-sync.test.d.ts.map +1 -0
- package/dist/sync/tests/two-device-sync.test.js +250 -0
- package/dist/sync/tests/two-device-sync.test.js.map +1 -0
- package/dist/sync/types.d.ts +87 -0
- package/dist/sync/types.d.ts.map +1 -0
- package/dist/sync/types.js +37 -0
- package/dist/sync/types.js.map +1 -0
- package/dist/tests/chunker.test.d.ts +2 -0
- package/dist/tests/chunker.test.d.ts.map +1 -0
- package/dist/tests/chunker.test.js +24 -0
- package/dist/tests/chunker.test.js.map +1 -0
- package/dist/tests/cloud-auth.test.d.ts +2 -0
- package/dist/tests/cloud-auth.test.d.ts.map +1 -0
- package/dist/tests/cloud-auth.test.js +75 -0
- package/dist/tests/cloud-auth.test.js.map +1 -0
- package/dist/tests/cloud-crypto.test.d.ts +2 -0
- package/dist/tests/cloud-crypto.test.d.ts.map +1 -0
- package/dist/tests/cloud-crypto.test.js +58 -0
- package/dist/tests/cloud-crypto.test.js.map +1 -0
- package/dist/tests/cloud-integration.test.d.ts +2 -0
- package/dist/tests/cloud-integration.test.d.ts.map +1 -0
- package/dist/tests/cloud-integration.test.js +193 -0
- package/dist/tests/cloud-integration.test.js.map +1 -0
- package/dist/tests/cloud-pairing.test.d.ts +2 -0
- package/dist/tests/cloud-pairing.test.d.ts.map +1 -0
- package/dist/tests/cloud-pairing.test.js +86 -0
- package/dist/tests/cloud-pairing.test.js.map +1 -0
- package/dist/tests/cloud-saves-integration.test.d.ts +2 -0
- package/dist/tests/cloud-saves-integration.test.d.ts.map +1 -0
- package/dist/tests/cloud-saves-integration.test.js +92 -0
- package/dist/tests/cloud-saves-integration.test.js.map +1 -0
- package/dist/tests/cloud-transit.test.d.ts +2 -0
- package/dist/tests/cloud-transit.test.d.ts.map +1 -0
- package/dist/tests/cloud-transit.test.js +263 -0
- package/dist/tests/cloud-transit.test.js.map +1 -0
- package/dist/tests/config.test.d.ts +2 -0
- package/dist/tests/config.test.d.ts.map +1 -0
- package/dist/tests/config.test.js +25 -0
- package/dist/tests/config.test.js.map +1 -0
- package/dist/tests/db.test.d.ts +2 -0
- package/dist/tests/db.test.d.ts.map +1 -0
- package/dist/tests/db.test.js +75 -0
- package/dist/tests/db.test.js.map +1 -0
- package/dist/tests/embeddings-providers.test.d.ts +2 -0
- package/dist/tests/embeddings-providers.test.d.ts.map +1 -0
- package/dist/tests/embeddings-providers.test.js +62 -0
- package/dist/tests/embeddings-providers.test.js.map +1 -0
- package/dist/tests/embeddings.test.d.ts +2 -0
- package/dist/tests/embeddings.test.d.ts.map +1 -0
- package/dist/tests/embeddings.test.js +22 -0
- package/dist/tests/embeddings.test.js.map +1 -0
- package/dist/tests/integrations-api.test.d.ts +2 -0
- package/dist/tests/integrations-api.test.d.ts.map +1 -0
- package/dist/tests/integrations-api.test.js +129 -0
- package/dist/tests/integrations-api.test.js.map +1 -0
- package/dist/tests/memory-store.test.d.ts +2 -0
- package/dist/tests/memory-store.test.d.ts.map +1 -0
- package/dist/tests/memory-store.test.js +129 -0
- package/dist/tests/memory-store.test.js.map +1 -0
- package/dist/tests/module-registry.test.d.ts +2 -0
- package/dist/tests/module-registry.test.d.ts.map +1 -0
- package/dist/tests/module-registry.test.js +44 -0
- package/dist/tests/module-registry.test.js.map +1 -0
- package/dist/tests/property-extractor.test.d.ts +2 -0
- package/dist/tests/property-extractor.test.d.ts.map +1 -0
- package/dist/tests/property-extractor.test.js +24 -0
- package/dist/tests/property-extractor.test.js.map +1 -0
- package/dist/tests/public-tools.test.d.ts +2 -0
- package/dist/tests/public-tools.test.d.ts.map +1 -0
- package/dist/tests/public-tools.test.js +270 -0
- package/dist/tests/public-tools.test.js.map +1 -0
- package/dist/tests/reindex.test.d.ts +2 -0
- package/dist/tests/reindex.test.d.ts.map +1 -0
- package/dist/tests/reindex.test.js +58 -0
- package/dist/tests/reindex.test.js.map +1 -0
- package/dist/tests/shamir.test.d.ts +2 -0
- package/dist/tests/shamir.test.d.ts.map +1 -0
- package/dist/tests/shamir.test.js +57 -0
- package/dist/tests/shamir.test.js.map +1 -0
- package/dist/tests/source-registry.test.d.ts +2 -0
- package/dist/tests/source-registry.test.d.ts.map +1 -0
- package/dist/tests/source-registry.test.js +58 -0
- package/dist/tests/source-registry.test.js.map +1 -0
- package/dist/tests/types.test.d.ts +2 -0
- package/dist/tests/types.test.d.ts.map +1 -0
- package/dist/tests/types.test.js +26 -0
- package/dist/tests/types.test.js.map +1 -0
- package/dist/tests/vector.test.d.ts +2 -0
- package/dist/tests/vector.test.d.ts.map +1 -0
- package/dist/tests/vector.test.js +61 -0
- package/dist/tests/vector.test.js.map +1 -0
- package/dist/tests/wikilinks.test.d.ts +2 -0
- package/dist/tests/wikilinks.test.d.ts.map +1 -0
- package/dist/tests/wikilinks.test.js +20 -0
- package/dist/tests/wikilinks.test.js.map +1 -0
- package/dist/tools/index.d.ts +22 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +38 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/types.d.ts +134 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +25 -0
- package/dist/types.js.map +1 -0
- package/dist/vector/store.d.ts +28 -0
- package/dist/vector/store.d.ts.map +1 -0
- package/dist/vector/store.js +132 -0
- package/dist/vector/store.js.map +1 -0
- package/dist/webapp/api/daily.d.ts +3 -0
- package/dist/webapp/api/daily.d.ts.map +1 -0
- package/dist/webapp/api/daily.js +44 -0
- package/dist/webapp/api/daily.js.map +1 -0
- package/dist/webapp/api/graph.d.ts +26 -0
- package/dist/webapp/api/graph.d.ts.map +1 -0
- package/dist/webapp/api/graph.js +80 -0
- package/dist/webapp/api/graph.js.map +1 -0
- package/dist/webapp/api/memories.d.ts +4 -0
- package/dist/webapp/api/memories.d.ts.map +1 -0
- package/dist/webapp/api/memories.js +70 -0
- package/dist/webapp/api/memories.js.map +1 -0
- package/dist/webapp/api/reindex.d.ts +3 -0
- package/dist/webapp/api/reindex.d.ts.map +1 -0
- package/dist/webapp/api/reindex.js +18 -0
- package/dist/webapp/api/reindex.js.map +1 -0
- package/dist/webapp/api/settings.d.ts +3 -0
- package/dist/webapp/api/settings.d.ts.map +1 -0
- package/dist/webapp/api/settings.js +24 -0
- package/dist/webapp/api/settings.js.map +1 -0
- package/dist/webapp/api/sources.d.ts +4 -0
- package/dist/webapp/api/sources.d.ts.map +1 -0
- package/dist/webapp/api/sources.js +45 -0
- package/dist/webapp/api/sources.js.map +1 -0
- package/dist/webapp/api/sync-status.d.ts +3 -0
- package/dist/webapp/api/sync-status.d.ts.map +1 -0
- package/dist/webapp/api/sync-status.js +43 -0
- package/dist/webapp/api/sync-status.js.map +1 -0
- package/dist/webapp/api/types.d.ts +3 -0
- package/dist/webapp/api/types.d.ts.map +1 -0
- package/dist/webapp/api/types.js +20 -0
- package/dist/webapp/api/types.js.map +1 -0
- package/dist/webapp/api/views.d.ts +25 -0
- package/dist/webapp/api/views.d.ts.map +1 -0
- package/dist/webapp/api/views.js +54 -0
- package/dist/webapp/api/views.js.map +1 -0
- package/dist/webapp/mcp-http.d.ts +2 -0
- package/dist/webapp/mcp-http.d.ts.map +1 -0
- package/dist/webapp/mcp-http.js +3 -0
- package/dist/webapp/mcp-http.js.map +1 -0
- package/dist/webapp/server.d.ts +2 -0
- package/dist/webapp/server.d.ts.map +1 -0
- package/dist/webapp/server.js +3 -0
- package/dist/webapp/server.js.map +1 -0
- package/dist/webapp/tests/api.test.d.ts +2 -0
- package/dist/webapp/tests/api.test.d.ts.map +1 -0
- package/dist/webapp/tests/api.test.js +125 -0
- package/dist/webapp/tests/api.test.js.map +1 -0
- package/dist/webapp/tests/mcp-http.test.d.ts +2 -0
- package/dist/webapp/tests/mcp-http.test.d.ts.map +1 -0
- package/dist/webapp/tests/mcp-http.test.js +47 -0
- package/dist/webapp/tests/mcp-http.test.js.map +1 -0
- package/dist/webapp/websocket.d.ts +2 -0
- package/dist/webapp/websocket.d.ts.map +1 -0
- package/dist/webapp/websocket.js +3 -0
- package/dist/webapp/websocket.js.map +1 -0
- package/package.json +128 -0
- package/src/private/README.md +49 -0
|
@@ -0,0 +1,1761 @@
|
|
|
1
|
+
// src/memory/public/tools.ts
|
|
2
|
+
// Full public surface: 21 tools — all agent-callable, no admin flag needed.
|
|
3
|
+
// Includes OAuth-initiating tools (connect_drive, connect_notion) with agent-safe descriptions.
|
|
4
|
+
import { ulid } from 'ulid';
|
|
5
|
+
import { createHash } from 'crypto';
|
|
6
|
+
import { createLogger } from '../../logger.js';
|
|
7
|
+
import { extractWikilinks } from '../core/wikilinks.js';
|
|
8
|
+
const log = createLogger('public-tools');
|
|
9
|
+
// ── Per-type weights for OSS recall calibration ───────────────────────────────
|
|
10
|
+
const TYPE_WEIGHTS = {
|
|
11
|
+
notes: 1.00, // user-curated, high signal
|
|
12
|
+
conversations: 0.95, // recent, dialog context
|
|
13
|
+
drive: 0.90, // structured documents
|
|
14
|
+
notion: 0.90,
|
|
15
|
+
obsidian: 0.95, // user notes, high signal
|
|
16
|
+
audio: 0.80, // transcripts can be noisy
|
|
17
|
+
youtube: 0.75, // longer, lower signal density
|
|
18
|
+
};
|
|
19
|
+
// Recency boost (exp decay, half-life ~180 days)
|
|
20
|
+
function recencyBoost(createdAtMs) {
|
|
21
|
+
const ageDays = (Date.now() - createdAtMs) / (1000 * 60 * 60 * 24);
|
|
22
|
+
return Math.exp(-ageDays / 260); // ln(2)/180 ≈ 1/260
|
|
23
|
+
}
|
|
24
|
+
export function buildPublicTools(store, config) {
|
|
25
|
+
const embeddingModel = `${config.embeddings.provider}/${config.embeddings.model}`;
|
|
26
|
+
return [
|
|
27
|
+
// ── remember ─────────────────────────────────────────────────────────────
|
|
28
|
+
{
|
|
29
|
+
name: 'remember',
|
|
30
|
+
description: [
|
|
31
|
+
'Store a memory (note, audio transcript, conversation, document) for later semantic retrieval.',
|
|
32
|
+
'INPUTS: content (required, free text). Optionally provide title (improves retrieval — 3-7 words), tags (array of strings — topics/people/projects this memory is about), type (default "notes"; use "conversations" for dialog turns, or any custom type), and properties (object — any extra key/value metadata).',
|
|
33
|
+
'WHEN: call after user shares anything worth remembering (preferences, facts, decisions, exchanges). Always include title + 2-5 tags so it surfaces in future recall.',
|
|
34
|
+
'WIKILINKS: mention related memories by [[id]] or [[title]] in content — edges are auto-extracted.',
|
|
35
|
+
'IDEMPOTENT on (content_hash, type): calling twice with identical content returns the same id with {created: false}.',
|
|
36
|
+
'DO NOT retry on success — store the returned id and move on.',
|
|
37
|
+
'If you get an error: retry at most once with adjusted input; if still fails, surface to user.',
|
|
38
|
+
'RETURNS: { id, created, wikilinks_extracted }.',
|
|
39
|
+
].join(' '),
|
|
40
|
+
inputSchema: {
|
|
41
|
+
type: 'object',
|
|
42
|
+
properties: {
|
|
43
|
+
content: {
|
|
44
|
+
type: 'string',
|
|
45
|
+
description: 'The memory text verbatim. Required.',
|
|
46
|
+
},
|
|
47
|
+
title: {
|
|
48
|
+
type: 'string',
|
|
49
|
+
description: '3-7 word summary — improves future recall significantly.',
|
|
50
|
+
},
|
|
51
|
+
tags: {
|
|
52
|
+
type: 'array',
|
|
53
|
+
items: { type: 'string' },
|
|
54
|
+
description: '2-5 lowercase keywords: people, projects, domains, event types.',
|
|
55
|
+
},
|
|
56
|
+
type: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
description: 'Memory type: "notes" (default), "conversations", or any custom type.',
|
|
59
|
+
default: 'notes',
|
|
60
|
+
},
|
|
61
|
+
properties: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
description: 'Optional extra metadata: source_url, author, sentiment, action_required, custom fields.',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
required: ['content'],
|
|
67
|
+
},
|
|
68
|
+
handler: async (args) => {
|
|
69
|
+
const content = args.content;
|
|
70
|
+
const title = args.title;
|
|
71
|
+
const tags = args.tags;
|
|
72
|
+
const type = args.type ?? 'notes';
|
|
73
|
+
const extraProps = args.properties ?? {};
|
|
74
|
+
const contentHash = createHash('sha256').update(content).digest('hex');
|
|
75
|
+
// Idempotency: check for existing memory with same content_hash + type
|
|
76
|
+
const { getDb } = await import('../../db/index.js');
|
|
77
|
+
const db = getDb();
|
|
78
|
+
const existing = db
|
|
79
|
+
.prepare(`SELECT id FROM memories WHERE content_hash = ? AND type = ? LIMIT 1`)
|
|
80
|
+
.get(contentHash, type);
|
|
81
|
+
if (existing) {
|
|
82
|
+
log.debug(`remember: duplicate detected, returning existing ${existing.id}`);
|
|
83
|
+
return { id: existing.id, created: false, reason: 'duplicate', wikilinks_extracted: [] };
|
|
84
|
+
}
|
|
85
|
+
const wikilinks = extractWikilinks(content);
|
|
86
|
+
const now = new Date().toISOString();
|
|
87
|
+
const item = {
|
|
88
|
+
id: ulid(),
|
|
89
|
+
type,
|
|
90
|
+
source_id: `manual:${Date.now()}`,
|
|
91
|
+
content,
|
|
92
|
+
content_hash: contentHash,
|
|
93
|
+
properties: {
|
|
94
|
+
created_at: now,
|
|
95
|
+
ingested_at: now,
|
|
96
|
+
title,
|
|
97
|
+
tags,
|
|
98
|
+
source_url: extraProps.source_url,
|
|
99
|
+
author: extraProps.author,
|
|
100
|
+
sentiment: extraProps.sentiment,
|
|
101
|
+
action_required: extraProps.action_required,
|
|
102
|
+
custom: extraProps.custom,
|
|
103
|
+
},
|
|
104
|
+
wikilinks,
|
|
105
|
+
related_ids: [],
|
|
106
|
+
embedding_model: embeddingModel,
|
|
107
|
+
};
|
|
108
|
+
await store.insert(item);
|
|
109
|
+
log.debug(`remember: stored ${item.id} type=${type}`);
|
|
110
|
+
const response = {
|
|
111
|
+
id: item.id,
|
|
112
|
+
created: true,
|
|
113
|
+
wikilinks_extracted: wikilinks,
|
|
114
|
+
};
|
|
115
|
+
if (!title || !tags || tags.length === 0) {
|
|
116
|
+
response.hint =
|
|
117
|
+
'No title and/or tags provided. Call update() with a 3-7 word title and 2-5 tags to improve future recall.';
|
|
118
|
+
}
|
|
119
|
+
return response;
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
// ── recall ────────────────────────────────────────────────────────────────
|
|
123
|
+
{
|
|
124
|
+
name: 'recall',
|
|
125
|
+
description: [
|
|
126
|
+
'Retrieve past memories by semantic similarity to a query.',
|
|
127
|
+
'INPUTS: query (required, short topic — NOT the full user question), optional types (array to restrict scope), optional limit (default 10, max 50).',
|
|
128
|
+
'WHEN: you need to surface past information on a topic. Use recall instead of recent when you have a specific subject in mind.',
|
|
129
|
+
'TIP: extract the topic noun from the user message. "what did I say about alice?" → query: "alice".',
|
|
130
|
+
'ANTI-LOOP: if results is empty, the answer is genuinely "no matching memories" — DO NOT call recall again with the same query.',
|
|
131
|
+
'Try a different query (synonym, broader topic, related entity) at most twice before telling the user "no matches".',
|
|
132
|
+
'RETURNS: array of { id, type, score, snippet, title, tags, created_at }.',
|
|
133
|
+
].join(' '),
|
|
134
|
+
inputSchema: {
|
|
135
|
+
type: 'object',
|
|
136
|
+
properties: {
|
|
137
|
+
query: {
|
|
138
|
+
type: 'string',
|
|
139
|
+
description: 'Short topic string — NOT the full user question. Extract the key noun/concept.',
|
|
140
|
+
},
|
|
141
|
+
types: {
|
|
142
|
+
type: 'array',
|
|
143
|
+
items: { type: 'string' },
|
|
144
|
+
description: 'Restrict to these memory types (default: all). Faster when type is known.',
|
|
145
|
+
},
|
|
146
|
+
limit: {
|
|
147
|
+
type: 'number',
|
|
148
|
+
default: 10,
|
|
149
|
+
description: 'Max results (default 10). Use 20 for exhaustive sweeps.',
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
required: ['query'],
|
|
153
|
+
},
|
|
154
|
+
handler: async (args) => {
|
|
155
|
+
const query = args.query;
|
|
156
|
+
const limit = args.limit ?? 10;
|
|
157
|
+
const types = args.types ?? store.listTypes();
|
|
158
|
+
// Use private smart version if loaded (per-type weights + recency boost + MMR)
|
|
159
|
+
if (store.algorithms.searchAll) {
|
|
160
|
+
return store.algorithms.searchAll(store, query, limit, types);
|
|
161
|
+
}
|
|
162
|
+
// ── OSS calibrated fallback ──────────────────────────────────────────
|
|
163
|
+
// Per-type weights + recency decay + MMR diversification
|
|
164
|
+
const perTypeLimit = Math.max(8, Math.ceil(limit * 1.5));
|
|
165
|
+
const allResults = await Promise.all(types.map(async (t) => {
|
|
166
|
+
try {
|
|
167
|
+
const hits = await store.search(t, query, perTypeLimit);
|
|
168
|
+
return hits.map((h) => ({
|
|
169
|
+
...h,
|
|
170
|
+
// Calibrated score = base_score * type_weight * recency_boost
|
|
171
|
+
score: h.score *
|
|
172
|
+
(TYPE_WEIGHTS[t] ?? 0.85) *
|
|
173
|
+
recencyBoost(Date.parse(h.memory.properties.created_at)),
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
}));
|
|
180
|
+
const candidates = allResults.flat().sort((a, b) => b.score - a.score);
|
|
181
|
+
// MMR diversification — penalize results too similar to already-picked ones
|
|
182
|
+
const picked = [];
|
|
183
|
+
const LAMBDA = 0.7; // 0=pure diversity, 1=pure relevance
|
|
184
|
+
const remaining = [...candidates];
|
|
185
|
+
while (picked.length < limit && remaining.length > 0) {
|
|
186
|
+
let bestIdx = 0;
|
|
187
|
+
let bestMmr = -Infinity;
|
|
188
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
189
|
+
const cand = remaining[i];
|
|
190
|
+
// Diversity penalty: max similarity to any already-picked memory
|
|
191
|
+
let maxSim = 0;
|
|
192
|
+
for (const p of picked) {
|
|
193
|
+
// Cheap similarity proxy: same source_id
|
|
194
|
+
if (cand.memory.source_id === p.memory.source_id) {
|
|
195
|
+
maxSim = Math.max(maxSim, 0.6);
|
|
196
|
+
}
|
|
197
|
+
// Shared tags
|
|
198
|
+
const candTags = cand.memory.properties.tags ?? [];
|
|
199
|
+
const pTags = p.memory.properties.tags ?? [];
|
|
200
|
+
const sharedTags = candTags.filter((t) => pTags.includes(t)).length;
|
|
201
|
+
const minTags = Math.min(candTags.length, pTags.length) || 1;
|
|
202
|
+
maxSim = Math.max(maxSim, (sharedTags / minTags) * 0.5);
|
|
203
|
+
}
|
|
204
|
+
const mmr = LAMBDA * cand.score - (1 - LAMBDA) * maxSim;
|
|
205
|
+
if (mmr > bestMmr) {
|
|
206
|
+
bestMmr = mmr;
|
|
207
|
+
bestIdx = i;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
picked.push(remaining[bestIdx]);
|
|
211
|
+
remaining.splice(bestIdx, 1);
|
|
212
|
+
}
|
|
213
|
+
return picked.slice(0, limit).map((h) => ({
|
|
214
|
+
id: h.memory.id,
|
|
215
|
+
type: h.memory.type,
|
|
216
|
+
score: h.score,
|
|
217
|
+
snippet: h.snippet,
|
|
218
|
+
title: h.memory.properties.title,
|
|
219
|
+
tags: h.memory.properties.tags,
|
|
220
|
+
created_at: h.memory.properties.created_at,
|
|
221
|
+
}));
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
// ── get ───────────────────────────────────────────────────────────────────
|
|
225
|
+
{
|
|
226
|
+
name: 'get',
|
|
227
|
+
description: [
|
|
228
|
+
'Fetch a single memory by id, returning full content + all properties.',
|
|
229
|
+
'WHEN: you already have an id (from recall or relate) and need the complete record.',
|
|
230
|
+
'Use recall instead of get when you are searching by topic.',
|
|
231
|
+
'ANTI-LOOP: returns {error: "not_found"} if id does not exist — DO NOT retry.',
|
|
232
|
+
'The memory was deleted or the id was wrong. Call recall to find an alternative.',
|
|
233
|
+
'RETURNS: full MemoryItem or { error: "not_found" }.',
|
|
234
|
+
].join(' '),
|
|
235
|
+
inputSchema: {
|
|
236
|
+
type: 'object',
|
|
237
|
+
properties: {
|
|
238
|
+
id: { type: 'string', description: 'Memory id (ULID).' },
|
|
239
|
+
},
|
|
240
|
+
required: ['id'],
|
|
241
|
+
},
|
|
242
|
+
handler: async (args) => {
|
|
243
|
+
const item = store.getById(args.id);
|
|
244
|
+
if (!item)
|
|
245
|
+
return { error: 'not_found' };
|
|
246
|
+
return item;
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
// ── update ────────────────────────────────────────────────────────────────
|
|
250
|
+
{
|
|
251
|
+
name: 'update',
|
|
252
|
+
description: [
|
|
253
|
+
'Edit metadata of an existing memory: title, tags, sentiment, action_required, or custom fields.',
|
|
254
|
+
'WHEN: user corrects a fact, retags, or you want to enrich an ingested memory after suggest_properties.',
|
|
255
|
+
'Only fields you pass are changed — other fields are untouched.',
|
|
256
|
+
'IDEMPOTENT: if the patch equals existing values, returns {updated: false} — no DB write.',
|
|
257
|
+
'If {updated: false}: the patch was redundant — DO NOT retry.',
|
|
258
|
+
'RETURNS: { updated: true|false, id } or { error: "not_found" }.',
|
|
259
|
+
].join(' '),
|
|
260
|
+
inputSchema: {
|
|
261
|
+
type: 'object',
|
|
262
|
+
properties: {
|
|
263
|
+
id: { type: 'string', description: 'Memory id to update.' },
|
|
264
|
+
title: { type: 'string' },
|
|
265
|
+
tags: { type: 'array', items: { type: 'string' } },
|
|
266
|
+
sentiment: { type: 'string', enum: ['positive', 'neutral', 'negative'] },
|
|
267
|
+
action_required: { type: 'boolean' },
|
|
268
|
+
custom: { type: 'object', description: 'Merged into existing custom properties.' },
|
|
269
|
+
},
|
|
270
|
+
required: ['id'],
|
|
271
|
+
},
|
|
272
|
+
handler: async (args) => {
|
|
273
|
+
const id = args.id;
|
|
274
|
+
const existing = store.getById(id);
|
|
275
|
+
if (!existing)
|
|
276
|
+
return { error: 'not_found' };
|
|
277
|
+
const patch = {
|
|
278
|
+
title: args.title,
|
|
279
|
+
tags: args.tags,
|
|
280
|
+
sentiment: args.sentiment,
|
|
281
|
+
action_required: args.action_required,
|
|
282
|
+
custom: args.custom,
|
|
283
|
+
};
|
|
284
|
+
// Strip undefined keys so we don't clobber existing values
|
|
285
|
+
const clean = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined));
|
|
286
|
+
// No-op diff: if all provided fields equal existing, skip the write
|
|
287
|
+
const isNoOp = Object.entries(clean).every(([k, v]) => {
|
|
288
|
+
const existingVal = existing.properties[k];
|
|
289
|
+
return JSON.stringify(existingVal) === JSON.stringify(v);
|
|
290
|
+
});
|
|
291
|
+
if (isNoOp)
|
|
292
|
+
return { updated: false, id };
|
|
293
|
+
const ok = store.setProperties(id, clean);
|
|
294
|
+
if (!ok)
|
|
295
|
+
return { error: 'not_found' };
|
|
296
|
+
return { updated: true, id };
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
// ── forget ────────────────────────────────────────────────────────────────
|
|
300
|
+
{
|
|
301
|
+
name: 'forget',
|
|
302
|
+
description: [
|
|
303
|
+
'Delete a memory by id. Removes from SQLite, FTS5, and vector index.',
|
|
304
|
+
'WHEN: user explicitly asks to forget something. Prefer update to just untag/retitle.',
|
|
305
|
+
'IDEMPOTENT: returns {deleted: id} even if the id never existed — DO NOT retry.',
|
|
306
|
+
'Calling again on the same id is a no-op.',
|
|
307
|
+
'RETURNS: { deleted: id }.',
|
|
308
|
+
].join(' '),
|
|
309
|
+
inputSchema: {
|
|
310
|
+
type: 'object',
|
|
311
|
+
properties: {
|
|
312
|
+
id: { type: 'string', description: 'Memory id to delete.' },
|
|
313
|
+
},
|
|
314
|
+
required: ['id'],
|
|
315
|
+
},
|
|
316
|
+
handler: async (args) => {
|
|
317
|
+
await store.delete(args.id);
|
|
318
|
+
return { deleted: args.id };
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
// ── relate ────────────────────────────────────────────────────────────────
|
|
322
|
+
{
|
|
323
|
+
name: 'relate',
|
|
324
|
+
description: [
|
|
325
|
+
'Find memories related to a given memory by id, using wikilinks + semantic similarity.',
|
|
326
|
+
'WHEN: "what else is connected to this?", building a memory graph, or exploring a topic cluster.',
|
|
327
|
+
'Use recall instead of relate when starting from a query string.',
|
|
328
|
+
'ANTI-LOOP: if returns empty, this memory has no semantic neighbors above the threshold — DO NOT retry.',
|
|
329
|
+
'Try recall with the memory\'s tags instead.',
|
|
330
|
+
'RETURNS: array of { id, type, score, snippet, title }.',
|
|
331
|
+
].join(' '),
|
|
332
|
+
inputSchema: {
|
|
333
|
+
type: 'object',
|
|
334
|
+
properties: {
|
|
335
|
+
id: { type: 'string', description: 'Memory id to find related memories for.' },
|
|
336
|
+
limit: { type: 'number', default: 10 },
|
|
337
|
+
},
|
|
338
|
+
required: ['id'],
|
|
339
|
+
},
|
|
340
|
+
handler: async (args) => {
|
|
341
|
+
const hits = await store.findRelated(args.id, args.limit ?? 10);
|
|
342
|
+
return hits.map((h) => ({
|
|
343
|
+
id: h.memory.id,
|
|
344
|
+
type: h.memory.type,
|
|
345
|
+
score: h.score,
|
|
346
|
+
snippet: h.snippet,
|
|
347
|
+
title: h.memory.properties.title,
|
|
348
|
+
}));
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
// ── list_types ────────────────────────────────────────────────────────────
|
|
352
|
+
{
|
|
353
|
+
name: 'list_types',
|
|
354
|
+
description: [
|
|
355
|
+
'List all memory types that currently have at least one item.',
|
|
356
|
+
'WHEN: first call when joining an existing Engram instance to discover what is stored.',
|
|
357
|
+
'Also useful before a recall to decide whether to restrict types.',
|
|
358
|
+
'CACHE: this is a cheap call but cache the result for the duration of the conversation — DO NOT call repeatedly in the same turn.',
|
|
359
|
+
'RETURNS: { types: string[] }.',
|
|
360
|
+
].join(' '),
|
|
361
|
+
inputSchema: { type: 'object', properties: {} },
|
|
362
|
+
handler: async () => {
|
|
363
|
+
return { types: store.listTypes() };
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
// ── recent ────────────────────────────────────────────────────────────────
|
|
367
|
+
{
|
|
368
|
+
name: 'recent',
|
|
369
|
+
description: [
|
|
370
|
+
'Return the most recently created memories, sorted by created_at desc.',
|
|
371
|
+
'WHEN: start of a conversation — call recent({limit: 10}) to refresh context before answering.',
|
|
372
|
+
'Use recall instead of recent when you have a specific topic in mind.',
|
|
373
|
+
'ANTI-LOOP: returns at most limit items (default 20). If fewer returned, the store has fewer memories — DO NOT call again with a larger limit hoping for more.',
|
|
374
|
+
'RETURNS: array of { id, type, title, tags, snippet, created_at }.',
|
|
375
|
+
].join(' '),
|
|
376
|
+
inputSchema: {
|
|
377
|
+
type: 'object',
|
|
378
|
+
properties: {
|
|
379
|
+
limit: {
|
|
380
|
+
type: 'number',
|
|
381
|
+
default: 20,
|
|
382
|
+
description: 'Max results (default 20).',
|
|
383
|
+
},
|
|
384
|
+
types: {
|
|
385
|
+
type: 'array',
|
|
386
|
+
items: { type: 'string' },
|
|
387
|
+
description: 'Restrict to these memory types (optional).',
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
handler: async (args) => {
|
|
392
|
+
const limit = args.limit ?? 20;
|
|
393
|
+
const types = args.types;
|
|
394
|
+
const { getDb } = await import('../../db/index.js');
|
|
395
|
+
const db = getDb();
|
|
396
|
+
let sql = `SELECT id, type, content, properties_json, created_at
|
|
397
|
+
FROM memories`;
|
|
398
|
+
const params = [];
|
|
399
|
+
if (types && types.length > 0) {
|
|
400
|
+
sql += ` WHERE type IN (${types.map(() => '?').join(',')})`;
|
|
401
|
+
params.push(...types);
|
|
402
|
+
}
|
|
403
|
+
sql += ` ORDER BY created_at DESC LIMIT ?`;
|
|
404
|
+
params.push(limit);
|
|
405
|
+
const rows = db.prepare(sql).all(...params);
|
|
406
|
+
return rows.map((r) => {
|
|
407
|
+
const props = JSON.parse(r.properties_json);
|
|
408
|
+
return {
|
|
409
|
+
id: r.id,
|
|
410
|
+
type: r.type,
|
|
411
|
+
title: props.title,
|
|
412
|
+
tags: props.tags,
|
|
413
|
+
snippet: r.content.slice(0, 200),
|
|
414
|
+
created_at: new Date(r.created_at).toISOString(),
|
|
415
|
+
};
|
|
416
|
+
});
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
// ── ingest ────────────────────────────────────────────────────────────────
|
|
420
|
+
{
|
|
421
|
+
name: 'ingest',
|
|
422
|
+
description: [
|
|
423
|
+
'Auto-route a URI to the right ingestion module and store the result.',
|
|
424
|
+
'WHEN: user mentions a file path, YouTube URL, Google Drive doc, Notion page, or Obsidian vault.',
|
|
425
|
+
'URI schemes handled:',
|
|
426
|
+
'• file://*.md|.txt — reads as note (synchronous).',
|
|
427
|
+
'• file://*.mp3|.wav|.m4a|.ogg|.webm — Whisper transcription (ASYNC — returns job_id).',
|
|
428
|
+
'• file://*.pdf — full text extraction via pdf-parse (synchronous; encrypted/corrupted PDFs get error message as content + tag pdf_extraction_failed).',
|
|
429
|
+
'• file://*.png|.jpg|.jpeg|.gif — stores as "images" type.',
|
|
430
|
+
'• https://www.youtube.com/watch?*|https://youtu.be/* — YouTube transcript (sync if <5 min via oEmbed probe, ASYNC otherwise).',
|
|
431
|
+
'• https://docs.google.com/document/d/* — Google Drive.',
|
|
432
|
+
'• https://*.notion.so/* — Notion page.',
|
|
433
|
+
'• obsidian://vault/<vault>/<path> — Obsidian vault.',
|
|
434
|
+
'Override routing with explicit type param.',
|
|
435
|
+
'IDEMPOTENT on (uri, content_hash): same URI returns same job_id or memory_id — DO NOT retry on conflict.',
|
|
436
|
+
'RETURNS: { id, type, title, status: "completed" } for fast paths, or { job_id, status: "pending", estimated_ms } for slow paths (audio, large videos).',
|
|
437
|
+
'For pending jobs, call get_ingest_status(job_id) to check progress. Completed jobs are automatically searchable via recall().',
|
|
438
|
+
].join(' '),
|
|
439
|
+
inputSchema: {
|
|
440
|
+
type: 'object',
|
|
441
|
+
properties: {
|
|
442
|
+
uri: {
|
|
443
|
+
type: 'string',
|
|
444
|
+
description: 'File path (absolute) or URL to ingest.',
|
|
445
|
+
},
|
|
446
|
+
type: {
|
|
447
|
+
type: 'string',
|
|
448
|
+
description: 'Force a specific memory type (overrides auto-routing).',
|
|
449
|
+
},
|
|
450
|
+
title: {
|
|
451
|
+
type: 'string',
|
|
452
|
+
description: 'Override the auto-detected title.',
|
|
453
|
+
},
|
|
454
|
+
tags: {
|
|
455
|
+
type: 'array',
|
|
456
|
+
items: { type: 'string' },
|
|
457
|
+
description: 'Tags to attach to the ingested memory.',
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
required: ['uri'],
|
|
461
|
+
},
|
|
462
|
+
handler: async (args) => {
|
|
463
|
+
const uri = args.uri;
|
|
464
|
+
const forceType = args.type;
|
|
465
|
+
const titleOverride = args.title;
|
|
466
|
+
const tagsOverride = args.tags;
|
|
467
|
+
const normalUri = normalizeUri(uri);
|
|
468
|
+
// YouTube fast-path heuristic: probe duration, sync if <5 min
|
|
469
|
+
const isYoutubeUri = normalUri.startsWith('https://www.youtube.com/watch') ||
|
|
470
|
+
normalUri.startsWith('https://youtu.be/');
|
|
471
|
+
if ((isYoutubeUri || forceType === 'youtube') && !forceType?.startsWith('audio')) {
|
|
472
|
+
const videoIdMatch = normalUri.match(/(?:[?&]v=|youtu\.be\/)([A-Za-z0-9_-]{11})/);
|
|
473
|
+
if (videoIdMatch) {
|
|
474
|
+
const duration = await probeYoutubeDuration(videoIdMatch[1]);
|
|
475
|
+
if (duration !== null && duration < 300) {
|
|
476
|
+
// Short video (<5 min) — sync path
|
|
477
|
+
try {
|
|
478
|
+
const result = await routeIngest(uri, forceType, titleOverride, tagsOverride, store, config);
|
|
479
|
+
return { ...result, status: 'completed', fast_path: true };
|
|
480
|
+
}
|
|
481
|
+
catch (e) {
|
|
482
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
483
|
+
log.warn(`ingest (sync youtube) failed for ${uri}: ${msg}`);
|
|
484
|
+
return { error: msg };
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// Probe failed or video >= 5 min — fall through to async
|
|
489
|
+
return handleAsyncIngest(uri, forceType, titleOverride, tagsOverride, store, config);
|
|
490
|
+
}
|
|
491
|
+
// Detect other heavy operations that need async processing
|
|
492
|
+
if (isHeavyOp(normalUri, forceType)) {
|
|
493
|
+
return handleAsyncIngest(uri, forceType, titleOverride, tagsOverride, store, config);
|
|
494
|
+
}
|
|
495
|
+
try {
|
|
496
|
+
const result = await routeIngest(uri, forceType, titleOverride, tagsOverride, store, config);
|
|
497
|
+
return { ...result, status: 'completed' };
|
|
498
|
+
}
|
|
499
|
+
catch (e) {
|
|
500
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
501
|
+
log.warn(`ingest failed for ${uri}: ${msg}`);
|
|
502
|
+
return { error: msg };
|
|
503
|
+
}
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
// ── get_ingest_status ─────────────────────────────────────────────────────
|
|
507
|
+
{
|
|
508
|
+
name: 'get_ingest_status',
|
|
509
|
+
description: [
|
|
510
|
+
'Check the status of an async ingest job (audio files, large YouTube videos).',
|
|
511
|
+
'WHEN: ingest() returned { job_id, status: "pending" }. Poll only after waiting retry_after_ms.',
|
|
512
|
+
'ANTI-LOOP: if status is "pending" or "processing", wait AT LEAST retry_after_ms (starts at 1s, doubles each poll, caps at 10s) before calling again.',
|
|
513
|
+
'If should_give_up is true (poll_count >= 10): stop polling, surface to user — DO NOT poll indefinitely.',
|
|
514
|
+
'RETURNS: { job_id, status, memory_id?, error?, progress, retry_after_ms, should_give_up }.',
|
|
515
|
+
'Once status is "completed", the memory_id is searchable via recall().',
|
|
516
|
+
].join(' '),
|
|
517
|
+
inputSchema: {
|
|
518
|
+
type: 'object',
|
|
519
|
+
properties: {
|
|
520
|
+
job_id: { type: 'string', description: 'Job id returned by ingest().' },
|
|
521
|
+
},
|
|
522
|
+
required: ['job_id'],
|
|
523
|
+
},
|
|
524
|
+
handler: async (args) => {
|
|
525
|
+
const { getJob, computeRetryHint } = await import('../../ingest/jobs.js');
|
|
526
|
+
const job = getJob(args.job_id);
|
|
527
|
+
if (!job)
|
|
528
|
+
return { error: 'job_not_found' };
|
|
529
|
+
const { retry_after_ms, should_give_up } = computeRetryHint(job.poll_count);
|
|
530
|
+
return {
|
|
531
|
+
job_id: job.id,
|
|
532
|
+
status: job.status,
|
|
533
|
+
progress: job.progress,
|
|
534
|
+
memory_id: job.memory_id ?? undefined,
|
|
535
|
+
error: job.error ?? undefined,
|
|
536
|
+
retry_after_ms,
|
|
537
|
+
should_give_up,
|
|
538
|
+
};
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
// ── suggest_properties ────────────────────────────────────────────────────
|
|
542
|
+
{
|
|
543
|
+
name: 'suggest_properties',
|
|
544
|
+
description: [
|
|
545
|
+
'Return the full content of a memory plus a structured instruction for you (the calling LLM) to extract title/tags/sentiment/action_required.',
|
|
546
|
+
'WHEN: a memory was ingested without metadata (audio drop, Drive file ingested by filename only). Call this, extract fields from the content, then call update().',
|
|
547
|
+
'Flow: suggest_properties(id) → extract metadata from returned content → update(id, title, tags).',
|
|
548
|
+
'ANTI-LOOP: DO NOT call suggest_properties twice on the same id without calling update() in between.',
|
|
549
|
+
'RETURNS: { memory_id, type, content, current_properties, instruction }.',
|
|
550
|
+
].join(' '),
|
|
551
|
+
inputSchema: {
|
|
552
|
+
type: 'object',
|
|
553
|
+
properties: {
|
|
554
|
+
id: { type: 'string', description: 'Memory id whose properties to enrich.' },
|
|
555
|
+
},
|
|
556
|
+
required: ['id'],
|
|
557
|
+
},
|
|
558
|
+
handler: async (args) => {
|
|
559
|
+
const id = args.id;
|
|
560
|
+
const item = store.getById(id);
|
|
561
|
+
if (!item)
|
|
562
|
+
return { error: 'not_found' };
|
|
563
|
+
const currentProps = {
|
|
564
|
+
title: item.properties.title ?? null,
|
|
565
|
+
tags: item.properties.tags ?? [],
|
|
566
|
+
sentiment: item.properties.sentiment ?? null,
|
|
567
|
+
action_required: item.properties.action_required ?? null,
|
|
568
|
+
};
|
|
569
|
+
const instruction = store.prompts.suggestPropertiesInstruction
|
|
570
|
+
? store.prompts.suggestPropertiesInstruction(id, item.content, currentProps)
|
|
571
|
+
: [
|
|
572
|
+
`Read the content below and extract metadata. Then call update() with id="${id}" and the fields you extract.`,
|
|
573
|
+
'Required: title (3-7 word summary), tags (2-5 lowercase keywords: people, projects, topics).',
|
|
574
|
+
'Optional: sentiment ("positive"|"neutral"|"negative"), action_required (true if there is an open task).',
|
|
575
|
+
'Only set fields that are currently null/empty.',
|
|
576
|
+
].join('\n');
|
|
577
|
+
return {
|
|
578
|
+
memory_id: id,
|
|
579
|
+
type: item.type,
|
|
580
|
+
content: item.content,
|
|
581
|
+
current_properties: currentProps,
|
|
582
|
+
instruction,
|
|
583
|
+
};
|
|
584
|
+
},
|
|
585
|
+
},
|
|
586
|
+
// ── watch ─────────────────────────────────────────────────────────────────
|
|
587
|
+
{
|
|
588
|
+
name: 'watch',
|
|
589
|
+
description: [
|
|
590
|
+
'Start watching a remote source for new content. Newly created/updated items are automatically ingested into memory.',
|
|
591
|
+
'Supports Drive files (auto-ingest on change), Notion pages, YouTube channels (auto-ingest new uploads), and Obsidian vaults (fs.watch).',
|
|
592
|
+
'REQUIRES the source to be connected first (use connect_drive / connect_notion if not yet authenticated — they require user browser action).',
|
|
593
|
+
'WHEN: user wants to continuously sync a Drive doc, Notion page, YouTube channel, or Obsidian vault.',
|
|
594
|
+
'IDEMPOTENT: calling twice on same target_id returns the existing watch with {already_watching: true} — DO NOT retry.',
|
|
595
|
+
'RETURNS: { watched: true, source_id, display_name } or { watched: true, already_watching: true, source_id }.',
|
|
596
|
+
].join(' '),
|
|
597
|
+
inputSchema: {
|
|
598
|
+
type: 'object',
|
|
599
|
+
properties: {
|
|
600
|
+
source_type: {
|
|
601
|
+
type: 'string',
|
|
602
|
+
enum: ['drive', 'notion', 'youtube', 'obsidian'],
|
|
603
|
+
description: 'Type of source to watch.',
|
|
604
|
+
},
|
|
605
|
+
target_id: {
|
|
606
|
+
type: 'string',
|
|
607
|
+
description: 'For drive: file/folder id. For notion: page/database id. For youtube: channel handle or ID (UCxxx). For obsidian: absolute vault path.',
|
|
608
|
+
},
|
|
609
|
+
opts: {
|
|
610
|
+
type: 'object',
|
|
611
|
+
description: 'Module-specific options (e.g. { recursive: true } for obsidian, { channelName: "My Channel" } for youtube).',
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
required: ['source_type', 'target_id'],
|
|
615
|
+
},
|
|
616
|
+
handler: async (args) => {
|
|
617
|
+
const sourceType = args.source_type;
|
|
618
|
+
const targetId = args.target_id;
|
|
619
|
+
const opts = args.opts ?? {};
|
|
620
|
+
const { sourceRegistry } = await import('../core/source-registry.js');
|
|
621
|
+
switch (sourceType) {
|
|
622
|
+
case 'drive': {
|
|
623
|
+
const { getFileMetadata, downloadFileContent } = await import('../modules/drive/connector.js');
|
|
624
|
+
const { buildDriveItem } = await import('../modules/drive/ingest.js');
|
|
625
|
+
const meta = await getFileMetadata(targetId, config);
|
|
626
|
+
const { id: sourceId, alreadyExists: driveAlreadyExists } = sourceRegistry.addWithStatus({
|
|
627
|
+
module_id: 'drive',
|
|
628
|
+
external_id: targetId,
|
|
629
|
+
display_name: meta.name,
|
|
630
|
+
config: { mimeType: meta.mimeType },
|
|
631
|
+
});
|
|
632
|
+
if (driveAlreadyExists) {
|
|
633
|
+
return { watched: true, already_watching: true, source_id: sourceId, display_name: meta.name };
|
|
634
|
+
}
|
|
635
|
+
const content = await downloadFileContent(targetId, meta.mimeType, config);
|
|
636
|
+
if (content) {
|
|
637
|
+
const item = buildDriveItem({ metadata: meta, content, embeddingModel });
|
|
638
|
+
await store.insert(item);
|
|
639
|
+
sourceRegistry.recordSync(sourceId, meta.modifiedTime);
|
|
640
|
+
}
|
|
641
|
+
return { watched: true, source_id: sourceId, display_name: meta.name };
|
|
642
|
+
}
|
|
643
|
+
case 'notion': {
|
|
644
|
+
const { getPageMetadata, fetchPageText } = await import('../modules/notion/connector.js');
|
|
645
|
+
const { buildNotionItem } = await import('../modules/notion/ingest.js');
|
|
646
|
+
const meta = await getPageMetadata(targetId);
|
|
647
|
+
const { id: sourceId, alreadyExists: notionAlreadyExists } = sourceRegistry.addWithStatus({
|
|
648
|
+
module_id: 'notion',
|
|
649
|
+
external_id: meta.id,
|
|
650
|
+
display_name: meta.title,
|
|
651
|
+
});
|
|
652
|
+
if (notionAlreadyExists) {
|
|
653
|
+
return { watched: true, already_watching: true, source_id: sourceId, display_name: meta.title };
|
|
654
|
+
}
|
|
655
|
+
const content = await fetchPageText(meta.id);
|
|
656
|
+
const item = buildNotionItem({ metadata: meta, content, embeddingModel });
|
|
657
|
+
await store.insert(item);
|
|
658
|
+
sourceRegistry.recordSync(sourceId, meta.last_edited_time);
|
|
659
|
+
return { watched: true, source_id: sourceId, display_name: meta.title };
|
|
660
|
+
}
|
|
661
|
+
case 'youtube': {
|
|
662
|
+
const { resolveChannelId } = await import('../modules/youtube/watcher.js');
|
|
663
|
+
const channelId = await resolveChannelId(targetId);
|
|
664
|
+
const channelName = opts.channelName ?? channelId;
|
|
665
|
+
const { id: sourceId, alreadyExists: ytAlreadyExists } = sourceRegistry.addWithStatus({
|
|
666
|
+
module_id: 'youtube',
|
|
667
|
+
external_id: channelId,
|
|
668
|
+
display_name: channelName,
|
|
669
|
+
config: { channelId, channelName },
|
|
670
|
+
});
|
|
671
|
+
if (ytAlreadyExists) {
|
|
672
|
+
const existingSource = sourceRegistry.get(sourceId);
|
|
673
|
+
return { watched: true, already_watching: true, source_id: sourceId, display_name: existingSource?.display_name ?? channelName };
|
|
674
|
+
}
|
|
675
|
+
return { watched: true, source_id: sourceId, display_name: channelName };
|
|
676
|
+
}
|
|
677
|
+
case 'obsidian': {
|
|
678
|
+
const path = await import('path');
|
|
679
|
+
const { readVault } = await import('../modules/obsidian/vault-reader.js');
|
|
680
|
+
const { buildObsidianItem } = await import('../modules/obsidian/ingest.js');
|
|
681
|
+
const vaultPath = path.default.resolve(targetId);
|
|
682
|
+
const { id: sourceId, alreadyExists: obsidianAlreadyExists } = sourceRegistry.addWithStatus({
|
|
683
|
+
module_id: 'obsidian',
|
|
684
|
+
external_id: vaultPath,
|
|
685
|
+
display_name: path.default.basename(vaultPath),
|
|
686
|
+
config: { vault_path: vaultPath },
|
|
687
|
+
});
|
|
688
|
+
if (obsidianAlreadyExists) {
|
|
689
|
+
return { watched: true, already_watching: true, source_id: sourceId, display_name: path.default.basename(vaultPath) };
|
|
690
|
+
}
|
|
691
|
+
const files = await readVault(vaultPath);
|
|
692
|
+
for (const file of files) {
|
|
693
|
+
const item = buildObsidianItem({ file, vaultRoot: vaultPath, embeddingModel });
|
|
694
|
+
await store.deleteBySourceId(item.source_id);
|
|
695
|
+
await store.insert(item);
|
|
696
|
+
}
|
|
697
|
+
sourceRegistry.recordSync(sourceId, new Date().toISOString());
|
|
698
|
+
return { watched: true, source_id: sourceId, display_name: path.default.basename(vaultPath), files_indexed: files.length };
|
|
699
|
+
}
|
|
700
|
+
default:
|
|
701
|
+
throw new Error(`Unknown source_type: ${sourceType}`);
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
},
|
|
705
|
+
// ── unwatch ───────────────────────────────────────────────────────────────
|
|
706
|
+
{
|
|
707
|
+
name: 'unwatch',
|
|
708
|
+
description: [
|
|
709
|
+
'Stop watching a source (Drive, Notion, YouTube, Obsidian). Memories already ingested are kept.',
|
|
710
|
+
'WHEN: user wants to stop auto-syncing a source.',
|
|
711
|
+
'IDEMPOTENT: returns success even if the source was not being watched — DO NOT retry.',
|
|
712
|
+
'RETURNS: { removed: true, source_id } or { error: "not_found" }.',
|
|
713
|
+
].join(' '),
|
|
714
|
+
inputSchema: {
|
|
715
|
+
type: 'object',
|
|
716
|
+
properties: {
|
|
717
|
+
source_type: {
|
|
718
|
+
type: 'string',
|
|
719
|
+
enum: ['drive', 'notion', 'youtube', 'obsidian'],
|
|
720
|
+
description: 'Type of source (used to look up the right entry if source_id not provided).',
|
|
721
|
+
},
|
|
722
|
+
target_id: {
|
|
723
|
+
type: 'string',
|
|
724
|
+
description: 'The original target_id passed to watch() (file id, page id, channel id, or vault path). Alternatively, pass source_id directly.',
|
|
725
|
+
},
|
|
726
|
+
source_id: {
|
|
727
|
+
type: 'string',
|
|
728
|
+
description: 'The source_id returned by watch(). If provided, source_type and target_id are ignored.',
|
|
729
|
+
},
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
handler: async (args) => {
|
|
733
|
+
const { sourceRegistry } = await import('../core/source-registry.js');
|
|
734
|
+
const sourceId = args.source_id;
|
|
735
|
+
if (sourceId) {
|
|
736
|
+
const found = sourceRegistry.get(sourceId);
|
|
737
|
+
if (!found) {
|
|
738
|
+
// Idempotent: already removed is still success
|
|
739
|
+
return { removed: true, already_removed: true, source_id: sourceId };
|
|
740
|
+
}
|
|
741
|
+
sourceRegistry.remove(sourceId);
|
|
742
|
+
return { removed: true, source_id: sourceId };
|
|
743
|
+
}
|
|
744
|
+
const sourceType = args.source_type;
|
|
745
|
+
const targetId = args.target_id;
|
|
746
|
+
if (!sourceType || !targetId) {
|
|
747
|
+
return { error: 'Provide either source_id or both source_type + target_id' };
|
|
748
|
+
}
|
|
749
|
+
const sources = sourceRegistry.list(sourceType);
|
|
750
|
+
const found = sources.find((s) => s.external_id === targetId);
|
|
751
|
+
if (!found) {
|
|
752
|
+
// Idempotent: not watching this target is still success
|
|
753
|
+
return { removed: true, already_removed: true, source_id: targetId };
|
|
754
|
+
}
|
|
755
|
+
sourceRegistry.remove(found.id);
|
|
756
|
+
return { removed: true, source_id: found.id };
|
|
757
|
+
},
|
|
758
|
+
},
|
|
759
|
+
// ── list_sources ──────────────────────────────────────────────────────────
|
|
760
|
+
{
|
|
761
|
+
name: 'list_sources',
|
|
762
|
+
description: [
|
|
763
|
+
'List all watched sources (Drive files, Notion pages, YouTube channels, Obsidian vaults) with sync status.',
|
|
764
|
+
'WHEN: checking what is being auto-synced, or to find a source_id for unwatch().',
|
|
765
|
+
'CACHE: DO NOT call repeatedly in the same turn — cache the result yourself.',
|
|
766
|
+
'RETURNS: array of { id, module_id, external_id, display_name, last_synced_at, enabled }.',
|
|
767
|
+
].join(' '),
|
|
768
|
+
inputSchema: {
|
|
769
|
+
type: 'object',
|
|
770
|
+
properties: {
|
|
771
|
+
source_type: {
|
|
772
|
+
type: 'string',
|
|
773
|
+
enum: ['drive', 'notion', 'youtube', 'obsidian'],
|
|
774
|
+
description: 'Filter by source type (optional — omit to list all).',
|
|
775
|
+
},
|
|
776
|
+
},
|
|
777
|
+
},
|
|
778
|
+
handler: async (args) => {
|
|
779
|
+
const { sourceRegistry } = await import('../core/source-registry.js');
|
|
780
|
+
const sourceType = args.source_type;
|
|
781
|
+
return sourceRegistry.list(sourceType);
|
|
782
|
+
},
|
|
783
|
+
},
|
|
784
|
+
// ── create_type ───────────────────────────────────────────────────────────
|
|
785
|
+
{
|
|
786
|
+
name: 'create_type',
|
|
787
|
+
description: [
|
|
788
|
+
'Create a user-defined memory type at runtime. Types are just rows in custom_types — no dynamic tools are registered.',
|
|
789
|
+
'Once created, use remember({ type: "<name>", ... }) to store items of that type.',
|
|
790
|
+
'Use recall({ types: ["<name>"] }) to search within the type.',
|
|
791
|
+
'WHEN: user wants to track a custom category (books, recipes, contacts, etc.).',
|
|
792
|
+
'IDEMPOTENT on (name): if the type already exists, returns the existing type with {created: false} — DO NOT retry.',
|
|
793
|
+
'RETURNS: { type_name, created: true|false }.',
|
|
794
|
+
].join(' '),
|
|
795
|
+
inputSchema: {
|
|
796
|
+
type: 'object',
|
|
797
|
+
properties: {
|
|
798
|
+
name: {
|
|
799
|
+
type: 'string',
|
|
800
|
+
description: 'lowercase snake_case name (e.g. "books", "recipes"). Max 31 chars.',
|
|
801
|
+
},
|
|
802
|
+
display_name: {
|
|
803
|
+
type: 'string',
|
|
804
|
+
description: 'Human-readable name (e.g. "Books").',
|
|
805
|
+
},
|
|
806
|
+
schema: {
|
|
807
|
+
type: 'object',
|
|
808
|
+
description: 'Optional JSON Schema for custom properties.',
|
|
809
|
+
},
|
|
810
|
+
},
|
|
811
|
+
required: ['name'],
|
|
812
|
+
},
|
|
813
|
+
handler: async (args) => {
|
|
814
|
+
const { createCustomType, listCustomTypes } = await import('../modules/_custom/persistence.js');
|
|
815
|
+
const { createGenericModule } = await import('../modules/_custom/generic-module.js');
|
|
816
|
+
const { moduleRegistry } = await import('../core/module-registry.js');
|
|
817
|
+
const typeName = args.name;
|
|
818
|
+
const displayName = args.display_name ?? typeName;
|
|
819
|
+
// Idempotency: check if type already exists
|
|
820
|
+
const existing = listCustomTypes().find((t) => t.type_name === typeName);
|
|
821
|
+
if (existing) {
|
|
822
|
+
return { type_name: existing.type_name, created: false };
|
|
823
|
+
}
|
|
824
|
+
const def = createCustomType({
|
|
825
|
+
type_name: typeName,
|
|
826
|
+
display_name: displayName,
|
|
827
|
+
schema: args.schema,
|
|
828
|
+
});
|
|
829
|
+
const mod = createGenericModule(def, config);
|
|
830
|
+
moduleRegistry.register(mod);
|
|
831
|
+
await mod.onBoot({ store });
|
|
832
|
+
log.info(`Created custom type ${def.type_name}`);
|
|
833
|
+
return { type_name: def.type_name, created: true };
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
// ── connect_drive ─────────────────────────────────────────────────────────
|
|
837
|
+
{
|
|
838
|
+
name: 'connect_drive',
|
|
839
|
+
description: [
|
|
840
|
+
'Initiate Google Drive OAuth. RETURNS {auth_url, instructions}.',
|
|
841
|
+
'The user must open auth_url in a browser and complete the authorization.',
|
|
842
|
+
'NON-RETRYABLE: DO NOT call this tool again until the user confirms completion in conversation.',
|
|
843
|
+
'Calling it repeatedly creates orphan OAuth states.',
|
|
844
|
+
'After user confirms, drive ingestion is available — verify with list_drive_files or use ingest(drive_url) / watch({source_type: "drive"}).',
|
|
845
|
+
'If Drive is already connected, returns {already_connected: true} — do not call again.',
|
|
846
|
+
'RETURNS: {already_connected: true} or {auth_url, connected: true} or {auth_url, timeout: true} (user did not authorize in 5 min).',
|
|
847
|
+
].join(' '),
|
|
848
|
+
inputSchema: { type: 'object', properties: {} },
|
|
849
|
+
handler: async () => {
|
|
850
|
+
const { startDriveOAuthFlow, isDriveConnected } = await import('../modules/drive/oauth.js');
|
|
851
|
+
if (isDriveConnected())
|
|
852
|
+
return { already_connected: true };
|
|
853
|
+
if (!config.drive) {
|
|
854
|
+
return {
|
|
855
|
+
error: 'drive_not_configured',
|
|
856
|
+
message: 'Google Drive OAuth credentials are not configured.',
|
|
857
|
+
hint: 'Set drive.clientId and drive.clientSecret in ~/.engram/config.json. Get OAuth credentials at https://console.cloud.google.com/apis/credentials',
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
try {
|
|
861
|
+
const flow = await startDriveOAuthFlow(config);
|
|
862
|
+
const result = await Promise.race([
|
|
863
|
+
flow.waitForCallback.then(() => ({ connected: true })),
|
|
864
|
+
new Promise((resolve) => setTimeout(() => resolve({ timeout: true }), 300_000)),
|
|
865
|
+
]);
|
|
866
|
+
return { auth_url: flow.authUrl, instructions: 'Open auth_url in your browser and authorize. Then confirm here.', ...result };
|
|
867
|
+
}
|
|
868
|
+
catch (e) {
|
|
869
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
870
|
+
return {
|
|
871
|
+
error: 'drive_not_configured',
|
|
872
|
+
message: msg,
|
|
873
|
+
hint: 'Set drive.clientId and drive.clientSecret in ~/.engram/config.json. Get OAuth credentials at https://console.cloud.google.com/apis/credentials',
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
},
|
|
877
|
+
},
|
|
878
|
+
// ── list_drive_files ──────────────────────────────────────────────────────
|
|
879
|
+
{
|
|
880
|
+
name: 'list_drive_files',
|
|
881
|
+
description: [
|
|
882
|
+
'List recent Google Drive files visible to the connected account.',
|
|
883
|
+
'Requires Drive to be connected first (use connect_drive if not).',
|
|
884
|
+
'Default returns up to 100 files. Use folder_id or query to narrow.',
|
|
885
|
+
'PAGINATION: DO NOT call multiple times for the same folder in one turn — cache the result yourself.',
|
|
886
|
+
'Prefer ingest(drive_url) or watch({source_type: "drive", target_id}) for specific files.',
|
|
887
|
+
'RETURNS: array of { id, name, mimeType, modifiedTime }.',
|
|
888
|
+
].join(' '),
|
|
889
|
+
inputSchema: {
|
|
890
|
+
type: 'object',
|
|
891
|
+
properties: {
|
|
892
|
+
query: { type: 'string', description: 'Google Drive search query (e.g. "name contains \'report\'".' },
|
|
893
|
+
limit: { type: 'number', default: 25, description: 'Max files to return (default 25, max 100).' },
|
|
894
|
+
},
|
|
895
|
+
},
|
|
896
|
+
handler: async (args) => {
|
|
897
|
+
const { isDriveConnected } = await import('../modules/drive/oauth.js');
|
|
898
|
+
if (!isDriveConnected()) {
|
|
899
|
+
return {
|
|
900
|
+
error: 'drive_not_connected',
|
|
901
|
+
message: 'Google Drive is not connected.',
|
|
902
|
+
hint: 'Call connect_drive first to authenticate with Google Drive.',
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
const { listFiles } = await import('../modules/drive/connector.js');
|
|
906
|
+
try {
|
|
907
|
+
const { files } = await listFiles(config, {
|
|
908
|
+
query: args.query,
|
|
909
|
+
pageSize: args.limit ?? 25,
|
|
910
|
+
});
|
|
911
|
+
return files.map((f) => ({
|
|
912
|
+
id: f.id,
|
|
913
|
+
name: f.name,
|
|
914
|
+
mimeType: f.mimeType,
|
|
915
|
+
modifiedTime: f.modifiedTime,
|
|
916
|
+
}));
|
|
917
|
+
}
|
|
918
|
+
catch (e) {
|
|
919
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
920
|
+
return {
|
|
921
|
+
error: 'drive_error',
|
|
922
|
+
message: msg,
|
|
923
|
+
hint: 'Check that Drive is connected and try again. If the token expired, call connect_drive to re-authenticate.',
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
},
|
|
927
|
+
},
|
|
928
|
+
// ── connect_notion ────────────────────────────────────────────────────────
|
|
929
|
+
{
|
|
930
|
+
name: 'connect_notion',
|
|
931
|
+
description: [
|
|
932
|
+
'Initiate Notion OAuth. RETURNS {auth_url, instructions}.',
|
|
933
|
+
'The user must open auth_url in a browser and authorize the Notion integration.',
|
|
934
|
+
'NON-RETRYABLE: DO NOT call this tool again until the user confirms completion in conversation.',
|
|
935
|
+
'Calling it repeatedly creates orphan OAuth states.',
|
|
936
|
+
'After user confirms, Notion ingestion is available — use ingest(notion_url) or watch({source_type: "notion"}).',
|
|
937
|
+
'If Notion is already connected, returns {already_connected: true, workspace} — do not call again.',
|
|
938
|
+
'RETURNS: {already_connected: true, workspace} or {auth_url, connected: true, workspace} or {auth_url, timeout: true}.',
|
|
939
|
+
].join(' '),
|
|
940
|
+
inputSchema: { type: 'object', properties: {} },
|
|
941
|
+
handler: async () => {
|
|
942
|
+
const { startNotionOAuthFlow, isNotionConnected, getNotionWorkspace } = await import('../modules/notion/oauth.js');
|
|
943
|
+
if (isNotionConnected()) {
|
|
944
|
+
const ws = getNotionWorkspace();
|
|
945
|
+
return { already_connected: true, workspace: ws?.name };
|
|
946
|
+
}
|
|
947
|
+
if (!config.notion) {
|
|
948
|
+
return {
|
|
949
|
+
error: 'notion_not_configured',
|
|
950
|
+
message: 'Notion OAuth credentials are not configured.',
|
|
951
|
+
hint: 'Set notion.clientId and notion.clientSecret in ~/.engram/config.json. Create an integration at https://www.notion.so/my-integrations',
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
try {
|
|
955
|
+
const flow = await startNotionOAuthFlow(config);
|
|
956
|
+
const result = await Promise.race([
|
|
957
|
+
flow.waitForCallback.then((t) => ({ connected: true, workspace: t.workspace_name })),
|
|
958
|
+
new Promise((resolve) => setTimeout(() => resolve({ timeout: true }), 300_000)),
|
|
959
|
+
]);
|
|
960
|
+
return { auth_url: flow.authUrl, instructions: 'Open auth_url in your browser and authorize. Then confirm here.', ...result };
|
|
961
|
+
}
|
|
962
|
+
catch (e) {
|
|
963
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
964
|
+
return {
|
|
965
|
+
error: 'notion_not_configured',
|
|
966
|
+
message: msg,
|
|
967
|
+
hint: 'Set notion.clientId and notion.clientSecret in ~/.engram/config.json. Create an integration at https://www.notion.so/my-integrations',
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
},
|
|
971
|
+
},
|
|
972
|
+
// ── list_notion_pages ─────────────────────────────────────────────────────
|
|
973
|
+
{
|
|
974
|
+
name: 'list_notion_pages',
|
|
975
|
+
description: [
|
|
976
|
+
'Search Notion workspace for pages matching a query.',
|
|
977
|
+
'Requires Notion to be connected first (use connect_notion if not).',
|
|
978
|
+
'Default returns up to 25 pages.',
|
|
979
|
+
'PAGINATION: DO NOT call multiple times for the same query in one turn — cache the result.',
|
|
980
|
+
'Prefer ingest(notion_url) or watch({source_type: "notion", target_id}) for specific pages.',
|
|
981
|
+
'RETURNS: array of Notion page objects.',
|
|
982
|
+
].join(' '),
|
|
983
|
+
inputSchema: {
|
|
984
|
+
type: 'object',
|
|
985
|
+
properties: {
|
|
986
|
+
query: { type: 'string', description: 'Search query for Notion pages.' },
|
|
987
|
+
limit: { type: 'number', default: 25, description: 'Max pages to return (default 25).' },
|
|
988
|
+
},
|
|
989
|
+
},
|
|
990
|
+
handler: async (args) => {
|
|
991
|
+
const { searchPages } = await import('../modules/notion/connector.js');
|
|
992
|
+
const { isNotionConnected } = await import('../modules/notion/oauth.js');
|
|
993
|
+
if (!isNotionConnected())
|
|
994
|
+
return { error: 'notion_not_connected', message: 'Notion is not connected.', hint: 'Call connect_notion first to authenticate with Notion.' };
|
|
995
|
+
return await searchPages(args.query ?? '', args.limit ?? 25);
|
|
996
|
+
},
|
|
997
|
+
},
|
|
998
|
+
// ── import_watch_later ────────────────────────────────────────────────────
|
|
999
|
+
{
|
|
1000
|
+
name: 'import_watch_later',
|
|
1001
|
+
description: [
|
|
1002
|
+
'Bulk-import a YouTube playlist (public URL). Imports all videos as memory items.',
|
|
1003
|
+
'SLOW: can take 1-30 minutes depending on playlist size.',
|
|
1004
|
+
'ANTI-LOOP: DO NOT call twice for the same playlist — duplicates are deduped but the API hammering wastes the user\'s YouTube quota.',
|
|
1005
|
+
'For individual YouTube videos, use ingest(youtube_url) instead.',
|
|
1006
|
+
'Poll get_ingest_status(job_id) to track progress.',
|
|
1007
|
+
'RETURNS: { job_id?, status, imported?, errors? } — large playlists run as async job.',
|
|
1008
|
+
].join(' '),
|
|
1009
|
+
inputSchema: {
|
|
1010
|
+
type: 'object',
|
|
1011
|
+
properties: {
|
|
1012
|
+
playlistUrl: { type: 'string', description: 'Public YouTube playlist URL.' },
|
|
1013
|
+
limit: { type: 'number', description: 'Max videos to import (default 50).' },
|
|
1014
|
+
},
|
|
1015
|
+
required: ['playlistUrl'],
|
|
1016
|
+
},
|
|
1017
|
+
handler: async (args) => {
|
|
1018
|
+
const { importPlaylist } = await import('../modules/youtube/watcher.js');
|
|
1019
|
+
const result = await importPlaylist(args.playlistUrl, store, config.embeddings, config.youtube, args.limit ?? 50);
|
|
1020
|
+
// Normalise response shape to always include status field (matches ingest() contract)
|
|
1021
|
+
return { ...result, status: 'completed' };
|
|
1022
|
+
},
|
|
1023
|
+
},
|
|
1024
|
+
// ── analyze_patterns ─────────────────────────────────────────────────────
|
|
1025
|
+
{
|
|
1026
|
+
name: 'analyze_patterns',
|
|
1027
|
+
description: [
|
|
1028
|
+
'Analyze patterns across multiple memories on a topic.',
|
|
1029
|
+
'WHEN: user asks "what patterns/themes/trends do you see in my notes about X?" or wants a higher-level synthesis than individual recall.',
|
|
1030
|
+
'Returns up to `limit` (default 30) matching memories PLUS pre-computed aggregations (tag freq, timeline, type distribution) PLUS a structured instruction for you (the calling LLM) to do the actual inference.',
|
|
1031
|
+
'You synthesize the patterns from the bundled memories — the aggregations field has pre-computed counts to speed your analysis.',
|
|
1032
|
+
'INPUTS: topic (required — short concept noun), optional types (array to restrict scope), optional limit (default 30, max 100), optional lookback_days (default no limit).',
|
|
1033
|
+
'IDEMPOTENT on (topic, types, limit, lookback_days) — calling twice with same args returns the same bundle. DO NOT call repeatedly in the same turn for the same topic.',
|
|
1034
|
+
'RETURNS: { topic, memories_found, date_range, memories, aggregations: { tags_frequency, types_distribution, timeline }, instruction }.',
|
|
1035
|
+
].join(' '),
|
|
1036
|
+
inputSchema: {
|
|
1037
|
+
type: 'object',
|
|
1038
|
+
properties: {
|
|
1039
|
+
topic: {
|
|
1040
|
+
type: 'string',
|
|
1041
|
+
description: 'Short concept noun to analyze — NOT the full user question.',
|
|
1042
|
+
},
|
|
1043
|
+
types: {
|
|
1044
|
+
type: 'array',
|
|
1045
|
+
items: { type: 'string' },
|
|
1046
|
+
description: 'Restrict to these memory types (default: all).',
|
|
1047
|
+
},
|
|
1048
|
+
limit: {
|
|
1049
|
+
type: 'number',
|
|
1050
|
+
default: 30,
|
|
1051
|
+
description: 'Max memories to bundle (default 30, max 100).',
|
|
1052
|
+
},
|
|
1053
|
+
lookback_days: {
|
|
1054
|
+
type: 'number',
|
|
1055
|
+
description: 'Only include memories from the past N days (default: all time).',
|
|
1056
|
+
},
|
|
1057
|
+
},
|
|
1058
|
+
required: ['topic'],
|
|
1059
|
+
},
|
|
1060
|
+
handler: async (args) => {
|
|
1061
|
+
const topic = args.topic;
|
|
1062
|
+
const limit = Math.min(args.limit ?? 30, 100);
|
|
1063
|
+
const lookbackDays = args.lookback_days;
|
|
1064
|
+
const types = args.types ?? store.listTypes();
|
|
1065
|
+
// Fan-out search across all requested types
|
|
1066
|
+
const perTypeLimit = Math.max(10, Math.ceil(limit * 1.5));
|
|
1067
|
+
const allResults = await Promise.all(types.map(async (t) => {
|
|
1068
|
+
try {
|
|
1069
|
+
const hits = await store.search(t, topic, perTypeLimit);
|
|
1070
|
+
return hits.map((h) => ({
|
|
1071
|
+
...h,
|
|
1072
|
+
score: h.score *
|
|
1073
|
+
(TYPE_WEIGHTS[t] ?? 0.85) *
|
|
1074
|
+
recencyBoost(Date.parse(h.memory.properties.created_at)),
|
|
1075
|
+
}));
|
|
1076
|
+
}
|
|
1077
|
+
catch {
|
|
1078
|
+
return [];
|
|
1079
|
+
}
|
|
1080
|
+
}));
|
|
1081
|
+
let candidates = allResults.flat().sort((a, b) => b.score - a.score).slice(0, limit);
|
|
1082
|
+
// Apply lookback filter if specified
|
|
1083
|
+
if (lookbackDays !== undefined) {
|
|
1084
|
+
const cutoff = Date.now() - lookbackDays * 24 * 60 * 60 * 1000;
|
|
1085
|
+
candidates = candidates.filter((c) => Date.parse(c.memory.properties.created_at) >= cutoff);
|
|
1086
|
+
}
|
|
1087
|
+
if (candidates.length === 0) {
|
|
1088
|
+
return {
|
|
1089
|
+
topic,
|
|
1090
|
+
memories_found: 0,
|
|
1091
|
+
date_range: { from: null, to: null },
|
|
1092
|
+
memories: [],
|
|
1093
|
+
aggregations: { tags_frequency: {}, types_distribution: {}, timeline: [] },
|
|
1094
|
+
instruction: `No memories found matching "${topic}". Try a broader or different topic noun.`,
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
// Pre-compute aggregations
|
|
1098
|
+
const tagsFreq = {};
|
|
1099
|
+
const typesDist = {};
|
|
1100
|
+
const timelineBuckets = {};
|
|
1101
|
+
for (const c of candidates) {
|
|
1102
|
+
// Type distribution
|
|
1103
|
+
const t = c.memory.type;
|
|
1104
|
+
typesDist[t] = (typesDist[t] ?? 0) + 1;
|
|
1105
|
+
// Tag frequency
|
|
1106
|
+
const tags = c.memory.properties.tags ?? [];
|
|
1107
|
+
for (const tag of tags) {
|
|
1108
|
+
tagsFreq[tag] = (tagsFreq[tag] ?? 0) + 1;
|
|
1109
|
+
}
|
|
1110
|
+
// Timeline — bucket by YYYY-MM-DD
|
|
1111
|
+
const day = c.memory.properties.created_at.slice(0, 10);
|
|
1112
|
+
timelineBuckets[day] = (timelineBuckets[day] ?? 0) + 1;
|
|
1113
|
+
}
|
|
1114
|
+
// Top 20 tags by frequency
|
|
1115
|
+
const sortedTags = Object.entries(tagsFreq)
|
|
1116
|
+
.sort(([, a], [, b]) => b - a)
|
|
1117
|
+
.slice(0, 20);
|
|
1118
|
+
const tags_frequency = Object.fromEntries(sortedTags);
|
|
1119
|
+
// Timeline sorted chronologically
|
|
1120
|
+
const timeline = Object.entries(timelineBuckets)
|
|
1121
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
1122
|
+
.map(([date, count]) => ({ date, count }));
|
|
1123
|
+
const allDates = candidates.map((c) => c.memory.properties.created_at);
|
|
1124
|
+
const dateFrom = allDates.reduce((a, b) => (a < b ? a : b));
|
|
1125
|
+
const dateTo = allDates.reduce((a, b) => (a > b ? a : b));
|
|
1126
|
+
const rangeLabel = dateFrom.slice(0, 10) === dateTo.slice(0, 10)
|
|
1127
|
+
? dateFrom.slice(0, 10)
|
|
1128
|
+
: `${dateFrom.slice(0, 10)} to ${dateTo.slice(0, 10)}`;
|
|
1129
|
+
const memories = candidates.map((c) => ({
|
|
1130
|
+
id: c.memory.id,
|
|
1131
|
+
type: c.memory.type,
|
|
1132
|
+
title: c.memory.properties.title ?? null,
|
|
1133
|
+
content_preview: c.snippet,
|
|
1134
|
+
tags: c.memory.properties.tags ?? [],
|
|
1135
|
+
created_at: c.memory.properties.created_at,
|
|
1136
|
+
}));
|
|
1137
|
+
const instruction = [
|
|
1138
|
+
`You have ${candidates.length} memories about "${topic}" spanning ${rangeLabel}. Analyze them and report:`,
|
|
1139
|
+
`1. **Recurring entities** — people, projects, places that come up repeatedly. List top 5 with counts.`,
|
|
1140
|
+
`2. **Pattern themes** — what subtopics/themes recur? Group similar memories.`,
|
|
1141
|
+
`3. **Time progression** — how has the user's relationship with this topic evolved over ${rangeLabel}? Any inflection points?`,
|
|
1142
|
+
`4. **Sentiment arc** — overall positive/neutral/negative, any shifts?`,
|
|
1143
|
+
`5. **Open questions** — what's missing or under-documented?`,
|
|
1144
|
+
`6. **Action items** — anything the user committed to that you can see hasn't been done?`,
|
|
1145
|
+
``,
|
|
1146
|
+
`Use the memories array directly. The aggregations field has pre-computed counts to speed your analysis.`,
|
|
1147
|
+
`Format as markdown sections.`,
|
|
1148
|
+
].join('\n');
|
|
1149
|
+
return {
|
|
1150
|
+
topic,
|
|
1151
|
+
memories_found: candidates.length,
|
|
1152
|
+
date_range: { from: dateFrom, to: dateTo },
|
|
1153
|
+
memories,
|
|
1154
|
+
aggregations: { tags_frequency, types_distribution: typesDist, timeline },
|
|
1155
|
+
instruction,
|
|
1156
|
+
};
|
|
1157
|
+
},
|
|
1158
|
+
},
|
|
1159
|
+
// ── summarize_recent ──────────────────────────────────────────────────────
|
|
1160
|
+
{
|
|
1161
|
+
name: 'summarize_recent',
|
|
1162
|
+
description: [
|
|
1163
|
+
'Return recent memories with a structured summarization prompt for you (the calling LLM) to produce a digest or daily summary.',
|
|
1164
|
+
'WHEN: user asks "summarize my last week", "what did I do recently?", or you want to produce a periodic digest.',
|
|
1165
|
+
'INPUTS: optional types (array to restrict scope), optional days (default 7), optional limit (default 50, max 200).',
|
|
1166
|
+
'IDEMPOTENT on (types, days, limit) — same args return the same bundle within the same day. DO NOT re-call in the same turn.',
|
|
1167
|
+
'RETURNS: { period: { from, to }, memories_count, memories, instruction }.',
|
|
1168
|
+
].join(' '),
|
|
1169
|
+
inputSchema: {
|
|
1170
|
+
type: 'object',
|
|
1171
|
+
properties: {
|
|
1172
|
+
types: {
|
|
1173
|
+
type: 'array',
|
|
1174
|
+
items: { type: 'string' },
|
|
1175
|
+
description: 'Restrict to these memory types (default: all).',
|
|
1176
|
+
},
|
|
1177
|
+
days: {
|
|
1178
|
+
type: 'number',
|
|
1179
|
+
default: 7,
|
|
1180
|
+
description: 'How many days to look back (default 7).',
|
|
1181
|
+
},
|
|
1182
|
+
limit: {
|
|
1183
|
+
type: 'number',
|
|
1184
|
+
default: 50,
|
|
1185
|
+
description: 'Max memories to return (default 50, max 200).',
|
|
1186
|
+
},
|
|
1187
|
+
},
|
|
1188
|
+
},
|
|
1189
|
+
handler: async (args) => {
|
|
1190
|
+
const days = args.days ?? 7;
|
|
1191
|
+
const limit = Math.min(args.limit ?? 50, 200);
|
|
1192
|
+
const types = args.types;
|
|
1193
|
+
const cutoffMs = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
1194
|
+
const cutoffTs = cutoffMs; // SQLite stores created_at as Unix ms integer
|
|
1195
|
+
const { getDb } = await import('../../db/index.js');
|
|
1196
|
+
const db = getDb();
|
|
1197
|
+
let sql = `SELECT id, type, content, properties_json, created_at
|
|
1198
|
+
FROM memories
|
|
1199
|
+
WHERE created_at >= ?`;
|
|
1200
|
+
const params = [cutoffTs];
|
|
1201
|
+
if (types && types.length > 0) {
|
|
1202
|
+
sql += ` AND type IN (${types.map(() => '?').join(',')})`;
|
|
1203
|
+
params.push(...types);
|
|
1204
|
+
}
|
|
1205
|
+
sql += ` ORDER BY created_at DESC LIMIT ?`;
|
|
1206
|
+
params.push(limit);
|
|
1207
|
+
const rows = db.prepare(sql).all(...params);
|
|
1208
|
+
const memories = rows.map((r) => {
|
|
1209
|
+
const props = JSON.parse(r.properties_json);
|
|
1210
|
+
return {
|
|
1211
|
+
id: r.id,
|
|
1212
|
+
type: r.type,
|
|
1213
|
+
title: props.title ?? null,
|
|
1214
|
+
content_preview: r.content.slice(0, 200),
|
|
1215
|
+
tags: props.tags ?? [],
|
|
1216
|
+
created_at: new Date(r.created_at).toISOString(),
|
|
1217
|
+
};
|
|
1218
|
+
});
|
|
1219
|
+
const periodFrom = new Date(cutoffMs).toISOString();
|
|
1220
|
+
const periodTo = new Date().toISOString();
|
|
1221
|
+
const instruction = [
|
|
1222
|
+
`Summarize the user's ${days}-day window (${periodFrom.slice(0, 10)} to ${periodTo.slice(0, 10)}) from ${memories.length} memories.`,
|
|
1223
|
+
``,
|
|
1224
|
+
`Produce a structured digest with these sections:`,
|
|
1225
|
+
`1. **Highlights** — the 3-5 most significant things that happened or were captured.`,
|
|
1226
|
+
`2. **Projects & Work** — what projects were active? Any progress or blockers noted?`,
|
|
1227
|
+
`3. **People** — who came up? Any notable interactions?`,
|
|
1228
|
+
`4. **Decisions made** — anything the user decided or committed to?`,
|
|
1229
|
+
`5. **Open items** — tasks or questions that appear unresolved.`,
|
|
1230
|
+
`6. **One-line summary** — a single sentence capturing the essence of the period.`,
|
|
1231
|
+
``,
|
|
1232
|
+
`Keep the summary concise. Use bullet points. Focus on signal over noise.`,
|
|
1233
|
+
].join('\n');
|
|
1234
|
+
return {
|
|
1235
|
+
period: { from: periodFrom, to: periodTo },
|
|
1236
|
+
memories_count: memories.length,
|
|
1237
|
+
memories,
|
|
1238
|
+
instruction,
|
|
1239
|
+
};
|
|
1240
|
+
},
|
|
1241
|
+
},
|
|
1242
|
+
// ── find_gaps ─────────────────────────────────────────────────────────────
|
|
1243
|
+
{
|
|
1244
|
+
name: 'find_gaps',
|
|
1245
|
+
description: [
|
|
1246
|
+
'Search memories on a topic and return them with a structured prompt asking you (the calling LLM) to identify gaps, broken threads, and unanswered questions.',
|
|
1247
|
+
'WHEN: user asks "what am I missing about X?", "what did I never follow up on?", or wants gap analysis rather than pattern synthesis.',
|
|
1248
|
+
'Returns matching memories PLUS a structured instruction focused on identifying: aspects mentioned but never expanded, promises/commitments without follow-up, people/projects mentioned once and abandoned, questions asked but never answered.',
|
|
1249
|
+
'INPUTS: topic (required), optional lookback_days (default 90).',
|
|
1250
|
+
'IDEMPOTENT on (topic, lookback_days) — calling twice returns same bundle. DO NOT re-call in the same turn.',
|
|
1251
|
+
'RETURNS: { topic, memories_found, date_range, memories, aggregations, instruction }.',
|
|
1252
|
+
].join(' '),
|
|
1253
|
+
inputSchema: {
|
|
1254
|
+
type: 'object',
|
|
1255
|
+
properties: {
|
|
1256
|
+
topic: {
|
|
1257
|
+
type: 'string',
|
|
1258
|
+
description: 'Short concept noun to find gaps for — NOT the full user question.',
|
|
1259
|
+
},
|
|
1260
|
+
lookback_days: {
|
|
1261
|
+
type: 'number',
|
|
1262
|
+
default: 90,
|
|
1263
|
+
description: 'How many days to look back (default 90).',
|
|
1264
|
+
},
|
|
1265
|
+
},
|
|
1266
|
+
required: ['topic'],
|
|
1267
|
+
},
|
|
1268
|
+
handler: async (args) => {
|
|
1269
|
+
const topic = args.topic;
|
|
1270
|
+
const lookbackDays = args.lookback_days ?? 90;
|
|
1271
|
+
const types = store.listTypes();
|
|
1272
|
+
const cutoff = Date.now() - lookbackDays * 24 * 60 * 60 * 1000;
|
|
1273
|
+
// Fan-out search
|
|
1274
|
+
const perTypeLimit = 20;
|
|
1275
|
+
const allResults = await Promise.all(types.map(async (t) => {
|
|
1276
|
+
try {
|
|
1277
|
+
const hits = await store.search(t, topic, perTypeLimit);
|
|
1278
|
+
return hits.map((h) => ({
|
|
1279
|
+
...h,
|
|
1280
|
+
score: h.score *
|
|
1281
|
+
(TYPE_WEIGHTS[t] ?? 0.85) *
|
|
1282
|
+
recencyBoost(Date.parse(h.memory.properties.created_at)),
|
|
1283
|
+
}));
|
|
1284
|
+
}
|
|
1285
|
+
catch {
|
|
1286
|
+
return [];
|
|
1287
|
+
}
|
|
1288
|
+
}));
|
|
1289
|
+
let candidates = allResults.flat().sort((a, b) => b.score - a.score).slice(0, 50);
|
|
1290
|
+
// Apply lookback filter
|
|
1291
|
+
candidates = candidates.filter((c) => Date.parse(c.memory.properties.created_at) >= cutoff);
|
|
1292
|
+
if (candidates.length === 0) {
|
|
1293
|
+
return {
|
|
1294
|
+
topic,
|
|
1295
|
+
memories_found: 0,
|
|
1296
|
+
date_range: { from: null, to: null },
|
|
1297
|
+
memories: [],
|
|
1298
|
+
aggregations: { tags_frequency: {}, types_distribution: {}, timeline: [] },
|
|
1299
|
+
instruction: `No memories found matching "${topic}" in the last ${lookbackDays} days. Try a broader topic or increase lookback_days.`,
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
// Pre-compute aggregations (same as analyze_patterns)
|
|
1303
|
+
const tagsFreq = {};
|
|
1304
|
+
const typesDist = {};
|
|
1305
|
+
const timelineBuckets = {};
|
|
1306
|
+
for (const c of candidates) {
|
|
1307
|
+
const t = c.memory.type;
|
|
1308
|
+
typesDist[t] = (typesDist[t] ?? 0) + 1;
|
|
1309
|
+
const tags = c.memory.properties.tags ?? [];
|
|
1310
|
+
for (const tag of tags) {
|
|
1311
|
+
tagsFreq[tag] = (tagsFreq[tag] ?? 0) + 1;
|
|
1312
|
+
}
|
|
1313
|
+
const day = c.memory.properties.created_at.slice(0, 10);
|
|
1314
|
+
timelineBuckets[day] = (timelineBuckets[day] ?? 0) + 1;
|
|
1315
|
+
}
|
|
1316
|
+
const sortedTags = Object.entries(tagsFreq)
|
|
1317
|
+
.sort(([, a], [, b]) => b - a)
|
|
1318
|
+
.slice(0, 20);
|
|
1319
|
+
const tags_frequency = Object.fromEntries(sortedTags);
|
|
1320
|
+
const timeline = Object.entries(timelineBuckets)
|
|
1321
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
1322
|
+
.map(([date, count]) => ({ date, count }));
|
|
1323
|
+
const allDates = candidates.map((c) => c.memory.properties.created_at);
|
|
1324
|
+
const dateFrom = allDates.reduce((a, b) => (a < b ? a : b));
|
|
1325
|
+
const dateTo = allDates.reduce((a, b) => (a > b ? a : b));
|
|
1326
|
+
const rangeLabel = dateFrom.slice(0, 10) === dateTo.slice(0, 10)
|
|
1327
|
+
? dateFrom.slice(0, 10)
|
|
1328
|
+
: `${dateFrom.slice(0, 10)} to ${dateTo.slice(0, 10)}`;
|
|
1329
|
+
const memories = candidates.map((c) => ({
|
|
1330
|
+
id: c.memory.id,
|
|
1331
|
+
type: c.memory.type,
|
|
1332
|
+
title: c.memory.properties.title ?? null,
|
|
1333
|
+
content_preview: c.snippet,
|
|
1334
|
+
tags: c.memory.properties.tags ?? [],
|
|
1335
|
+
created_at: c.memory.properties.created_at,
|
|
1336
|
+
}));
|
|
1337
|
+
const instruction = [
|
|
1338
|
+
`You have ${candidates.length} memories about "${topic}" spanning ${rangeLabel}. Identify the gaps — what's incomplete, abandoned, or unresolved:`,
|
|
1339
|
+
``,
|
|
1340
|
+
`1. **Mentioned but never expanded** — topics, ideas, or projects referenced briefly but never detailed. List them.`,
|
|
1341
|
+
`2. **Promises & commitments without follow-up** — anything the user said they would do or decided to pursue, that has no later memory confirming it was done.`,
|
|
1342
|
+
`3. **Single-mention entities** — people, tools, or projects that appear exactly once and were never revisited. Are they dropped threads?`,
|
|
1343
|
+
`4. **Unanswered questions** — questions or "I wonder if..." phrasing that was never answered in a later memory.`,
|
|
1344
|
+
`5. **Documentation gaps** — important decisions or events that you'd expect to find notes about but don't.`,
|
|
1345
|
+
`6. **Recommendations** — what should the user document or follow up on next?`,
|
|
1346
|
+
``,
|
|
1347
|
+
`Be specific: quote or cite memory titles/content when identifying gaps. Use the aggregations to spot single-occurrence tags.`,
|
|
1348
|
+
].join('\n');
|
|
1349
|
+
return {
|
|
1350
|
+
topic,
|
|
1351
|
+
memories_found: candidates.length,
|
|
1352
|
+
date_range: { from: dateFrom, to: dateTo },
|
|
1353
|
+
memories,
|
|
1354
|
+
aggregations: { tags_frequency, types_distribution: typesDist, timeline },
|
|
1355
|
+
instruction,
|
|
1356
|
+
};
|
|
1357
|
+
},
|
|
1358
|
+
},
|
|
1359
|
+
// ── delete_type ───────────────────────────────────────────────────────────
|
|
1360
|
+
{
|
|
1361
|
+
name: 'delete_type',
|
|
1362
|
+
description: [
|
|
1363
|
+
'Delete a custom type definition. Memories of that type are kept but the type schema is removed.',
|
|
1364
|
+
'WHEN: user wants to remove a custom type they no longer need.',
|
|
1365
|
+
'REQUIRES confirm: true — if confirm is missing or false, returns {error: "confirm_required", type_summary} for the agent to show the user before retrying with confirm: true.',
|
|
1366
|
+
'DO NOT retry with confirm: true without user acknowledgement — this is a destructive operation.',
|
|
1367
|
+
'RETURNS: { deleted: type_name } or { error: "confirm_required", type_summary } or { error }.',
|
|
1368
|
+
].join(' '),
|
|
1369
|
+
inputSchema: {
|
|
1370
|
+
type: 'object',
|
|
1371
|
+
properties: {
|
|
1372
|
+
name: { type: 'string', description: 'The type name to delete.' },
|
|
1373
|
+
confirm: {
|
|
1374
|
+
type: 'boolean',
|
|
1375
|
+
description: 'Must be true to confirm deletion. Safety guard.',
|
|
1376
|
+
},
|
|
1377
|
+
},
|
|
1378
|
+
required: ['name', 'confirm'],
|
|
1379
|
+
},
|
|
1380
|
+
handler: async (args) => {
|
|
1381
|
+
const typeName = args.name;
|
|
1382
|
+
if (!args.confirm) {
|
|
1383
|
+
// Return type summary so agent can show user before confirming
|
|
1384
|
+
const { getDb } = await import('../../db/index.js');
|
|
1385
|
+
const db = getDb();
|
|
1386
|
+
const memoryCount = db.prepare(`SELECT COUNT(*) as n FROM memories WHERE type = ?`).get(typeName).n;
|
|
1387
|
+
return {
|
|
1388
|
+
error: 'confirm_required',
|
|
1389
|
+
type_summary: {
|
|
1390
|
+
type_name: typeName,
|
|
1391
|
+
memory_count: memoryCount,
|
|
1392
|
+
warning: `This will delete the type schema. ${memoryCount} memories of this type will be kept but the custom type definition will be removed.`,
|
|
1393
|
+
},
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
const { deleteCustomType } = await import('../modules/_custom/persistence.js');
|
|
1397
|
+
deleteCustomType(typeName);
|
|
1398
|
+
return { deleted: typeName };
|
|
1399
|
+
},
|
|
1400
|
+
},
|
|
1401
|
+
];
|
|
1402
|
+
}
|
|
1403
|
+
// ── Heavy-op detection ────────────────────────────────────────────────────────
|
|
1404
|
+
function normalizeUri(uri) {
|
|
1405
|
+
if (!uri.startsWith('http') && !uri.startsWith('obsidian://') && !uri.startsWith('file://')) {
|
|
1406
|
+
return `file://${uri}`;
|
|
1407
|
+
}
|
|
1408
|
+
return uri;
|
|
1409
|
+
}
|
|
1410
|
+
function isHeavyOp(normalUri, forceType) {
|
|
1411
|
+
// Audio files → Whisper (always heavy)
|
|
1412
|
+
if (forceType === 'audio')
|
|
1413
|
+
return true;
|
|
1414
|
+
if (normalUri.startsWith('file://')) {
|
|
1415
|
+
const ext = normalUri.split('.').pop()?.toLowerCase() ?? '';
|
|
1416
|
+
if (['mp3', 'wav', 'm4a', 'ogg', 'webm'].includes(ext))
|
|
1417
|
+
return true;
|
|
1418
|
+
}
|
|
1419
|
+
// YouTube → use fast-path heuristic (handled separately)
|
|
1420
|
+
const isYoutube = normalUri.startsWith('https://www.youtube.com/watch') ||
|
|
1421
|
+
normalUri.startsWith('https://youtu.be/');
|
|
1422
|
+
if (isYoutube || forceType === 'youtube')
|
|
1423
|
+
return true;
|
|
1424
|
+
return false;
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Probe YouTube watch page to estimate video duration in seconds.
|
|
1428
|
+
* Extracts `"lengthSeconds":"NNN"` from the injected player config JSON.
|
|
1429
|
+
* Caps probe at 2s timeout. Returns null on any failure (safe to async).
|
|
1430
|
+
*/
|
|
1431
|
+
async function probeYoutubeDuration(videoId) {
|
|
1432
|
+
try {
|
|
1433
|
+
const resp = await fetch(`https://www.youtube.com/watch?v=${videoId}`, {
|
|
1434
|
+
signal: AbortSignal.timeout(2000),
|
|
1435
|
+
headers: {
|
|
1436
|
+
'User-Agent': 'Mozilla/5.0 (compatible; EngramMCP/0.2)',
|
|
1437
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
1438
|
+
},
|
|
1439
|
+
});
|
|
1440
|
+
if (!resp.ok)
|
|
1441
|
+
return null;
|
|
1442
|
+
const html = await resp.text();
|
|
1443
|
+
const match = html.match(/"lengthSeconds":"(\d+)"/);
|
|
1444
|
+
if (!match)
|
|
1445
|
+
return null;
|
|
1446
|
+
return parseInt(match[1], 10);
|
|
1447
|
+
}
|
|
1448
|
+
catch {
|
|
1449
|
+
return null;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
async function handleAsyncIngest(uri, forceType, titleOverride, tagsOverride, store, config) {
|
|
1453
|
+
const { createJob, startJob, completeJob, failJob } = await import('../../ingest/jobs.js');
|
|
1454
|
+
const normalUri = normalizeUri(uri);
|
|
1455
|
+
const ext = normalUri.startsWith('file://')
|
|
1456
|
+
? normalUri.split('.').pop()?.toLowerCase() ?? ''
|
|
1457
|
+
: '';
|
|
1458
|
+
const isAudio = ['mp3', 'wav', 'm4a', 'ogg', 'webm'].includes(ext) || forceType === 'audio';
|
|
1459
|
+
const jobId = createJob(uri, forceType);
|
|
1460
|
+
const estimatedMs = isAudio ? 60_000 : 30_000;
|
|
1461
|
+
// Fire-and-forget background processing
|
|
1462
|
+
void (async () => {
|
|
1463
|
+
try {
|
|
1464
|
+
startJob(jobId);
|
|
1465
|
+
const result = await routeIngest(uri, forceType, titleOverride, tagsOverride, store, config);
|
|
1466
|
+
const memoryId = result.id;
|
|
1467
|
+
completeJob(jobId, memoryId);
|
|
1468
|
+
}
|
|
1469
|
+
catch (e) {
|
|
1470
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1471
|
+
failJob(jobId, msg);
|
|
1472
|
+
}
|
|
1473
|
+
})();
|
|
1474
|
+
return { job_id: jobId, status: 'pending', estimated_ms: estimatedMs };
|
|
1475
|
+
}
|
|
1476
|
+
// ── Ingest routing helper ─────────────────────────────────────────────────────
|
|
1477
|
+
async function routeIngest(uri, forceType, titleOverride, tagsOverride, store, config) {
|
|
1478
|
+
const embeddingModel = `${config.embeddings.provider}/${config.embeddings.model}`;
|
|
1479
|
+
// Normalise: bare absolute paths → file:// URI
|
|
1480
|
+
let normalUri = uri;
|
|
1481
|
+
if (!uri.startsWith('http') && !uri.startsWith('obsidian://') && !uri.startsWith('file://')) {
|
|
1482
|
+
normalUri = `file://${uri}`;
|
|
1483
|
+
}
|
|
1484
|
+
// ── YouTube ─────────────────────────────────────────────────────────────────
|
|
1485
|
+
const isYoutube = normalUri.startsWith('https://www.youtube.com/watch') ||
|
|
1486
|
+
normalUri.startsWith('https://youtu.be/');
|
|
1487
|
+
if (isYoutube || forceType === 'youtube') {
|
|
1488
|
+
const { fetchTranscript } = await import('../modules/youtube/transcript-fetcher.js');
|
|
1489
|
+
const { buildYoutubeItem } = await import('../modules/youtube/ingest.js');
|
|
1490
|
+
const transcript = await fetchTranscript(normalUri, config.youtube);
|
|
1491
|
+
// Fail fast if transcript is empty — do not create an empty memory
|
|
1492
|
+
if (!transcript.full_text || transcript.full_text.trim() === '' || transcript.segments.length === 0) {
|
|
1493
|
+
throw new Error(`Could not fetch transcript — video may have no captions or yt-dlp is unavailable (video_id: ${transcript.video_id})`);
|
|
1494
|
+
}
|
|
1495
|
+
const item = buildYoutubeItem({ transcript, embeddingModel });
|
|
1496
|
+
if (titleOverride)
|
|
1497
|
+
item.properties.title = titleOverride;
|
|
1498
|
+
if (tagsOverride)
|
|
1499
|
+
item.properties.tags = tagsOverride;
|
|
1500
|
+
await store.insert(item);
|
|
1501
|
+
return { id: item.id, type: item.type, title: item.properties.title };
|
|
1502
|
+
}
|
|
1503
|
+
// ── Google Drive ─────────────────────────────────────────────────────────────
|
|
1504
|
+
const isDrive = normalUri.startsWith('https://docs.google.com/document/d/') ||
|
|
1505
|
+
normalUri.startsWith('https://drive.google.com/file/');
|
|
1506
|
+
if (isDrive || forceType === 'drive') {
|
|
1507
|
+
// Extract file id from URL
|
|
1508
|
+
const driveIdMatch = normalUri.match(/\/d\/([^/?]+)/);
|
|
1509
|
+
if (!driveIdMatch)
|
|
1510
|
+
throw new Error('Cannot extract Drive file id from URI');
|
|
1511
|
+
const fileId = driveIdMatch[1];
|
|
1512
|
+
const { getFileMetadata, downloadFileContent } = await import('../modules/drive/connector.js');
|
|
1513
|
+
const { buildDriveItem } = await import('../modules/drive/ingest.js');
|
|
1514
|
+
const meta = await getFileMetadata(fileId, config);
|
|
1515
|
+
const content = await downloadFileContent(fileId, meta.mimeType, config);
|
|
1516
|
+
if (!content)
|
|
1517
|
+
throw new Error(`Unsupported Drive mimeType: ${meta.mimeType}`);
|
|
1518
|
+
const item = buildDriveItem({ metadata: meta, content, embeddingModel });
|
|
1519
|
+
if (titleOverride)
|
|
1520
|
+
item.properties.title = titleOverride;
|
|
1521
|
+
if (tagsOverride)
|
|
1522
|
+
item.properties.tags = tagsOverride;
|
|
1523
|
+
await store.insert(item);
|
|
1524
|
+
return { id: item.id, type: item.type, title: item.properties.title };
|
|
1525
|
+
}
|
|
1526
|
+
// ── Notion ───────────────────────────────────────────────────────────────────
|
|
1527
|
+
const isNotion = /https:\/\/[^.]+\.notion\.so\//.test(normalUri);
|
|
1528
|
+
if (isNotion || forceType === 'notion') {
|
|
1529
|
+
// Extract page id from Notion URL (last path segment, strip hyphens)
|
|
1530
|
+
const notionPageMatch = normalUri.match(/([a-f0-9]{32}|[a-f0-9-]{36})\??/);
|
|
1531
|
+
if (!notionPageMatch)
|
|
1532
|
+
throw new Error('Cannot extract Notion page id from URI');
|
|
1533
|
+
const pageId = notionPageMatch[1].replace(/-/g, '');
|
|
1534
|
+
const { getPageMetadata, fetchPageText } = await import('../modules/notion/connector.js');
|
|
1535
|
+
const { buildNotionItem } = await import('../modules/notion/ingest.js');
|
|
1536
|
+
const meta = await getPageMetadata(pageId);
|
|
1537
|
+
const content = await fetchPageText(meta.id);
|
|
1538
|
+
const item = buildNotionItem({ metadata: meta, content, embeddingModel });
|
|
1539
|
+
if (titleOverride)
|
|
1540
|
+
item.properties.title = titleOverride;
|
|
1541
|
+
if (tagsOverride)
|
|
1542
|
+
item.properties.tags = tagsOverride;
|
|
1543
|
+
await store.insert(item);
|
|
1544
|
+
return { id: item.id, type: item.type, title: item.properties.title };
|
|
1545
|
+
}
|
|
1546
|
+
// ── Obsidian ─────────────────────────────────────────────────────────────────
|
|
1547
|
+
if (normalUri.startsWith('obsidian://vault/') || forceType === 'obsidian') {
|
|
1548
|
+
// obsidian://vault/<vault>/<path> — treat path as a single file
|
|
1549
|
+
const { readFile } = await import('fs/promises');
|
|
1550
|
+
const vaultMatch = normalUri.replace('obsidian://vault/', '').split('/');
|
|
1551
|
+
const vaultName = vaultMatch[0];
|
|
1552
|
+
const filePath = vaultMatch.slice(1).join('/');
|
|
1553
|
+
const { buildObsidianItem } = await import('../modules/obsidian/ingest.js');
|
|
1554
|
+
const absolutePath = `/${filePath}`; // simplified — real usage needs vault root mapping
|
|
1555
|
+
const content = await readFile(absolutePath, 'utf-8');
|
|
1556
|
+
const item = buildObsidianItem({
|
|
1557
|
+
file: {
|
|
1558
|
+
relativePath: filePath,
|
|
1559
|
+
absolutePath,
|
|
1560
|
+
content,
|
|
1561
|
+
modifiedAt: Date.now(),
|
|
1562
|
+
},
|
|
1563
|
+
vaultRoot: `/${vaultName}`,
|
|
1564
|
+
embeddingModel,
|
|
1565
|
+
});
|
|
1566
|
+
if (titleOverride)
|
|
1567
|
+
item.properties.title = titleOverride;
|
|
1568
|
+
if (tagsOverride)
|
|
1569
|
+
item.properties.tags = tagsOverride;
|
|
1570
|
+
await store.insert(item);
|
|
1571
|
+
return { id: item.id, type: item.type, title: item.properties.title };
|
|
1572
|
+
}
|
|
1573
|
+
// ── File URI ─────────────────────────────────────────────────────────────────
|
|
1574
|
+
if (normalUri.startsWith('file://')) {
|
|
1575
|
+
const filePath = normalUri.replace('file://', '');
|
|
1576
|
+
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
|
1577
|
+
// Audio
|
|
1578
|
+
if (['mp3', 'wav', 'm4a', 'ogg', 'webm'].includes(ext) || forceType === 'audio') {
|
|
1579
|
+
const { transcribeAudio } = await import('../modules/audio/transcriber.js');
|
|
1580
|
+
const { buildAudioItem } = await import('../modules/audio/ingest.js');
|
|
1581
|
+
const transcript = await transcribeAudio(filePath, config.whisper);
|
|
1582
|
+
const item = buildAudioItem({ audioPath: filePath, transcript, embeddingModel });
|
|
1583
|
+
if (titleOverride)
|
|
1584
|
+
item.properties.title = titleOverride;
|
|
1585
|
+
if (tagsOverride)
|
|
1586
|
+
item.properties.tags = tagsOverride;
|
|
1587
|
+
await store.insert(item);
|
|
1588
|
+
return { id: item.id, type: item.type, title: item.properties.title };
|
|
1589
|
+
}
|
|
1590
|
+
// Images
|
|
1591
|
+
if (['png', 'jpg', 'jpeg', 'gif'].includes(ext) || forceType === 'images') {
|
|
1592
|
+
const path = await import('path');
|
|
1593
|
+
const now = new Date().toISOString();
|
|
1594
|
+
const title = titleOverride ?? path.default.basename(filePath);
|
|
1595
|
+
const item = {
|
|
1596
|
+
id: ulid(),
|
|
1597
|
+
type: 'images',
|
|
1598
|
+
source_id: `file:${filePath}`,
|
|
1599
|
+
content: `Image: ${title}`,
|
|
1600
|
+
content_hash: createHash('sha256').update(filePath).digest('hex'),
|
|
1601
|
+
properties: {
|
|
1602
|
+
title,
|
|
1603
|
+
tags: tagsOverride,
|
|
1604
|
+
created_at: now,
|
|
1605
|
+
ingested_at: now,
|
|
1606
|
+
source_url: normalUri,
|
|
1607
|
+
},
|
|
1608
|
+
wikilinks: [],
|
|
1609
|
+
related_ids: [],
|
|
1610
|
+
embedding_model: embeddingModel,
|
|
1611
|
+
};
|
|
1612
|
+
await store.insert(item);
|
|
1613
|
+
return { id: item.id, type: item.type, title: item.properties.title };
|
|
1614
|
+
}
|
|
1615
|
+
// PDF — full text extraction via pdf-parse
|
|
1616
|
+
if (ext === 'pdf' || forceType === 'pdf') {
|
|
1617
|
+
const { readFile } = await import('fs/promises');
|
|
1618
|
+
const path = await import('path');
|
|
1619
|
+
const now = new Date().toISOString();
|
|
1620
|
+
const title = titleOverride ?? path.default.basename(filePath, '.pdf');
|
|
1621
|
+
let content;
|
|
1622
|
+
let extractionFailed = false;
|
|
1623
|
+
let extractionError;
|
|
1624
|
+
try {
|
|
1625
|
+
const buffer = await readFile(filePath);
|
|
1626
|
+
const { PDFParse } = await import('pdf-parse');
|
|
1627
|
+
const parser = new PDFParse({ data: buffer, verbosity: 0 });
|
|
1628
|
+
const result = await parser.getText();
|
|
1629
|
+
content = result.text.trim() || `[PDF] ${title} — no extractable text (possibly scanned image PDF)`;
|
|
1630
|
+
}
|
|
1631
|
+
catch (e) {
|
|
1632
|
+
extractionFailed = true;
|
|
1633
|
+
extractionError = e instanceof Error ? e.message : String(e);
|
|
1634
|
+
content = `[PDF] ${title} — text extraction failed: ${extractionError}. File: ${filePath}`;
|
|
1635
|
+
log.warn(`PDF extraction failed for ${filePath}: ${extractionError}`);
|
|
1636
|
+
}
|
|
1637
|
+
const wikilinks = extractWikilinks(content);
|
|
1638
|
+
const item = {
|
|
1639
|
+
id: ulid(),
|
|
1640
|
+
type: 'notes',
|
|
1641
|
+
source_id: `file:${filePath}`,
|
|
1642
|
+
content,
|
|
1643
|
+
content_hash: createHash('sha256').update(content).digest('hex'),
|
|
1644
|
+
properties: {
|
|
1645
|
+
title,
|
|
1646
|
+
tags: extractionFailed ? [...(tagsOverride ?? []), 'pdf_extraction_failed'] : tagsOverride,
|
|
1647
|
+
created_at: now,
|
|
1648
|
+
ingested_at: now,
|
|
1649
|
+
source_url: normalUri,
|
|
1650
|
+
custom: { pdf_path: filePath, extraction_status: extractionFailed ? 'failed' : 'complete' },
|
|
1651
|
+
},
|
|
1652
|
+
wikilinks,
|
|
1653
|
+
related_ids: [],
|
|
1654
|
+
embedding_model: embeddingModel,
|
|
1655
|
+
};
|
|
1656
|
+
await store.insert(item);
|
|
1657
|
+
const response = { id: item.id, type: 'notes', title };
|
|
1658
|
+
if (extractionFailed)
|
|
1659
|
+
response.extraction_failed = true;
|
|
1660
|
+
return response;
|
|
1661
|
+
}
|
|
1662
|
+
// Markdown / plain text
|
|
1663
|
+
if (['md', 'txt', 'markdown'].includes(ext) || forceType === 'notes') {
|
|
1664
|
+
const { readFile } = await import('fs/promises');
|
|
1665
|
+
const path = await import('path');
|
|
1666
|
+
const content = await readFile(filePath, 'utf-8');
|
|
1667
|
+
const wikilinks = extractWikilinks(content);
|
|
1668
|
+
const now = new Date().toISOString();
|
|
1669
|
+
// Extract title from frontmatter or first H1
|
|
1670
|
+
let autoTitle;
|
|
1671
|
+
const firstLine = content.split('\n').find((l) => l.startsWith('# '));
|
|
1672
|
+
if (firstLine)
|
|
1673
|
+
autoTitle = firstLine.replace(/^#\s+/, '').trim();
|
|
1674
|
+
const item = {
|
|
1675
|
+
id: ulid(),
|
|
1676
|
+
type: 'notes',
|
|
1677
|
+
source_id: `file:${filePath}`,
|
|
1678
|
+
content,
|
|
1679
|
+
content_hash: createHash('sha256').update(content).digest('hex'),
|
|
1680
|
+
properties: {
|
|
1681
|
+
title: titleOverride ?? autoTitle ?? path.default.basename(filePath, `.${ext}`),
|
|
1682
|
+
tags: tagsOverride,
|
|
1683
|
+
created_at: now,
|
|
1684
|
+
ingested_at: now,
|
|
1685
|
+
source_url: normalUri,
|
|
1686
|
+
},
|
|
1687
|
+
wikilinks,
|
|
1688
|
+
related_ids: [],
|
|
1689
|
+
embedding_model: embeddingModel,
|
|
1690
|
+
};
|
|
1691
|
+
await store.insert(item);
|
|
1692
|
+
return { id: item.id, type: 'notes', title: item.properties.title };
|
|
1693
|
+
}
|
|
1694
|
+
// Unknown file type — fallback: store filename as note
|
|
1695
|
+
const path = await import('path');
|
|
1696
|
+
const now = new Date().toISOString();
|
|
1697
|
+
const title = titleOverride ?? path.default.basename(filePath);
|
|
1698
|
+
const item = {
|
|
1699
|
+
id: ulid(),
|
|
1700
|
+
type: 'notes',
|
|
1701
|
+
source_id: `file:${filePath}`,
|
|
1702
|
+
content: `File: ${filePath}`,
|
|
1703
|
+
content_hash: createHash('sha256').update(filePath).digest('hex'),
|
|
1704
|
+
properties: {
|
|
1705
|
+
title,
|
|
1706
|
+
tags: tagsOverride,
|
|
1707
|
+
created_at: now,
|
|
1708
|
+
ingested_at: now,
|
|
1709
|
+
source_url: normalUri,
|
|
1710
|
+
},
|
|
1711
|
+
wikilinks: [],
|
|
1712
|
+
related_ids: [],
|
|
1713
|
+
embedding_model: embeddingModel,
|
|
1714
|
+
};
|
|
1715
|
+
await store.insert(item);
|
|
1716
|
+
return { id: item.id, type: 'notes', title };
|
|
1717
|
+
}
|
|
1718
|
+
// ── Generic HTTP URL ─────────────────────────────────────────────────────────
|
|
1719
|
+
if (normalUri.startsWith('http://') || normalUri.startsWith('https://')) {
|
|
1720
|
+
let content = `URL: ${normalUri}`;
|
|
1721
|
+
let autoTitle = normalUri;
|
|
1722
|
+
try {
|
|
1723
|
+
const res = await fetch(normalUri, {
|
|
1724
|
+
headers: { 'User-Agent': 'EngramMCP/0.2 (semantic memory ingestion)' },
|
|
1725
|
+
signal: AbortSignal.timeout(10_000),
|
|
1726
|
+
});
|
|
1727
|
+
const html = await res.text();
|
|
1728
|
+
// Extract <title>
|
|
1729
|
+
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
1730
|
+
if (titleMatch)
|
|
1731
|
+
autoTitle = titleMatch[1].trim();
|
|
1732
|
+
// Naive text extraction: strip tags
|
|
1733
|
+
content = html.replace(/<[^>]+>/g, ' ').replace(/\s{2,}/g, ' ').slice(0, 5000);
|
|
1734
|
+
}
|
|
1735
|
+
catch {
|
|
1736
|
+
// fetch failed — just store URL as note
|
|
1737
|
+
}
|
|
1738
|
+
const now = new Date().toISOString();
|
|
1739
|
+
const item = {
|
|
1740
|
+
id: ulid(),
|
|
1741
|
+
type: forceType ?? 'notes',
|
|
1742
|
+
source_id: `url:${normalUri}`,
|
|
1743
|
+
content,
|
|
1744
|
+
content_hash: createHash('sha256').update(content).digest('hex'),
|
|
1745
|
+
properties: {
|
|
1746
|
+
title: titleOverride ?? autoTitle,
|
|
1747
|
+
tags: tagsOverride,
|
|
1748
|
+
created_at: now,
|
|
1749
|
+
ingested_at: now,
|
|
1750
|
+
source_url: normalUri,
|
|
1751
|
+
},
|
|
1752
|
+
wikilinks: [],
|
|
1753
|
+
related_ids: [],
|
|
1754
|
+
embedding_model: embeddingModel,
|
|
1755
|
+
};
|
|
1756
|
+
await store.insert(item);
|
|
1757
|
+
return { id: item.id, type: item.type, title: item.properties.title };
|
|
1758
|
+
}
|
|
1759
|
+
throw new Error(`Cannot determine ingest route for URI: ${uri}`);
|
|
1760
|
+
}
|
|
1761
|
+
//# sourceMappingURL=tools.js.map
|