index.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | #!/usr/bin/env node |
| 2 | import '../lib/load-env.mjs'; |
| 3 | |
| 4 | /** |
| 5 | * Knowtation CLI — single entry point for search, get-note, list-notes, index, etc. |
| 6 | */ |
| 7 | |
| 8 | import fs from 'fs'; |
| 9 | import path from 'path'; |
| 10 | import { spawn } from 'child_process'; |
| 11 | import { execSync } from 'child_process'; |
| 12 | import { fileURLToPath } from 'url'; |
| 13 | import yaml from 'js-yaml'; |
| 14 | |
| 15 | const __filename = fileURLToPath(import.meta.url); |
| 16 | import { loadConfig } from '../lib/config.mjs'; |
| 17 | import { readNote, resolveVaultRelativePath, noteFileExistsInVault, normalizeMetadataFacets } from '../lib/vault.mjs'; |
| 18 | import { buildNoteOutline } from '../lib/note-outline.mjs'; |
| 19 | import { buildDocumentTree } from '../lib/document-tree.mjs'; |
| 20 | import { readSectionSource } from '../lib/section-source-note.mjs'; |
| 21 | import { noteStateIdFromHubNoteJson, absentNoteStateId } from '../lib/note-state-id.mjs'; |
| 22 | import { runListNotes as runListNotesOp } from '../lib/list-notes.mjs'; |
| 23 | import { exitWithError } from '../lib/errors.mjs'; |
| 24 | import { IMPORT_SOURCE_TYPES, IMPORT_SOURCE_TYPES_HELP } from '../lib/import-source-types.mjs'; |
| 25 | |
| 26 | const args = process.argv.slice(2); |
| 27 | const subcommand = args[0]; |
| 28 | const useJson = args.includes('--json'); |
| 29 | |
| 30 | const help = ` |
| 31 | knowtation — personal knowledge and content system (know + notation) |
| 32 | |
| 33 | Usage: |
| 34 | knowtation <command> [options] |
| 35 | |
| 36 | Commands: |
| 37 | search <query> Semantic search over vault (default), or --keyword for literal text. Use --project, --tag, --folder, --limit. --json for machine output. |
| 38 | get-note <path> Return full content of one note by path. Use --body-only, --frontmatter-only, --json. |
| 39 | get-note-outline <path> Return a derived Markdown heading outline for one note. Requires --json. |
| 40 | get-document-tree <path> Return a derived Markdown heading tree for one note. Requires --json. |
| 41 | get-metadata-facets <path> Return bounded body-free metadata facets for one note. Requires --json. |
| 42 | get-section-source <path> Return body-free SectionSource metadata for one note. Requires --json. |
| 43 | list-notes List notes. Use --folder, --project, --tag, --limit, --offset, --fields, --count-only, --json. |
| 44 | index Re-run indexer: vault → chunk → embed → vector store (Qdrant or sqlite-vec). |
| 45 | write <path> Create or overwrite a note. Use --stdin for body, --frontmatter k=v, --append. |
| 46 | export <path|query> <output> Export note(s) to dir/file. Use --format, --project. Provenance and AIR per spec. |
| 47 | import <source-type> <input> Ingest from ChatGPT, Claude, Mem0, etc. See docs/IMPORT-SOURCES.md. |
| 48 | memory <action> Memory layer commands: query, list, store, search, clear, export, stats. Requires memory.enabled. |
| 49 | hub status Check Hub reachability (use --hub <url>). Requires Hub API. |
| 50 | doctor Local vault + optional Hub API checks (token discipline per docs/TOKEN-SAVINGS.md). Options: --json, --hub <url>. |
| 51 | propose <path> Create a proposal from local vault note (body/frontmatter) on the Hub. Options: --hub, --intent, --vault (X-Vault-Id), --external-ref, --labels a,b, --source agent|human|import, --base-state-id, --no-fetch-base. |
| 52 | vault sync Commit and push vault to Git (when vault.git.enabled and remote set). See config. |
| 53 | mcp Start MCP server (stdio transport). For Cursor/Claude Desktop. |
| 54 | daemon <action> Background consolidation daemon: start [--background], stop, status, log. |
| 55 | |
| 56 | Options (global): |
| 57 | --help, -h Show this help or command-specific help. |
| 58 | --json Output JSON for piping to other tools. |
| 59 | |
| 60 | Config: config/local.yaml or env (KNOWTATION_VAULT_PATH). Full spec: docs/SPEC.md. |
| 61 | `; |
| 62 | |
| 63 | function getOpt(name, type = 'string') { |
| 64 | const i = args.indexOf('--' + name); |
| 65 | if (i === -1 || !args[i + 1]) return null; |
| 66 | const v = args[i + 1]; |
| 67 | return type === 'number' ? parseInt(v, 10) : v; |
| 68 | } |
| 69 | |
| 70 | function hasOpt(name) { |
| 71 | return args.includes('--' + name); |
| 72 | } |
| 73 | |
| 74 | function runGetNote() { |
| 75 | const pathArg = args.find((a, i) => i >= 1 && !a.startsWith('--')); |
| 76 | if (!pathArg) { |
| 77 | exitWithError('knowtation get-note: provide a note path.', 1, useJson); |
| 78 | } |
| 79 | const bodyOnly = hasOpt('body-only'); |
| 80 | const frontmatterOnly = hasOpt('frontmatter-only'); |
| 81 | if (bodyOnly && frontmatterOnly) { |
| 82 | exitWithError('knowtation get-note: use only one of --body-only or --frontmatter-only.', 1, useJson); |
| 83 | } |
| 84 | |
| 85 | let config; |
| 86 | try { |
| 87 | config = loadConfig(); |
| 88 | } catch (e) { |
| 89 | exitWithError(e.message, 2, useJson); |
| 90 | } |
| 91 | |
| 92 | try { |
| 93 | resolveVaultRelativePath(config.vault_path, pathArg); |
| 94 | } catch (e) { |
| 95 | exitWithError(e.message, 2, useJson); |
| 96 | } |
| 97 | |
| 98 | let note; |
| 99 | try { |
| 100 | note = readNote(config.vault_path, pathArg); |
| 101 | } catch (e) { |
| 102 | exitWithError(e.message, 2, useJson); |
| 103 | } |
| 104 | |
| 105 | if (useJson) { |
| 106 | if (bodyOnly) { |
| 107 | console.log(JSON.stringify({ path: note.path, body: note.body })); |
| 108 | } else if (frontmatterOnly) { |
| 109 | console.log(JSON.stringify({ path: note.path, frontmatter: note.frontmatter })); |
| 110 | } else { |
| 111 | console.log(JSON.stringify({ path: note.path, frontmatter: note.frontmatter, body: note.body })); |
| 112 | } |
| 113 | } else { |
| 114 | if (bodyOnly) { |
| 115 | process.stdout.write(note.body + (note.body ? '\n' : '')); |
| 116 | } else if (frontmatterOnly) { |
| 117 | console.log(JSON.stringify(note.frontmatter, null, 2)); |
| 118 | } else { |
| 119 | console.log('---'); |
| 120 | console.log(yaml.dump(note.frontmatter).trimEnd()); |
| 121 | console.log('---'); |
| 122 | if (note.body) console.log(note.body); |
| 123 | } |
| 124 | } |
| 125 | process.exit(0); |
| 126 | } |
| 127 | |
| 128 | function runGetNoteOutline() { |
| 129 | const pathArg = args.find((a, i) => i >= 1 && !a.startsWith('--')); |
| 130 | if (!pathArg) { |
| 131 | exitWithError('knowtation get-note-outline: provide a note path.', 1, useJson); |
| 132 | } |
| 133 | if (!useJson) { |
| 134 | exitWithError('knowtation get-note-outline: --json is required in this MVP.', 1, useJson); |
| 135 | } |
| 136 | |
| 137 | let config; |
| 138 | try { |
| 139 | config = loadConfig(); |
| 140 | } catch (e) { |
| 141 | exitWithError(e.message, 2, useJson); |
| 142 | } |
| 143 | |
| 144 | try { |
| 145 | resolveVaultRelativePath(config.vault_path, pathArg); |
| 146 | } catch (e) { |
| 147 | exitWithError(e.message, 2, useJson); |
| 148 | } |
| 149 | |
| 150 | let note; |
| 151 | try { |
| 152 | note = readNote(config.vault_path, pathArg); |
| 153 | } catch (e) { |
| 154 | exitWithError(e.message, 2, useJson); |
| 155 | } |
| 156 | |
| 157 | try { |
| 158 | console.log(JSON.stringify(buildNoteOutline(note))); |
| 159 | } catch (e) { |
| 160 | exitWithError(e.message, 2, useJson); |
| 161 | } |
| 162 | process.exit(0); |
| 163 | } |
| 164 | |
| 165 | function runGetDocumentTree() { |
| 166 | const pathArg = args.find((a, i) => i >= 1 && !a.startsWith('--')); |
| 167 | if (!pathArg) { |
| 168 | exitWithError('knowtation get-document-tree: provide a note path.', 1, useJson); |
| 169 | } |
| 170 | if (!useJson) { |
| 171 | exitWithError('knowtation get-document-tree: --json is required in this MVP.', 1, useJson); |
| 172 | } |
| 173 | |
| 174 | let config; |
| 175 | try { |
| 176 | config = loadConfig(); |
| 177 | } catch (e) { |
| 178 | exitWithError(e.message, 2, useJson); |
| 179 | } |
| 180 | |
| 181 | try { |
| 182 | resolveVaultRelativePath(config.vault_path, pathArg); |
| 183 | } catch (e) { |
| 184 | exitWithError(e.message, 2, useJson); |
| 185 | } |
| 186 | |
| 187 | let note; |
| 188 | try { |
| 189 | note = readNote(config.vault_path, pathArg); |
| 190 | } catch (e) { |
| 191 | exitWithError(e.message, 2, useJson); |
| 192 | } |
| 193 | |
| 194 | try { |
| 195 | console.log(JSON.stringify(buildDocumentTree(note))); |
| 196 | } catch (e) { |
| 197 | exitWithError(e.message, 2, useJson); |
| 198 | } |
| 199 | process.exit(0); |
| 200 | } |
| 201 | |
| 202 | function runGetMetadataFacets() { |
| 203 | const pathArg = args.find((a, i) => i >= 1 && !a.startsWith('--')); |
| 204 | if (!pathArg) { |
| 205 | exitWithError('knowtation get-metadata-facets: provide a note path.', 1, useJson); |
| 206 | } |
| 207 | if (!useJson) { |
| 208 | exitWithError('knowtation get-metadata-facets: --json is required.', 1, useJson); |
| 209 | } |
| 210 | |
| 211 | let config; |
| 212 | try { |
| 213 | config = loadConfig(); |
| 214 | } catch (e) { |
| 215 | exitWithError(e.message, 2, useJson); |
| 216 | } |
| 217 | |
| 218 | try { |
| 219 | resolveVaultRelativePath(config.vault_path, pathArg); |
| 220 | } catch (e) { |
| 221 | exitWithError(e.message, 2, useJson); |
| 222 | } |
| 223 | |
| 224 | let note; |
| 225 | try { |
| 226 | note = readNote(config.vault_path, pathArg); |
| 227 | } catch (e) { |
| 228 | exitWithError(e.message, 2, useJson); |
| 229 | } |
| 230 | |
| 231 | try { |
| 232 | console.log(JSON.stringify(normalizeMetadataFacets(note.path, note.frontmatter))); |
| 233 | } catch (e) { |
| 234 | exitWithError(e.message, 2, useJson); |
| 235 | } |
| 236 | process.exit(0); |
| 237 | } |
| 238 | |
| 239 | function runGetSectionSource() { |
| 240 | const pathArgs = args.filter((a, i) => i >= 1 && !a.startsWith('--')); |
| 241 | const pathArg = pathArgs[0]; |
| 242 | if (!pathArg) { |
| 243 | exitWithError('knowtation get-section-source: provide a note path.', 1, useJson); |
| 244 | } |
| 245 | if (pathArgs.length > 1) { |
| 246 | exitWithError('knowtation get-section-source: provide exactly one note path.', 1, useJson); |
| 247 | } |
| 248 | if (!useJson) { |
| 249 | exitWithError('knowtation get-section-source: --json is required.', 1, useJson); |
| 250 | } |
| 251 | |
| 252 | let config; |
| 253 | try { |
| 254 | config = loadConfig(); |
| 255 | } catch (e) { |
| 256 | exitWithError(e.message, 2, useJson); |
| 257 | } |
| 258 | |
| 259 | try { |
| 260 | console.log(JSON.stringify(readSectionSource(config.vault_path, pathArg))); |
| 261 | } catch (e) { |
| 262 | exitWithError(e.message, 2, useJson); |
| 263 | } |
| 264 | process.exit(0); |
| 265 | } |
| 266 | |
| 267 | function runListNotes() { |
| 268 | const folder = getOpt('folder'); |
| 269 | const project = getOpt('project'); |
| 270 | const tag = getOpt('tag'); |
| 271 | const since = getOpt('since'); |
| 272 | const until = getOpt('until'); |
| 273 | const chain = getOpt('chain'); |
| 274 | const entity = getOpt('entity'); |
| 275 | const episode = getOpt('episode'); |
| 276 | let limit = getOpt('limit', 'number') ?? 20; |
| 277 | let offset = getOpt('offset', 'number') ?? 0; |
| 278 | if (typeof limit === 'number' && (limit < 0 || limit > 100)) { |
| 279 | exitWithError('knowtation list-notes: --limit must be between 0 and 100.', 1, useJson); |
| 280 | } |
| 281 | if (typeof offset === 'number' && offset < 0) { |
| 282 | exitWithError('knowtation list-notes: --offset must be non-negative.', 1, useJson); |
| 283 | } |
| 284 | limit = Math.min(100, Math.max(0, limit ?? 20)); |
| 285 | offset = Math.max(0, offset ?? 0); |
| 286 | const order = getOpt('order') || 'date'; |
| 287 | const fields = getOpt('fields') || 'path+metadata'; |
| 288 | const countOnly = hasOpt('count-only'); |
| 289 | |
| 290 | let config; |
| 291 | try { |
| 292 | config = loadConfig(); |
| 293 | } catch (e) { |
| 294 | exitWithError(e.message, 2, useJson); |
| 295 | } |
| 296 | |
| 297 | const out = runListNotesOp(config, { |
| 298 | folder: folder ?? undefined, |
| 299 | project: project ?? undefined, |
| 300 | tag: tag ?? undefined, |
| 301 | since: since ?? undefined, |
| 302 | until: until ?? undefined, |
| 303 | chain: chain ?? undefined, |
| 304 | entity: entity ?? undefined, |
| 305 | episode: episode ?? undefined, |
| 306 | limit, |
| 307 | offset, |
| 308 | order, |
| 309 | fields, |
| 310 | countOnly, |
| 311 | }); |
| 312 | |
| 313 | if (countOnly) { |
| 314 | if (useJson) { |
| 315 | console.log(JSON.stringify({ total: out.total })); |
| 316 | } else { |
| 317 | console.log(out.total); |
| 318 | } |
| 319 | process.exit(0); |
| 320 | } |
| 321 | |
| 322 | if (useJson) { |
| 323 | console.log(JSON.stringify({ notes: out.notes, total: out.total })); |
| 324 | } else { |
| 325 | for (const n of out.notes) { |
| 326 | const meta = [n.project, n.tags?.join?.(', ') ?? (n.tags || []).join(', '), n.date].filter(Boolean).join(' | '); |
| 327 | console.log(n.path + (meta ? ` ${meta}` : '')); |
| 328 | } |
| 329 | } |
| 330 | process.exit(0); |
| 331 | } |
| 332 | |
| 333 | async function main() { |
| 334 | if (!subcommand || subcommand === '--help' || subcommand === '-h') { |
| 335 | console.log(help.trim()); |
| 336 | process.exit(0); |
| 337 | } |
| 338 | |
| 339 | if (subcommand === 'get-note') { |
| 340 | if (hasOpt('help') || hasOpt('h')) { |
| 341 | console.log('knowtation get-note <path>\n Options: --json, --body-only, --frontmatter-only'); |
| 342 | process.exit(0); |
| 343 | } |
| 344 | runGetNote(); |
| 345 | } |
| 346 | |
| 347 | if (subcommand === 'get-note-outline') { |
| 348 | if (hasOpt('help') || hasOpt('h')) { |
| 349 | console.log('knowtation get-note-outline <path>\n Options: --json (required)'); |
| 350 | process.exit(0); |
| 351 | } |
| 352 | runGetNoteOutline(); |
| 353 | } |
| 354 | |
| 355 | if (subcommand === 'get-document-tree') { |
| 356 | if (hasOpt('help') || hasOpt('h')) { |
| 357 | console.log('knowtation get-document-tree <path>\n Options: --json (required)'); |
| 358 | process.exit(0); |
| 359 | } |
| 360 | runGetDocumentTree(); |
| 361 | } |
| 362 | |
| 363 | if (subcommand === 'get-metadata-facets') { |
| 364 | if (hasOpt('help') || hasOpt('h')) { |
| 365 | console.log('knowtation get-metadata-facets <path>\n Options: --json (required)'); |
| 366 | process.exit(0); |
| 367 | } |
| 368 | runGetMetadataFacets(); |
| 369 | } |
| 370 | |
| 371 | if (subcommand === 'get-section-source') { |
| 372 | if (hasOpt('help') || hasOpt('h')) { |
| 373 | console.log('knowtation get-section-source <path>\n Options: --json (required)'); |
| 374 | process.exit(0); |
| 375 | } |
| 376 | runGetSectionSource(); |
| 377 | } |
| 378 | |
| 379 | if (subcommand === 'list-notes') { |
| 380 | if (hasOpt('help') || hasOpt('h')) { |
| 381 | console.log('knowtation list-notes\n Options: --folder, --project, --tag, --since, --until, --chain, --entity, --episode, --limit, --offset, --order date|date-asc, --fields path|path+metadata|full, --count-only, --json'); |
| 382 | process.exit(0); |
| 383 | } |
| 384 | runListNotes(); |
| 385 | } |
| 386 | |
| 387 | if (subcommand === 'search') { |
| 388 | if (hasOpt('help') || hasOpt('h')) { |
| 389 | console.log('knowtation search <query>\n Options: --keyword (substring/token search), --match phrase|all-terms (with --keyword), --folder, --project, --tag, --since, --until, --chain, --entity, --episode, --content-scope all|notes|approval_logs, --order date|date-asc, --limit, --fields path|path+snippet|full, --snippet-chars <n>, --count-only, --json'); |
| 390 | process.exit(0); |
| 391 | } |
| 392 | const query = args.slice(1).filter((a) => !a.startsWith('--')).join(' ').trim(); |
| 393 | if (!query) { |
| 394 | exitWithError('knowtation search: provide a query string.', 1, useJson); |
| 395 | } |
| 396 | const folder = getOpt('folder'); |
| 397 | const project = getOpt('project'); |
| 398 | const tag = getOpt('tag'); |
| 399 | const since = getOpt('since'); |
| 400 | const until = getOpt('until'); |
| 401 | const chain = getOpt('chain'); |
| 402 | const entity = getOpt('entity'); |
| 403 | const episode = getOpt('episode'); |
| 404 | const order = getOpt('order'); |
| 405 | let limit = getOpt('limit', 'number') ?? 10; |
| 406 | if (typeof limit === 'number' && (limit < 0 || limit > 100)) { |
| 407 | exitWithError('knowtation search: --limit must be between 0 and 100.', 1, useJson); |
| 408 | } |
| 409 | limit = Math.min(100, Math.max(0, limit ?? 10)); |
| 410 | const fields = getOpt('fields') || 'path+snippet'; |
| 411 | const snippetChars = getOpt('snippet-chars', 'number'); |
| 412 | const countOnly = hasOpt('count-only'); |
| 413 | const useKeyword = hasOpt('keyword'); |
| 414 | const matchRaw = getOpt('match'); |
| 415 | const contentScope = getOpt('content-scope'); |
| 416 | const validFields = ['path', 'path+snippet', 'full']; |
| 417 | if (fields && !validFields.includes(fields)) { |
| 418 | exitWithError(`knowtation search: --fields must be one of ${validFields.join(', ')}.`, 1, useJson); |
| 419 | } |
| 420 | if (matchRaw && !useKeyword) { |
| 421 | exitWithError('knowtation search: --match is only valid with --keyword.', 1, useJson); |
| 422 | } |
| 423 | let match = 'phrase'; |
| 424 | if (matchRaw) { |
| 425 | if (matchRaw === 'all-terms' || matchRaw === 'all_terms') match = 'all_terms'; |
| 426 | else if (matchRaw === 'phrase') match = 'phrase'; |
| 427 | else exitWithError('knowtation search: --match must be phrase or all-terms.', 1, useJson); |
| 428 | } |
| 429 | const validScopes = ['all', 'notes', 'approval_logs']; |
| 430 | if (contentScope && !validScopes.includes(contentScope)) { |
| 431 | exitWithError(`knowtation search: --content-scope must be one of ${validScopes.join(', ')}.`, 1, useJson); |
| 432 | } |
| 433 | (async () => { |
| 434 | try { |
| 435 | const config = loadConfig(); |
| 436 | const baseOpts = { |
| 437 | folder: folder ?? undefined, |
| 438 | project: project ?? undefined, |
| 439 | tag: tag ?? undefined, |
| 440 | since: since ?? undefined, |
| 441 | until: until ?? undefined, |
| 442 | chain: chain ?? undefined, |
| 443 | entity: entity ?? undefined, |
| 444 | episode: episode ?? undefined, |
| 445 | order: order ?? undefined, |
| 446 | limit, |
| 447 | fields: fields || 'path+snippet', |
| 448 | snippetChars: snippetChars ?? 300, |
| 449 | countOnly, |
| 450 | content_scope: contentScope === 'all' ? undefined : contentScope ?? undefined, |
| 451 | }; |
| 452 | let out; |
| 453 | if (useKeyword) { |
| 454 | const { runKeywordSearch } = await import('../lib/keyword-search.mjs'); |
| 455 | out = await runKeywordSearch(query, { ...baseOpts, match }, config); |
| 456 | } else { |
| 457 | const { runSearch } = await import('../lib/search.mjs'); |
| 458 | out = await runSearch(query, baseOpts, config); |
| 459 | } |
| 460 | if (config.memory?.enabled) { |
| 461 | try { |
| 462 | const { createMemoryManager } = await import('../lib/memory.mjs'); |
| 463 | const mm = createMemoryManager(config); |
| 464 | if (mm.shouldCapture('search')) { |
| 465 | mm.store('search', { |
| 466 | query: out.query, |
| 467 | mode: useKeyword ? 'keyword' : 'semantic', |
| 468 | paths: (out.results || []).map((r) => r.path), |
| 469 | count: out.count ?? (out.results || []).length, |
| 470 | }); |
| 471 | } |
| 472 | } catch (_) {} |
| 473 | } |
| 474 | if (useJson) { |
| 475 | console.log(JSON.stringify(out)); |
| 476 | } else { |
| 477 | if (out.count !== undefined) { |
| 478 | console.log(out.count); |
| 479 | } else { |
| 480 | const list = out.results || []; |
| 481 | for (const r of list) { |
| 482 | const meta = [r.project, r.tags?.join(', ')].filter(Boolean).join(' | '); |
| 483 | const line = r.snippet != null ? `${r.path}\t${r.snippet}` : r.path; |
| 484 | console.log(line + (meta ? ` ${meta}` : '')); |
| 485 | } |
| 486 | } |
| 487 | } |
| 488 | process.exit(0); |
| 489 | } catch (e) { |
| 490 | exitWithError(e.message || String(e), 2, useJson); |
| 491 | } |
| 492 | })(); |
| 493 | return; |
| 494 | } |
| 495 | |
| 496 | if (subcommand === 'index') { |
| 497 | if (hasOpt('help') || hasOpt('h')) { |
| 498 | console.log('knowtation index\n Re-run indexer: vault → chunk → embed → vector store. Reads config; exit 0 on success, 2 on failure.'); |
| 499 | process.exit(0); |
| 500 | } |
| 501 | const { runIndex } = await import('../lib/indexer.mjs'); |
| 502 | try { |
| 503 | const t0 = Date.now(); |
| 504 | const result = await runIndex(); |
| 505 | const config = loadConfig(); |
| 506 | if (config.memory?.enabled) { |
| 507 | try { |
| 508 | const { createMemoryManager } = await import('../lib/memory.mjs'); |
| 509 | const mm = createMemoryManager(config); |
| 510 | if (mm.shouldCapture('index')) { |
| 511 | mm.store('index', { |
| 512 | notes_processed: result.notesProcessed, |
| 513 | chunks_indexed: result.chunksIndexed, |
| 514 | duration_ms: Date.now() - t0, |
| 515 | }); |
| 516 | } |
| 517 | } catch (_) {} |
| 518 | } |
| 519 | if (useJson) { |
| 520 | console.log(JSON.stringify({ ok: true, notesProcessed: result.notesProcessed, chunksIndexed: result.chunksIndexed })); |
| 521 | } |
| 522 | process.exit(0); |
| 523 | } catch (e) { |
| 524 | exitWithError(e.message, 2, useJson); |
| 525 | } |
| 526 | } |
| 527 | |
| 528 | if (subcommand === 'write') { |
| 529 | if (hasOpt('help') || hasOpt('h')) { |
| 530 | console.log('knowtation write <path> [content]\n Options: --stdin (body from stdin), --frontmatter k=v [k2=v2 ...], --append, --json'); |
| 531 | process.exit(0); |
| 532 | } |
| 533 | const pathArg = args.find((a, i) => i >= 1 && !a.startsWith('--')); |
| 534 | if (!pathArg) { |
| 535 | exitWithError('knowtation write: provide a note path.', 1, useJson); |
| 536 | } |
| 537 | const stdin = hasOpt('stdin'); |
| 538 | const append = hasOpt('append'); |
| 539 | const frontmatterPairs = []; |
| 540 | for (let i = 0; i < args.length; i++) { |
| 541 | if (args[i] === '--frontmatter' && args[i + 1]) { |
| 542 | let j = i + 1; |
| 543 | while (j < args.length && !args[j].startsWith('--') && args[j].includes('=')) { |
| 544 | frontmatterPairs.push(args[j]); |
| 545 | j++; |
| 546 | } |
| 547 | break; |
| 548 | } |
| 549 | } |
| 550 | const frontmatterOverrides = {}; |
| 551 | for (const p of frontmatterPairs) { |
| 552 | const eq = p.indexOf('='); |
| 553 | if (eq > 0) { |
| 554 | frontmatterOverrides[p.slice(0, eq).trim()] = p.slice(eq + 1).trim(); |
| 555 | } |
| 556 | } |
| 557 | let body; |
| 558 | if (stdin) { |
| 559 | body = fs.readFileSync(0, 'utf8'); |
| 560 | } else { |
| 561 | const contentArg = args[args.indexOf(pathArg) + 1]; |
| 562 | body = contentArg && !contentArg.startsWith('--') ? contentArg : undefined; |
| 563 | } |
| 564 | let config; |
| 565 | try { |
| 566 | config = loadConfig(); |
| 567 | } catch (e) { |
| 568 | exitWithError(e.message, 2, useJson); |
| 569 | } |
| 570 | (async () => { |
| 571 | try { |
| 572 | const { writeNote } = await import('../lib/write.mjs'); |
| 573 | const result = await writeNote(config.vault_path, pathArg, { |
| 574 | body, |
| 575 | frontmatter: Object.keys(frontmatterOverrides).length ? frontmatterOverrides : undefined, |
| 576 | append, |
| 577 | config, |
| 578 | }); |
| 579 | try { |
| 580 | const { maybeAutoSync } = await import('../lib/vault-git-sync.mjs'); |
| 581 | maybeAutoSync(config); |
| 582 | } catch (_) {} |
| 583 | if (config.memory?.enabled) { |
| 584 | try { |
| 585 | const { createMemoryManager } = await import('../lib/memory.mjs'); |
| 586 | const mm = createMemoryManager(config); |
| 587 | if (mm.shouldCapture('write')) { |
| 588 | mm.store('write', { |
| 589 | path: result.path, |
| 590 | action: append ? 'append' : 'create', |
| 591 | air_id: result.air_id || undefined, |
| 592 | }); |
| 593 | } |
| 594 | } catch (_) {} |
| 595 | } |
| 596 | if (useJson) { |
| 597 | console.log(JSON.stringify(result)); |
| 598 | } else { |
| 599 | console.log(`Written: ${result.path}`); |
| 600 | } |
| 601 | process.exit(0); |
| 602 | } catch (e) { |
| 603 | exitWithError(e.message, 2, useJson); |
| 604 | } |
| 605 | })(); |
| 606 | return; |
| 607 | } |
| 608 | |
| 609 | if (subcommand === 'export') { |
| 610 | if (hasOpt('help') || hasOpt('h')) { |
| 611 | console.log('knowtation export <path-or-query> <output-dir-or-file>\n Options: --format md|html, --project <slug>, --json'); |
| 612 | process.exit(0); |
| 613 | } |
| 614 | const pathOrQuery = args[1]; |
| 615 | const output = args[2]; |
| 616 | if (!pathOrQuery || !output) { |
| 617 | exitWithError('knowtation export: provide <path-or-query> and <output-dir-or-file>.', 1, useJson); |
| 618 | } |
| 619 | const format = getOpt('format') || 'md'; |
| 620 | const project = getOpt('project'); |
| 621 | if (format && !['md', 'html'].includes(format)) { |
| 622 | exitWithError('knowtation export: --format must be md or html.', 1, useJson); |
| 623 | } |
| 624 | let config; |
| 625 | try { |
| 626 | config = loadConfig(); |
| 627 | } catch (e) { |
| 628 | exitWithError(e.message, 2, useJson); |
| 629 | } |
| 630 | (async () => { |
| 631 | try { |
| 632 | const { exportNotes } = await import('../lib/export.mjs'); |
| 633 | const { attestBeforeExport } = await import('../lib/air.mjs'); |
| 634 | let paths = []; |
| 635 | const looksLikePath = !pathOrQuery.includes(' ') && (pathOrQuery.endsWith('.md') || pathOrQuery.includes('/')); |
| 636 | if (looksLikePath) { |
| 637 | try { |
| 638 | resolveVaultRelativePath(config.vault_path, pathOrQuery); |
| 639 | paths = [pathOrQuery]; |
| 640 | } catch (_) { |
| 641 | // Fall through: treat as query |
| 642 | } |
| 643 | } |
| 644 | if (paths.length === 0) { |
| 645 | const { runSearch } = await import('../lib/search.mjs'); |
| 646 | const result = await runSearch(pathOrQuery, { |
| 647 | limit: 50, |
| 648 | project: project ?? undefined, |
| 649 | fields: 'path', |
| 650 | }); |
| 651 | paths = (result.results || []).map((r) => r.path).filter(Boolean); |
| 652 | } |
| 653 | if (!paths.length) { |
| 654 | exitWithError('knowtation export: no notes found for path or query.', 2, useJson); |
| 655 | } |
| 656 | if (config.air?.enabled) { |
| 657 | await attestBeforeExport(config, paths); |
| 658 | } |
| 659 | const result = exportNotes(config.vault_path, paths, output, { format }); |
| 660 | if (config.memory?.enabled) { |
| 661 | try { |
| 662 | const { createMemoryManager } = await import('../lib/memory.mjs'); |
| 663 | const mm = createMemoryManager(config); |
| 664 | if (mm.shouldCapture('export')) { |
| 665 | mm.store('export', { provenance: result.provenance, exported: result.exported, format }); |
| 666 | } |
| 667 | } catch (_) {} |
| 668 | } |
| 669 | if (useJson) { |
| 670 | console.log(JSON.stringify({ exported: result.exported, provenance: result.provenance })); |
| 671 | } else { |
| 672 | for (const e of result.exported) { |
| 673 | console.log(`${e.path} → ${e.output}`); |
| 674 | } |
| 675 | if (result.provenance) console.log(result.provenance); |
| 676 | } |
| 677 | process.exit(0); |
| 678 | } catch (e) { |
| 679 | exitWithError(e.message, 2, useJson); |
| 680 | } |
| 681 | })(); |
| 682 | return; |
| 683 | } |
| 684 | |
| 685 | if (subcommand === 'import') { |
| 686 | if (hasOpt('help') || hasOpt('h')) { |
| 687 | console.log( |
| 688 | `knowtation import <source-type> <input>\n Options: --project, --output-dir, --tags t1,t2, --dry-run, --json, --sheets-range 'A1 range' (google-sheets only), --url-mode auto|bookmark|extract (url only)\n Source types: ${IMPORT_SOURCE_TYPES_HELP}` |
| 689 | ); |
| 690 | process.exit(0); |
| 691 | } |
| 692 | const sourceType = args[1]; |
| 693 | const input = args[2]; |
| 694 | if (!sourceType) { |
| 695 | exitWithError('knowtation import: provide <source-type> and <input>. See docs/IMPORT-SOURCES.md.', 1, useJson); |
| 696 | } |
| 697 | if (sourceType !== 'google-sheets' && !input) { |
| 698 | exitWithError('knowtation import: provide <source-type> and <input>. See docs/IMPORT-SOURCES.md.', 1, useJson); |
| 699 | } |
| 700 | if (sourceType === 'google-sheets' && !input) { |
| 701 | exitWithError( |
| 702 | 'knowtation import google-sheets: provide the spreadsheet id as <input> (the id from the Google Sheets URL).', |
| 703 | 1, |
| 704 | useJson, |
| 705 | ); |
| 706 | } |
| 707 | if (!IMPORT_SOURCE_TYPES.includes(sourceType)) { |
| 708 | exitWithError(`Unknown source-type "${sourceType}". Valid: ${IMPORT_SOURCE_TYPES_HELP}.`, 1, useJson); |
| 709 | } |
| 710 | (async () => { |
| 711 | try { |
| 712 | const config = loadConfig(); |
| 713 | const { runImport } = await import('../lib/import.mjs'); |
| 714 | const project = getOpt('project'); |
| 715 | const outputDir = getOpt('output-dir'); |
| 716 | const tagsOpt = getOpt('tags'); |
| 717 | const tags = tagsOpt ? tagsOpt.split(',').map((t) => t.trim()).filter(Boolean) : []; |
| 718 | const dryRun = hasOpt('dry-run'); |
| 719 | let memoryManager; |
| 720 | if (config.memory?.enabled && !dryRun) { |
| 721 | try { |
| 722 | const { createMemoryManager } = await import('../lib/memory.mjs'); |
| 723 | memoryManager = createMemoryManager(config); |
| 724 | } catch (_) {} |
| 725 | } |
| 726 | |
| 727 | const urlModeRaw = getOpt('url-mode'); |
| 728 | let urlMode; |
| 729 | if (urlModeRaw) { |
| 730 | const v = String(urlModeRaw).trim().toLowerCase(); |
| 731 | if (v !== 'auto' && v !== 'bookmark' && v !== 'extract') { |
| 732 | exitWithError(`Invalid --url-mode "${urlModeRaw}". Use auto, bookmark, or extract.`, 1, useJson); |
| 733 | } |
| 734 | urlMode = v; |
| 735 | } |
| 736 | if (urlModeRaw && sourceType !== 'url') { |
| 737 | exitWithError('--url-mode is only valid when source-type is url.', 1, useJson); |
| 738 | } |
| 739 | const sheetsRangeRaw = getOpt('sheets-range'); |
| 740 | if (sheetsRangeRaw && sourceType !== 'google-sheets') { |
| 741 | exitWithError('--sheets-range is only valid when source-type is google-sheets.', 1, useJson); |
| 742 | } |
| 743 | |
| 744 | const importOpts = { |
| 745 | project: project ?? undefined, |
| 746 | outputDir: outputDir ?? undefined, |
| 747 | tags, |
| 748 | dryRun, |
| 749 | ...(sourceType === 'url' && urlMode ? { urlMode } : {}), |
| 750 | ...(sourceType === 'google-sheets' && sheetsRangeRaw |
| 751 | ? { sheetsRange: String(sheetsRangeRaw).trim() } |
| 752 | : {}), |
| 753 | }; |
| 754 | if (memoryManager && sourceType === 'mem0-export' && memoryManager.shouldCapture('capture')) { |
| 755 | importOpts.onMemoryEvent = (data) => { |
| 756 | try { memoryManager.store('capture', data); } catch (_) {} |
| 757 | }; |
| 758 | } |
| 759 | |
| 760 | const result = await runImport(sourceType, input, importOpts); |
| 761 | if (memoryManager) { |
| 762 | try { |
| 763 | if (memoryManager.shouldCapture('import')) { |
| 764 | memoryManager.store('import', { |
| 765 | source_type: sourceType, |
| 766 | count: result.count ?? 0, |
| 767 | paths: (result.imported || []).map((r) => r.path).slice(0, 50), |
| 768 | project: project ?? undefined, |
| 769 | }); |
| 770 | } |
| 771 | } catch (_) {} |
| 772 | } |
| 773 | if (useJson) { |
| 774 | console.log(JSON.stringify({ imported: result.imported, count: result.count })); |
| 775 | } else { |
| 776 | for (const r of result.imported) { |
| 777 | console.log(r.path); |
| 778 | } |
| 779 | if (result.count === 0) { |
| 780 | console.log('No notes imported.'); |
| 781 | } else { |
| 782 | console.log(`Imported ${result.count} note(s).`); |
| 783 | } |
| 784 | } |
| 785 | process.exit(0); |
| 786 | } catch (e) { |
| 787 | exitWithError(e.message, 2, useJson); |
| 788 | } |
| 789 | })(); |
| 790 | return; |
| 791 | } |
| 792 | |
| 793 | if (subcommand === 'mcp') { |
| 794 | if (hasOpt('help') || hasOpt('h')) { |
| 795 | console.log( |
| 796 | 'knowtation mcp\n Start MCP server (default: stdio for Cursor / Claude Desktop).\n Streamable HTTP: MCP_TRANSPORT=http or KNOWTATION_MCP_TRANSPORT=http (see docs/MCP-PHASE-D.md).\n Requires config/local.yaml and KNOWTATION_VAULT_PATH.' |
| 797 | ); |
| 798 | process.exit(0); |
| 799 | } |
| 800 | const serverMod = await import('../mcp/server.mjs'); |
| 801 | return; |
| 802 | } |
| 803 | |
| 804 | if (subcommand === 'memory') { |
| 805 | const action = args[1]; |
| 806 | if (hasOpt('help') || hasOpt('h')) { |
| 807 | console.log(`knowtation memory <action> |
| 808 | Actions: |
| 809 | query <key> Read latest value for an event type (e.g. search, export, write, import, index, propose, user). |
| 810 | list List recent memory events. --type, --topic, --since, --until, --limit (default 20), --json. |
| 811 | store <key> <value> Store a user-defined memory entry. Value is JSON string or --stdin. |
| 812 | search <query> Semantic search over memory (requires vector or mem0 provider). --limit, --json. |
| 813 | clear Clear memory. --type, --before <date>, --confirm required. --json. |
| 814 | export Export memory log. --format jsonl|mif, --since, --until, --type. Output to stdout. |
| 815 | stats Show memory statistics. --json. |
| 816 | index Print lightweight pointer index (markdown). --json returns structured object. |
| 817 | consolidate Run LLM-powered memory consolidation. --dry-run, --passes consolidate,verify,discover, --lookback-hours <n>. --json. |
| 818 | |
| 819 | Options: --json`); |
| 820 | process.exit(0); |
| 821 | } |
| 822 | const validActions = ['query', 'list', 'store', 'search', 'clear', 'export', 'stats', 'index', 'consolidate']; |
| 823 | if (!action || !validActions.includes(action)) { |
| 824 | exitWithError(`knowtation memory: use "memory <action>". Actions: ${validActions.join(', ')}.`, 1, useJson); |
| 825 | } |
| 826 | let config; |
| 827 | try { |
| 828 | config = loadConfig(); |
| 829 | } catch (e) { |
| 830 | exitWithError(e.message, 2, useJson); |
| 831 | } |
| 832 | if (!config.memory?.enabled) { |
| 833 | exitWithError('knowtation memory: memory layer not enabled. Set memory.enabled in config.', 2, useJson); |
| 834 | } |
| 835 | (async () => { |
| 836 | try { |
| 837 | const { createMemoryManager } = await import('../lib/memory.mjs'); |
| 838 | const { MEMORY_EVENT_TYPES } = await import('../lib/memory-event.mjs'); |
| 839 | const scopeOpt = getOpt('scope') === 'global' ? 'global' : undefined; |
| 840 | const mm = createMemoryManager(config, 'default', scopeOpt ? { scope: scopeOpt } : {}); |
| 841 | |
| 842 | if (action === 'query') { |
| 843 | const keyArg = args[2]; |
| 844 | if (!keyArg) { |
| 845 | exitWithError('knowtation memory query: provide a key (event type).', 1, useJson); |
| 846 | } |
| 847 | const key = keyArg.replace(/\s+/g, '_'); |
| 848 | const latest = mm.getLatest(key); |
| 849 | if (!latest) { |
| 850 | if (useJson) console.log(JSON.stringify({ key, value: null })); |
| 851 | else console.log('(no value)'); |
| 852 | } else { |
| 853 | const { id: _id, vault_id: _vid, ...display } = latest; |
| 854 | if (useJson) console.log(JSON.stringify({ key, value: display })); |
| 855 | else console.log(JSON.stringify(display, null, 2)); |
| 856 | } |
| 857 | process.exit(0); |
| 858 | } |
| 859 | |
| 860 | if (action === 'list') { |
| 861 | const type = getOpt('type'); |
| 862 | const topic = getOpt('topic'); |
| 863 | const since = getOpt('since'); |
| 864 | const until = getOpt('until'); |
| 865 | const limit = getOpt('limit', 'number') ?? 20; |
| 866 | const events = mm.list({ type: type ?? undefined, topic: topic ?? undefined, since: since ?? undefined, until: until ?? undefined, limit }); |
| 867 | if (useJson) { |
| 868 | console.log(JSON.stringify({ events, count: events.length })); |
| 869 | } else { |
| 870 | if (events.length === 0) console.log('(no events)'); |
| 871 | for (const e of events) { |
| 872 | const summary = JSON.stringify(e.data).slice(0, 120); |
| 873 | console.log(`${e.ts} ${e.type} ${summary}`); |
| 874 | } |
| 875 | } |
| 876 | process.exit(0); |
| 877 | } |
| 878 | |
| 879 | if (action === 'store') { |
| 880 | const keyArg = args[2]; |
| 881 | if (!keyArg) { |
| 882 | exitWithError('knowtation memory store: provide a key.', 1, useJson); |
| 883 | } |
| 884 | let valueRaw; |
| 885 | if (hasOpt('stdin')) { |
| 886 | valueRaw = fs.readFileSync(0, 'utf8').trim(); |
| 887 | } else { |
| 888 | valueRaw = args[3]; |
| 889 | } |
| 890 | if (!valueRaw) { |
| 891 | exitWithError('knowtation memory store: provide a value (JSON string) or --stdin.', 1, useJson); |
| 892 | } |
| 893 | let value; |
| 894 | try { |
| 895 | value = JSON.parse(valueRaw); |
| 896 | } catch (_) { |
| 897 | value = { text: valueRaw }; |
| 898 | } |
| 899 | const result = mm.store('user', { key: keyArg, ...value }); |
| 900 | if (useJson) console.log(JSON.stringify(result)); |
| 901 | else console.log(`Stored: ${result.id}`); |
| 902 | process.exit(0); |
| 903 | } |
| 904 | |
| 905 | if (action === 'search') { |
| 906 | const query = args.slice(2).filter((a) => !a.startsWith('--')).join(' ').trim(); |
| 907 | if (!query) { |
| 908 | exitWithError('knowtation memory search: provide a query string.', 1, useJson); |
| 909 | } |
| 910 | if (!mm.supportsSearch()) { |
| 911 | exitWithError('knowtation memory search: semantic search requires memory.provider: vector or mem0.', 2, useJson); |
| 912 | } |
| 913 | const limit = getOpt('limit', 'number') ?? 10; |
| 914 | const results = mm.search(query, { limit }); |
| 915 | if (useJson) { |
| 916 | console.log(JSON.stringify({ results, count: results.length })); |
| 917 | } else { |
| 918 | if (results.length === 0) console.log('(no results)'); |
| 919 | for (const r of results) { |
| 920 | console.log(`${r.ts} ${r.type} ${JSON.stringify(r.data).slice(0, 120)}`); |
| 921 | } |
| 922 | } |
| 923 | process.exit(0); |
| 924 | } |
| 925 | |
| 926 | if (action === 'clear') { |
| 927 | if (!hasOpt('confirm')) { |
| 928 | exitWithError('knowtation memory clear: use --confirm to confirm deletion.', 1, useJson); |
| 929 | } |
| 930 | const type = getOpt('type'); |
| 931 | const before = getOpt('before'); |
| 932 | const result = mm.clear({ type: type ?? undefined, before: before ?? undefined }); |
| 933 | if (useJson) console.log(JSON.stringify(result)); |
| 934 | else console.log(`Cleared ${result.cleared} event(s).`); |
| 935 | process.exit(0); |
| 936 | } |
| 937 | |
| 938 | if (action === 'export') { |
| 939 | const format = getOpt('format') || 'jsonl'; |
| 940 | if (!['jsonl', 'mif'].includes(format)) { |
| 941 | exitWithError('knowtation memory export: --format must be jsonl or mif.', 1, useJson); |
| 942 | } |
| 943 | const type = getOpt('type'); |
| 944 | const since = getOpt('since'); |
| 945 | const until = getOpt('until'); |
| 946 | const events = mm.list({ type: type ?? undefined, since: since ?? undefined, until: until ?? undefined, limit: 10000 }); |
| 947 | if (format === 'jsonl') { |
| 948 | for (const e of events) { |
| 949 | console.log(JSON.stringify(e)); |
| 950 | } |
| 951 | } else { |
| 952 | for (const e of events) { |
| 953 | console.log(`---`); |
| 954 | console.log(`id: ${e.id}`); |
| 955 | console.log(`type: ${e.type}`); |
| 956 | console.log(`ts: ${e.ts}`); |
| 957 | console.log(`vault_id: ${e.vault_id}`); |
| 958 | console.log(`---`); |
| 959 | console.log(JSON.stringify(e.data, null, 2)); |
| 960 | console.log(''); |
| 961 | } |
| 962 | } |
| 963 | process.exit(0); |
| 964 | } |
| 965 | |
| 966 | if (action === 'summarize') { |
| 967 | const since = getOpt('since') || new Date(Date.now() - 86_400_000).toISOString(); |
| 968 | const maxTokens = getOpt('max-tokens', 'number') ?? 512; |
| 969 | const dryRun = hasOpt('dry-run'); |
| 970 | try { |
| 971 | const { generateSessionSummary } = await import('../lib/memory-session-summary.mjs'); |
| 972 | const result = await generateSessionSummary(config, { since, maxTokens, dryRun }); |
| 973 | if (useJson) { |
| 974 | console.log(JSON.stringify(result)); |
| 975 | } else { |
| 976 | console.log(result.summary); |
| 977 | if (result.id) console.log(`\nStored as: ${result.id}`); |
| 978 | console.log(`Events summarized: ${result.event_count}`); |
| 979 | } |
| 980 | } catch (e) { |
| 981 | exitWithError(`Session summary failed: ${e.message}`, 2, useJson); |
| 982 | } |
| 983 | process.exit(0); |
| 984 | } |
| 985 | |
| 986 | if (action === 'consolidate') { |
| 987 | const dryRun = hasOpt('dry-run'); |
| 988 | const passesRaw = getOpt('passes', 'string'); |
| 989 | const passes = passesRaw |
| 990 | ? passesRaw.split(',').map((s) => s.trim()).filter(Boolean) |
| 991 | : undefined; |
| 992 | const lookbackHours = getOpt('lookback-hours', 'number') ?? undefined; |
| 993 | try { |
| 994 | const { consolidateMemory } = await import('../lib/memory-consolidate.mjs'); |
| 995 | const result = await consolidateMemory(config, { dryRun, passes, lookbackHours }); |
| 996 | if (useJson) { |
| 997 | console.log(JSON.stringify(result)); |
| 998 | } else if (result.dry_run) { |
| 999 | console.log(`[dry-run] Would process ${result.total_events} events across ${result.topics.length} topics.`); |
| 1000 | for (const t of result.topics) { |
| 1001 | console.log(`[dry-run] Topic "${t.topic}": ${t.event_count} events → ${t.dry_run_estimate || 'estimated facts'}`); |
| 1002 | } |
| 1003 | if (result.verify) { |
| 1004 | console.log(`[dry-run] Verify pass: would check paths in events (no writes).`); |
| 1005 | } |
| 1006 | if (result.discover) { |
| 1007 | console.log(`[dry-run] Discover pass: would analyze ${result.discover.topic_count} topic(s) for cross-topic insights (no writes).`); |
| 1008 | } |
| 1009 | } else if (result.topics.length === 0 && !result.verify && !result.discover) { |
| 1010 | console.log('No events to consolidate.'); |
| 1011 | } else { |
| 1012 | if (result.topics.length > 0) { |
| 1013 | console.log(`Consolidated ${result.total_events} events across ${result.topics.length} topics.`); |
| 1014 | for (const t of result.topics) { |
| 1015 | if (t.error) { |
| 1016 | console.log(` ${t.topic}: error — ${t.error}`); |
| 1017 | } else { |
| 1018 | console.log(` ${t.topic}: ${t.facts.length} facts written${t.id ? ` (${t.id})` : ''}`); |
| 1019 | } |
| 1020 | } |
| 1021 | console.log('Index regenerated.'); |
| 1022 | } |
| 1023 | if (result.verify) { |
| 1024 | const v = result.verify; |
| 1025 | console.log(`Verify pass: checked ${v.checked_count} events — ${v.verified_paths.length} verified, ${v.stale_paths.length} stale.`); |
| 1026 | if (v.stale_paths.length > 0) { |
| 1027 | for (const p of v.stale_paths) console.log(` stale: ${p}`); |
| 1028 | } |
| 1029 | } |
| 1030 | if (result.discover) { |
| 1031 | const d = result.discover; |
| 1032 | console.log(`Discover pass: ${d.connections.length} connection(s), ${d.contradictions.length} contradiction(s), ${d.open_questions.length} open question(s) across ${d.topic_count} topic(s).`); |
| 1033 | } |
| 1034 | } |
| 1035 | } catch (e) { |
| 1036 | exitWithError(`Consolidation failed: ${e.message}`, 2, useJson); |
| 1037 | } |
| 1038 | process.exit(0); |
| 1039 | } |
| 1040 | |
| 1041 | if (action === 'index') { |
| 1042 | const idx = mm.generateIndex({ force: true }); |
| 1043 | if (useJson) { |
| 1044 | console.log(JSON.stringify(idx)); |
| 1045 | } else { |
| 1046 | console.log(idx.markdown); |
| 1047 | } |
| 1048 | process.exit(0); |
| 1049 | } |
| 1050 | |
| 1051 | if (action === 'stats') { |
| 1052 | const stats = mm.stats(); |
| 1053 | if (useJson) { |
| 1054 | console.log(JSON.stringify(stats)); |
| 1055 | } else { |
| 1056 | console.log(`Total events: ${stats.total}`); |
| 1057 | console.log(`Storage: ${stats.size_bytes} bytes`); |
| 1058 | if (stats.oldest) console.log(`Oldest: ${stats.oldest}`); |
| 1059 | if (stats.newest) console.log(`Newest: ${stats.newest}`); |
| 1060 | if (Object.keys(stats.counts_by_type).length > 0) { |
| 1061 | console.log('Counts by type:'); |
| 1062 | for (const [t, c] of Object.entries(stats.counts_by_type)) { |
| 1063 | console.log(` ${t}: ${c}`); |
| 1064 | } |
| 1065 | } |
| 1066 | } |
| 1067 | process.exit(0); |
| 1068 | } |
| 1069 | } catch (e) { |
| 1070 | exitWithError(e.message, 2, useJson); |
| 1071 | } |
| 1072 | })(); |
| 1073 | return; |
| 1074 | } |
| 1075 | |
| 1076 | if (subcommand === 'doctor') { |
| 1077 | if (hasOpt('help') || hasOpt('h')) { |
| 1078 | console.log( |
| 1079 | 'knowtation doctor\n Checks local vault config (disk vault) and optional Hub API (KNOWTATION_HUB_*).\n Explains vault vs terminal token discipline per docs/TOKEN-SAVINGS.md.\n Options: --json, --hub <url> (override KNOWTATION_HUB_URL for probes only).' |
| 1080 | ); |
| 1081 | process.exit(0); |
| 1082 | } |
| 1083 | const hubUrlOpt = getOpt('hub'); |
| 1084 | const { runDoctor } = await import('./doctor.mjs'); |
| 1085 | const code = await runDoctor({ useJson, hubUrlOpt }); |
| 1086 | process.exit(code); |
| 1087 | } |
| 1088 | |
| 1089 | if (subcommand === 'hub') { |
| 1090 | const action = args[1]; |
| 1091 | if (action !== 'status') { |
| 1092 | exitWithError('knowtation hub: use "hub status". Option: --hub <url>.', 1, useJson); |
| 1093 | } |
| 1094 | const hubUrl = getOpt('hub') || process.env.KNOWTATION_HUB_URL || 'http://localhost:3333'; |
| 1095 | const base = hubUrl.replace(/\/$/, ''); |
| 1096 | (async () => { |
| 1097 | try { |
| 1098 | const res = await fetch(base + '/health', { method: 'GET' }); |
| 1099 | const data = await res.json().catch(() => ({})); |
| 1100 | if (useJson) { |
| 1101 | console.log(JSON.stringify({ ok: res.ok, status: res.status, url: base })); |
| 1102 | } else { |
| 1103 | console.log(res.ok ? `Hub at ${base} is up.` : `Hub at ${base} returned ${res.status}.`); |
| 1104 | } |
| 1105 | process.exit(res.ok ? 0 : 2); |
| 1106 | } catch (e) { |
| 1107 | exitWithError('Hub unreachable: ' + e.message, 2, useJson); |
| 1108 | } |
| 1109 | })(); |
| 1110 | return; |
| 1111 | } |
| 1112 | |
| 1113 | if (subcommand === 'vault') { |
| 1114 | const vaultSub = args[1]; |
| 1115 | if (vaultSub === 'sync') { |
| 1116 | if (hasOpt('help') || hasOpt('h')) { |
| 1117 | console.log('knowtation vault sync\n Commits and pushes the vault to the configured Git remote.\n Requires config: vault.git.enabled=true and vault.git.remote=<url>.'); |
| 1118 | process.exit(0); |
| 1119 | } |
| 1120 | let config; |
| 1121 | try { |
| 1122 | config = loadConfig(); |
| 1123 | } catch (e) { |
| 1124 | exitWithError(e.message, 2, useJson); |
| 1125 | } |
| 1126 | (async () => { |
| 1127 | try { |
| 1128 | const { runVaultSync } = await import('../lib/vault-git-sync.mjs'); |
| 1129 | const result = runVaultSync(config); |
| 1130 | if (useJson) console.log(JSON.stringify(result)); |
| 1131 | else console.log(result.message === 'Synced' ? 'Vault synced to remote.' : result.message); |
| 1132 | process.exit(0); |
| 1133 | } catch (e) { |
| 1134 | exitWithError('knowtation vault sync: ' + (e.message || 'git failed'), 1, useJson); |
| 1135 | } |
| 1136 | })(); |
| 1137 | return; |
| 1138 | } |
| 1139 | exitWithError('knowtation vault: unknown subcommand. Use vault sync.', 1, useJson); |
| 1140 | } |
| 1141 | |
| 1142 | if (subcommand === 'propose') { |
| 1143 | const pathArg = args[1]; |
| 1144 | if (!pathArg || pathArg.startsWith('--')) { |
| 1145 | exitWithError('knowtation propose: provide a vault-relative note path (e.g. inbox/note.md).', 1, useJson); |
| 1146 | } |
| 1147 | const hubUrl = getOpt('hub') || process.env.KNOWTATION_HUB_URL; |
| 1148 | if (!hubUrl) { |
| 1149 | exitWithError('knowtation propose: set --hub <url> or KNOWTATION_HUB_URL.', 1, useJson); |
| 1150 | } |
| 1151 | let config; |
| 1152 | try { |
| 1153 | config = loadConfig(); |
| 1154 | } catch (e) { |
| 1155 | exitWithError(e.message, 2, useJson); |
| 1156 | } |
| 1157 | try { |
| 1158 | resolveVaultRelativePath(config.vault_path, pathArg); |
| 1159 | } catch (e) { |
| 1160 | exitWithError(e.message, 2, useJson); |
| 1161 | } |
| 1162 | const intent = getOpt('intent') || ''; |
| 1163 | const token = process.env.KNOWTATION_HUB_TOKEN; |
| 1164 | if (!token) { |
| 1165 | exitWithError('knowtation propose: set KNOWTATION_HUB_TOKEN (JWT from Hub login).', 2, useJson); |
| 1166 | } |
| 1167 | const base = hubUrl.replace(/\/$/, ''); |
| 1168 | const vaultHdr = getOpt('vault') || process.env.KNOWTATION_HUB_VAULT_ID; |
| 1169 | const labelsRaw = getOpt('labels'); |
| 1170 | const labels = labelsRaw |
| 1171 | ? labelsRaw |
| 1172 | .split(',') |
| 1173 | .map((s) => s.trim()) |
| 1174 | .filter(Boolean) |
| 1175 | : undefined; |
| 1176 | const source = getOpt('source') || undefined; |
| 1177 | const externalRef = getOpt('external-ref') || undefined; |
| 1178 | const baseStateOverride = getOpt('base-state-id'); |
| 1179 | const skipFetchBase = hasOpt('no-fetch-base'); |
| 1180 | |
| 1181 | let bodyText = ''; |
| 1182 | let frontmatter = {}; |
| 1183 | if (noteFileExistsInVault(config.vault_path, pathArg)) { |
| 1184 | const n = readNote(config.vault_path, pathArg); |
| 1185 | bodyText = n.body; |
| 1186 | frontmatter = n.frontmatter; |
| 1187 | } |
| 1188 | |
| 1189 | (async () => { |
| 1190 | try { |
| 1191 | const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }; |
| 1192 | if (vaultHdr) headers['X-Vault-Id'] = vaultHdr; |
| 1193 | |
| 1194 | let baseStateId = baseStateOverride && String(baseStateOverride).trim() ? String(baseStateOverride).trim() : ''; |
| 1195 | if (!baseStateId && !skipFetchBase) { |
| 1196 | const encPath = pathArg.replace(/\\/g, '/').split('/').map(encodeURIComponent).join('/'); |
| 1197 | const gres = await fetch(`${base}/api/v1/notes/${encPath}`, { method: 'GET', headers }); |
| 1198 | if (gres.status === 404) { |
| 1199 | baseStateId = absentNoteStateId(); |
| 1200 | } else if (gres.ok) { |
| 1201 | const noteJson = await gres.json(); |
| 1202 | baseStateId = noteStateIdFromHubNoteJson(noteJson); |
| 1203 | } |
| 1204 | } |
| 1205 | |
| 1206 | const payload = { |
| 1207 | path: pathArg.replace(/\\/g, '/'), |
| 1208 | body: bodyText, |
| 1209 | frontmatter, |
| 1210 | intent: intent || undefined, |
| 1211 | external_ref: externalRef || undefined, |
| 1212 | labels, |
| 1213 | source, |
| 1214 | }; |
| 1215 | if (baseStateId) payload.base_state_id = baseStateId; |
| 1216 | |
| 1217 | const res = await fetch(base + '/api/v1/proposals', { |
| 1218 | method: 'POST', |
| 1219 | headers, |
| 1220 | body: JSON.stringify(payload), |
| 1221 | }); |
| 1222 | const data = await res.json().catch(() => ({})); |
| 1223 | if (!res.ok) { |
| 1224 | exitWithError(data.error || res.statusText, 2, useJson); |
| 1225 | return; |
| 1226 | } |
| 1227 | try { |
| 1228 | const config2 = loadConfig(); |
| 1229 | if (config2.memory?.enabled) { |
| 1230 | const { createMemoryManager } = await import('../lib/memory.mjs'); |
| 1231 | const mm = createMemoryManager(config2); |
| 1232 | if (mm.shouldCapture('propose')) { |
| 1233 | mm.store('propose', { |
| 1234 | proposal_id: data.proposal_id, |
| 1235 | path: data.path || pathArg, |
| 1236 | intent: intent || undefined, |
| 1237 | base_state_id: baseStateId || undefined, |
| 1238 | }); |
| 1239 | } |
| 1240 | } |
| 1241 | } catch (_) {} |
| 1242 | if (useJson) console.log(JSON.stringify(data)); |
| 1243 | else console.log('Proposal created:', data.proposal_id, data.path); |
| 1244 | process.exit(0); |
| 1245 | } catch (e) { |
| 1246 | exitWithError(e.message, 2, useJson); |
| 1247 | } |
| 1248 | })(); |
| 1249 | return; |
| 1250 | } |
| 1251 | |
| 1252 | if (subcommand === 'daemon') { |
| 1253 | const daemonAction = args[1]; |
| 1254 | |
| 1255 | if (!daemonAction || hasOpt('help') || hasOpt('h')) { |
| 1256 | console.log(`knowtation daemon <action> |
| 1257 | Actions: |
| 1258 | start [--background] Start the daemon. --background runs it detached (writes PID). |
| 1259 | stop Stop a running daemon (SIGTERM → SIGKILL after 10 s). |
| 1260 | status Show running state, PID, last pass, next scheduled pass. |
| 1261 | log [--tail <n>] Print daemon log entries (JSONL). --tail limits to last N. |
| 1262 | |
| 1263 | Notes: |
| 1264 | - Daemon requires daemon.enabled in config and a reachable LLM. |
| 1265 | - Foreground mode: Ctrl+C to stop (SIGINT). |
| 1266 | - Background mode writes PID to {data_dir}/daemon.pid, log to {data_dir}/daemon.log.`); |
| 1267 | process.exit(0); |
| 1268 | } |
| 1269 | |
| 1270 | let config; |
| 1271 | try { |
| 1272 | config = loadConfig(); |
| 1273 | } catch (e) { |
| 1274 | exitWithError(e.message, 2, useJson); |
| 1275 | } |
| 1276 | |
| 1277 | // ── daemon start ─────────────────────────────────────────────────────── |
| 1278 | if (daemonAction === 'start') { |
| 1279 | const background = hasOpt('background'); |
| 1280 | |
| 1281 | if (background) { |
| 1282 | // Spawn a detached child that runs `knowtation daemon start` (foreground). |
| 1283 | // Use env var to prevent the child from re-entering background-spawn logic. |
| 1284 | const child = spawn(process.execPath, [__filename, 'daemon', 'start'], { |
| 1285 | detached: true, |
| 1286 | stdio: 'ignore', |
| 1287 | env: { ...process.env, KNOWTATION_DAEMON_BACKGROUND: '0' }, |
| 1288 | }); |
| 1289 | child.unref(); |
| 1290 | |
| 1291 | const pidPath = path.join(config.data_dir, 'daemon.pid'); |
| 1292 | const logPath = config.daemon?.log_file || path.join(config.data_dir, 'daemon.log'); |
| 1293 | |
| 1294 | if (useJson) { |
| 1295 | console.log(JSON.stringify({ ok: true, pid: child.pid, pid_path: pidPath, log_path: logPath })); |
| 1296 | } else { |
| 1297 | const llmProvider = config.daemon?.llm?.provider || 'auto-detect'; |
| 1298 | const llmModel = config.daemon?.llm?.model || 'default'; |
| 1299 | console.log(`Daemon started in background (PID ${child.pid}). Consolidation every ${config.daemon?.interval_minutes ?? 120} min when idle.`); |
| 1300 | console.log(`LLM: ${llmProvider} ${llmModel}.`); |
| 1301 | console.log(`Log: ${logPath}`); |
| 1302 | } |
| 1303 | process.exit(0); |
| 1304 | return; |
| 1305 | } |
| 1306 | |
| 1307 | // Foreground mode |
| 1308 | if (!config.daemon?.enabled && process.env.KNOWTATION_DAEMON_BACKGROUND !== '0') { |
| 1309 | console.warn('Warning: daemon.enabled is false in config. Starting anyway (foreground mode).'); |
| 1310 | } |
| 1311 | |
| 1312 | (async () => { |
| 1313 | try { |
| 1314 | const { startDaemon } = await import('../lib/daemon.mjs'); |
| 1315 | const logPath = config.daemon?.log_file || path.join(config.data_dir, 'daemon.log'); |
| 1316 | const intervalMin = config.daemon?.interval_minutes ?? 120; |
| 1317 | console.log(`Daemon starting (PID ${process.pid}). Consolidation every ${intervalMin} min when idle.`); |
| 1318 | console.log(`Log: ${logPath}. Press Ctrl+C to stop.`); |
| 1319 | await startDaemon(config); |
| 1320 | console.log('Daemon stopped.'); |
| 1321 | process.exit(0); |
| 1322 | } catch (e) { |
| 1323 | exitWithError(`Daemon start failed: ${e.message}`, 2, useJson); |
| 1324 | } |
| 1325 | })(); |
| 1326 | return; |
| 1327 | } |
| 1328 | |
| 1329 | // ── daemon stop ──────────────────────────────────────────────────────── |
| 1330 | if (daemonAction === 'stop') { |
| 1331 | (async () => { |
| 1332 | try { |
| 1333 | const { stopDaemon } = await import('../lib/daemon.mjs'); |
| 1334 | const result = await stopDaemon(config); |
| 1335 | if (useJson) { |
| 1336 | console.log(JSON.stringify(result)); |
| 1337 | } else if (result.stopped) { |
| 1338 | console.log(`Daemon stopped (PID ${result.pid}, signal ${result.signal}).`); |
| 1339 | } else { |
| 1340 | console.log(`Daemon was not running: ${result.reason}`); |
| 1341 | } |
| 1342 | process.exit(0); |
| 1343 | } catch (e) { |
| 1344 | exitWithError(`Daemon stop failed: ${e.message}`, 2, useJson); |
| 1345 | } |
| 1346 | })(); |
| 1347 | return; |
| 1348 | } |
| 1349 | |
| 1350 | // ── daemon status ────────────────────────────────────────────────────── |
| 1351 | if (daemonAction === 'status') { |
| 1352 | try { |
| 1353 | const { getDaemonStatus } = await import('../lib/daemon.mjs'); |
| 1354 | const status = getDaemonStatus(config); |
| 1355 | if (useJson) { |
| 1356 | console.log(JSON.stringify(status)); |
| 1357 | } else if (!status.running) { |
| 1358 | console.log('Status: not running'); |
| 1359 | if (status.last_pass) { |
| 1360 | console.log(`Last pass: ${status.last_pass.ts} (${status.last_pass.events_processed} events, ${status.last_pass.topics} topics)`); |
| 1361 | } |
| 1362 | console.log(`Log: ${status.log_path}`); |
| 1363 | } else { |
| 1364 | const uptimeSec = Math.round((status.uptime_ms ?? 0) / 1000); |
| 1365 | const uptimeStr = uptimeSec < 60 |
| 1366 | ? `${uptimeSec}s` |
| 1367 | : uptimeSec < 3600 |
| 1368 | ? `${Math.round(uptimeSec / 60)}m ${uptimeSec % 60}s` |
| 1369 | : `${Math.floor(uptimeSec / 3600)}h ${Math.floor((uptimeSec % 3600) / 60)}m`; |
| 1370 | console.log(`Status: running (PID ${status.pid}, uptime ${uptimeStr})`); |
| 1371 | if (status.last_pass) { |
| 1372 | const lp = status.last_pass; |
| 1373 | console.log(`Last pass: ${lp.ts} (processed ${lp.events_processed} events, ${lp.topics} topics)`); |
| 1374 | } else { |
| 1375 | console.log('Last pass: none yet'); |
| 1376 | } |
| 1377 | if (status.next_pass_at) { |
| 1378 | console.log(`Next pass: ~${status.next_pass_at} (if idle)`); |
| 1379 | } |
| 1380 | } |
| 1381 | process.exit(0); |
| 1382 | } catch (e) { |
| 1383 | exitWithError(`Daemon status failed: ${e.message}`, 2, useJson); |
| 1384 | } |
| 1385 | return; |
| 1386 | } |
| 1387 | |
| 1388 | // ── daemon log ───────────────────────────────────────────────────────── |
| 1389 | if (daemonAction === 'log') { |
| 1390 | const tail = getOpt('tail', 'number') ?? null; |
| 1391 | try { |
| 1392 | const { getLogPath, readDaemonLog } = await import('../lib/daemon.mjs'); |
| 1393 | const logPath = getLogPath(config); |
| 1394 | const entries = readDaemonLog(logPath, { tail: tail ?? undefined }); |
| 1395 | if (useJson) { |
| 1396 | console.log(JSON.stringify({ entries, count: entries.length, log_path: logPath })); |
| 1397 | } else if (entries.length === 0) { |
| 1398 | console.log(`(no log entries — log: ${logPath})`); |
| 1399 | } else { |
| 1400 | for (const e of entries) { |
| 1401 | const { ts, event, ...rest } = e; |
| 1402 | const detail = Object.keys(rest).length ? ' ' + JSON.stringify(rest) : ''; |
| 1403 | console.log(`${ts} ${event ?? '?'}${detail}`); |
| 1404 | } |
| 1405 | } |
| 1406 | process.exit(0); |
| 1407 | } catch (e) { |
| 1408 | exitWithError(`Daemon log failed: ${e.message}`, 2, useJson); |
| 1409 | } |
| 1410 | return; |
| 1411 | } |
| 1412 | |
| 1413 | exitWithError(`knowtation daemon: unknown action "${daemonAction}". Use start, stop, status, or log.`, 1, useJson); |
| 1414 | return; |
| 1415 | } |
| 1416 | |
| 1417 | exitWithError(`Unknown command: ${subcommand}`, 1, useJson); |
| 1418 | } |
| 1419 | |
| 1420 | main().catch((e) => { |
| 1421 | console.error(e.message || e); |
| 1422 | process.exit(2); |
| 1423 | }); |
File History
2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6
docs: accept Calendar Events v0 spec with Phase 0 security …
Human
1 day ago