Skip to content

feat!: libscope 2.0 — one operation layer for CLI, MCP, REST and SDK - #515

Merged
RobertLD merged 106 commits into
mainfrom
feat/simplify-redesign
Oct 8, 2026
Merged

RobertLD merged 106 commits into
mainfrom
feat/simplify-redesign

Conversation

@RobertLD

@RobertLD RobertLD commented Oct 8, 2026

Copy link
Copy Markdown
Owner

Summary

This is the libscope 2.0 redesign. Every action is defined once, in a shared operation layer (src/core/operations/, 61 operations, each with one zod input schema). The CLI, MCP server, REST API, web dashboard server and Node SDK are thin adapters over that layer, so their names, defaults and validation can no longer drift apart. Backward compatibility was traded for simpler code: old names are removed without aliases, and docs/migration-v2.md lists every old name and its replacement.

Diff against main: 276 files changed, +26,843 / −19,558 lines, in 95 commits. None of the commits has a Co-authored-by trailer.

What changed

  • Cleanup and bug fixes (before the redesign).
    • All surfaces now open the same workspace database.
    • MCP logs go to stderr.
    • config set keeps the registries list.
    • Subcommand --version flags were renamed to --lib-version and --pack-version.
    • Webhooks fire.
    • MCP results carry document and chunk IDs.
    • Cancelling an MCP task stops the work.
    • Every embedding model works, and switching models points to reindex --rebuild.
    • Schema migration 18 rebuilds the keyword index keyed by rowid and adds a title index; in a benchmark, deleting a 40-chunk document went from 94 ms to 0.15 ms.
    • Scheduling works for every connector.
  • Settings. One zod schema defines the defaults, validation, config get/set/unset, the LIBSCOPE_<SECTION>_<KEY> env vars and the generated docs.
    • Keys are provider-neutral: embedding.{provider,model,url,dimensions} and llm.{provider,model,url}.
    • llm.provider defaults to auto: passthrough under MCP, otherwise whichever API key is set.
    • API keys are read from env vars or ~/.libscope/secrets.json (mode 0600).
  • CLI. About 15 command groups replace the old commands, and src/cli/index.ts went from 3,194 lines to 129.
    • add handles files, folders, URLs, crawls and repos.
    • search with no query opens interactive search.
    • doctor replaces init.
    • Maintenance moved under admin: reindex, dedupe, backup, restore, stats and prune.
    • Connectors use connect <type>, sync, disconnect and connections.
    • Global --json and --verbose. Errors print ✗ message plus a hint for the next step.
  • MCP. 11 core tools, generated from operations, plus an opt-in admin toolset (mcp.toolsets).
    • Server instructions describe the workflow.
    • Destructive tools carry annotations.
    • createMcpServer() has no side effects on import.
  • REST. Routes and the OpenAPI 3.1 spec are generated from the operations.
    • Bad input returns 400 and missing items 404; long jobs return 202 with a task ID.
    • The REST repo indexer, which kept a separate database per repo, is removed.
    • The dashboard server reuses the operations and refuses cross-origin writes.
  • SDK. LibScope is the only public entry point, with namespaces such as scope.docs and scope.packs.
    • The package root went from about 150 runtime exports to 11.
    • libscope/lite is now a preset of LibScope instead of a separate class.
  • Packs. Only the git registries remain; the URL registry is removed. Pack names are resolved in core.
  • Python and Go client SDKs use the 2.0 routes and background tasks.
  • Docker. The image runs serve api, stores its data in the volume, and uses node:22-bookworm-slim, because the local embedding model does not load on Alpine.
  • Docs. The CLI, MCP, settings and REST references are generated from code (npm run docs:gen). CI runs docs:check. The README and guides were rewritten for 2.0.

Verification

  • Node 20.20 and Node 22.23: format check, lint (0 errors), typecheck, test:coverage (2,035 tests; 91% statements, 83% branches), build and docs:check all pass. Node 22 was run on an earlier tip of this branch.
  • Live, with a temporary HOME:
    • single URL add;
    • spider crawls;
    • GitHub repo adds (including --branch/--path and tree URLs);
    • the docs-site connector (connect, incremental sync, disconnect);
    • registry publish --submit against a local bare repo;
    • the REST add → task → search flow.
  • Dashboard in headless Chromium (Playwright): load, search, open a document, delete, and the graph page; no failed requests. Chromium's missing system libraries were unpacked locally, not installed.
  • Doc examples: every runnable example in the README, the guides, lite-api/registry/rest-api, and the Python and Go READMEs was run or compiled. Examples that need credentials were checked against --help and the generated references.

Bugs found during verification were fixed with tests in this PR:

  • HTML entities in page titles.
  • Docs connector disconnect deleting pages it did not index.
  • registry publish --submit failing on master and leaving the clone on the feature branch.
  • registry create using git's default branch.
  • The tree-sitter TypeScript grammar failing to load under ESM.
  • Function words dominating the keyword fallback.
  • A custom chunker unable to index source files by path.

Not verified

  • Live Slack, Notion, Confluence and OneNote syncs: no credentials were available.
  • Answers from a real LLM (OpenAI, Anthropic, Ollama).
  • Webhook delivery to a real receiver.
  • The SonarCloud quality gate: it runs on this PR.

Known issues not fixed here

  • The same page can be stored under two URLs (trailing slash in the spider vs the docs connector; repo tree URL vs root URL).
  • Repo document titles are file base names.
  • add <github-url> needs --branch when the default branch is not main.
  • registry publish --pack-version does not rewrite the version inside the pack file.
  • In a non-TTY, confirmation prompts exit 0 without acting.
  • The SDK and REST server ignore logging.level.
  • The unused connector_configs table is kept; dropping it is pending a decision.
  • package.json is still 1.8.0. Release-please should produce 2.0.0 from the breaking-change commits.

- Add confirmOrCancel() to cli/confirm.ts and use it at all 13 confirm sites
- Extract addBulkFilterOptions/addBulkRunOptions/selectorFromOpts/runBulk for bulk delete/retag/move
- Use initializeAppWithEmbedding() in connect obsidian
- Use static connector-config imports in schedule set/remove
- Add splitCsv() for identical comma-split option parsing
- mcp: add runMaybeAsync() so each async-capable tool formats its output once
- mcp: add internal textResult() helper and use ToolResult instead of inline types
- mcp: collapse duplicate cancelled branches in startAsyncTask; drop dead fallbacks
- api: replace hand-written match*/is*Path/dispatch* helpers with a pattern route table
- api: remove dead placeholder/never-read state in spider handler
- confluence: compute html.toLowerCase() once per helper call
- obsidian: build the vault file map once per sync instead of per file
- obsidian/onenote: call createTopic directly (ON CONFLICT returns existing)
- slack/spider/repo/onenote: use node:timers/promises setTimeout for delays
- url-fetcher: fetchAndConvert reuses fetchRaw; share getInsecureAgent with http-utils
- graph/docs: has-only Maps become Sets
- rag: extract shared retrieval helper
- config: truthy() and LLM_PROVIDERS helpers in getEnvOverrides
- lite/normalize: file and buffer inputs share one path
- parsers: MarkdownParser extends PlainTextParser
- search: shared doc column list, rating join helpers, base zod row schema
  with .extend, toResult/vectorRowToResult mappers, runFtsQuery for the
  FTS5 AND/OR-fallback queries; hoist zod schemas to module scope; drop
  unused vectorSearch offset param
- documents/topics/tags: DOC_COLUMNS + rowToDocument, TOPIC_COLUMNS +
  rowToTopic
- add deleteChunkEmbeddings(db, docIds) and use it in documents, indexing,
  watcher, packs and ttl with each site's existing error handling
- registry/publish: reuse readPackFile from core/packs
- registry/resolve: remove unreachable manifest alt-path fallback
- packs: drop redundant batchInstalled counter; tags: dedupe STOPWORDS
search-docs results now show a Document ID / Chunk ID line, link-documents
prints the created link's ID, and get-document-links shows each link's ID,
so get-related, rate-document, and delete-link are usable from tool output.
The link-documents enum now comes from LINK_TYPES in core/links.ts and
accepts "references".
pino defaults to stdout, so the MCP stdio server interleaved JSON log
lines (DB open, migrations) with protocol messages. initLogger now takes
a destination option (default stderr); MCP main requests stderr
explicitly. The CLI configures logging before loading config so
validation warnings honour the CLI log level.
…ry path

fireWebhooks had no callers, so webhooks never fired. Add a small
core/events module; indexDocument, updateDocument, deleteDocument,
rateDocument and searchDocuments emit document.created/updated/deleted,
document.rated and search.executed. Webhooks subscribe to it. Emission
never throws into the caller and never waits on delivery.

Add deliverWebhook(webhook, event, data): decrypts the stored secret,
signs, re-runs the SSRF check and POSTs with redirect: "error". Used by
fireWebhooks, the CLI 'webhooks test' command and the REST test route,
which previously signed with the encrypted secret (and the CLI skipped
the SSRF check and followed redirects).
The FTS backfill ran after every migration, so each schema upgrade
re-inserted the whole corpus into chunks_fts and doubled keyword hits.
Backfill now runs only when chunks_fts has no rows. runMigrations takes
an optional target version so tests can build older schemas.
getEnvOverrides spread DEFAULT_CONFIG sections into the override layer,
so setting any LIBSCOPE_* variable reset sibling keys from config files
to defaults (e.g. file provider=openai + LIBSCOPE_OPENAI_API_KEY gave
provider=local). The env layer now contains only variables that are set.
…t 50

syncDocSite built its existing-URL set from listDocuments, which applies
a default LIMIT 50 and loads full content. Sites with more than 50 pages
had unchanged and changed pages miscounted as new. Query the url column
directly with no limit.
The root program's .version() registers a global --version option, so
`add/import/import-batch/docs update/pack install/pack create/registry
publish --version X` printed the CLI version and exited, and `registry
unpublish` (required --version) could never run.

- Document metadata flag: --version -> --lib-version (add, import,
  import-batch, docs update)
- Pack/registry flag: --version -> --pack-version (pack install,
  pack create, registry publish, registry unpublish)
- registry unpublish accepts <name>@<version>; --pack-version is now
  optional and takes precedence over the suffix
- Only call program.parse() when the module is the process entry point,
  and export the program so tests can parse arguments without running it
The FTS delete and update triggers matched rows on the UNINDEXED
chunk_id column, so deleting one chunk scanned the whole FTS table.
Migration 18 recreates chunks_fts with rowid = chunks.rowid, makes the
triggers delete by rowid, and repopulates the index once from chunks,
which also removes duplicate rows left by earlier backfills. Column
names are unchanged, so search queries are unchanged.

Deleting a 40-chunk document at 20k chunks: 94 ms -> 0.15 ms.
indexDocument checks for an existing document with
WHERE title = ? AND LENGTH(content) = ?, which scanned the whole
documents table on every index call. Migration 18 adds
idx_documents_title.
- Add signal?: AbortSignal to ReindexOptions, InstallOptions, and
  SpiderOptions; each checks throwIfAborted() between batches/pages.
- MCP submit-document (single and spider), reindex-documents, and
  install-pack pass the task signal instead of throwing from onProgress.
- installPack no longer hangs (with an unhandled rejection) when a batch
  callback throws inside its scheduler; it now rejects and rolls back the
  partially installed pack.
- TaskRegistry.run records completed when work resolves (even after a late
  cancel), cancelled only when it rejects because of the abort, else failed.
- Rename the reindex task type from reindex_library to reindex_documents.
Keys now resolve in loadConfig as LIBSCOPE_<P>_API_KEY > <P>_API_KEY >
config file, for both embedding and LLM (OPENAI_API_KEY was accepted by
validation but never read by providers). LIBSCOPE_ANTHROPIC_API_KEY no
longer requires LIBSCOPE_LLM_PROVIDER, LIBSCOPE_OLLAMA_MODEL is read,
validation checks the resolved config (incl. anthropic), the config-file
key warning names the file and the real env vars, and config show masks
keys. Docs: env var tables, .env.example (nothing loads .env), REST API
auth header.
deleteTopic({ deleteDocuments: true }) deleted the documents (and, by
cascade, their chunks) but left their vectors in chunk_embeddings,
a vec0 virtual table with no foreign key. Delete the vectors first with
deleteChunkEmbeddings, in batches of 500 documents.
config set accepted only 3 keys while docs and error messages told users
to set others, and saveUserConfig rewrote config.json from 5 known
sections: it dropped the registries array, wrote every default, and did
not restrict permissions.

- config set <key> <value> accepts every key of LibScopeConfig (typed
  key table; booleans/integers coerced, enums validated). API keys are
  refused with the env var to use.
- New config get, config unset, config path; show/get mask API keys.
- saveUserConfig is read-modify-write: only passed keys are written,
  unknown keys (registries, hand-written values) are kept.
- One raw reader/writer of ~/.libscope/config.json (readRawUserConfig /
  writeRawUserConfig, 0600 file, 0700 dir), used by registry/config.ts.
Fresh indexDocument embedded a title/library/version prefix plus the chunk
and wrote chunk_embedding_metadata, but updateDocument, reindex, and pack
install embedded the raw chunk and skipped the metadata row, so a chunk's
vector depended on how its document was written.

Add buildEmbeddingText, embedChunks, splitIntoChunks, and createChunkWriter
in core/indexing.ts and use them on all four paths. updateDocument now also
re-extracts document links when content changes, and re-embeds existing
chunks in place when title, library, or version change.
…stry

import-batch (core/batch.ts) and watch (core/watcher.ts plus the CLI's
initial scan) read every file as raw UTF-8, so PDF/DOCX/EPUB/PPTX files
were indexed as binary garbage.

- batchImport now indexes through indexFile (same parser path as `add`
  and `import`) and honours its sourceType option, which was ignored.
  Unsupported extensions are skipped with a warning and counted in a new
  `skipped` field instead of failing.
- FileWatcher and the CLI watch scan parse files with the matching
  parser via readWatchedFile(); .rst stays indexed as plain text, and
  unsupported extensions are skipped with a warning.
- Default extension sets for import, import-batch (--filter) and watch
  now come from getSupportedExtensions() instead of hand-written lists.
- indexFile accepts an optional sourceType override.
import-batch called node:fs globSync, which only exists in Node 22+, so the
command crashed on Node 20. Replace it with recursive readdirSync plus a
shared globToRegExp helper (supports **, *, ?, {a,b}), and use the same helper
for pack-create exclude patterns and the repo indexer's include/exclude
globs instead of two separate copies.
The REST API and the CLI link command kept their own link-type lists that
omitted "references", which core accepts. Use the exported LINK_TYPES.
database.path always had a default (~/.libscope/libscope.db), so the SDK
opened that file while CLI/MCP opened the workspace database, the SDK
workspace option did nothing, init/stats reported the wrong file, and
connect/disconnect onenote built join(wsPath, absolutePath).

- resolveDatabasePath(): explicit path (SDK dbPath, or database.path set
  in a config file, ~ expanded) > workspace (option, else active).
- database.path has no default; used by CLI initializeApp, MCP main,
  LibScope.create (new dbPath option; workspace option now works) and
  the onenote commands (now use initializeApp*).
- init prints db.name; getStats defaults to the open file (CLI stats,
  REST /health and /stats, SDK stats()).
- If the workspace DB does not exist and ~/.libscope/libscope.db does, a
  single warning names it and how to keep it (config set database.path).
  Nothing is moved or deleted.
…er run

Only 'connect slack' saved a named config, so 'schedule set notion'
(the command's own help example) always failed, and scheduled runs
wrote two connector_syncs rows (scheduler + connector) with drifted
stats mappings.

- Every 'connect <type>' saves a named config (connectors/<name>.json,
  0600, default name = type, --name to override) with the credentials a
  later run needs and connectorType; an existing schedule is kept.
- --sync on every connect command re-runs the saved config (obsidian's
  vault path is now optional with --sync).
- Connectors record the run (new trackSync helper, optional syncName);
  the scheduler no longer tracks, so each run writes exactly one row
  under the configured name with the connector's own stats. A config
  that cannot be loaded records one failed row.
- OneNote token refresh moved into connectors/onenote.ts
  (ensureOneNoteAccessToken), so scheduled runs refresh expired tokens
  and the new tokens are saved. Old connectors.json OneNote credentials
  are still read for --sync.
- Schedule entries resolve the type from connectorType, so Confluence's
  cloud/server 'type' field no longer hides the connector type.
- Slack: send lastSync as Unix seconds in 'oldest' (saved configs store
  ISO-8601, which Slack does not accept).
- disconnect <type> also deletes the saved config (--name).

Connector function signatures only gain an optional options argument.
The DB connector_configs functions stay: core/index.ts exports them.
libscope/lite in 1.x indexed source files by path with the tree-sitter chunker;
2.0 rejected them as an unsupported format. indexFile now reads such files as
UTF-8 when a chunker is set and indexes them if the chunker returns chunks.
Directory ingest replaces batchImport; searchBatch had no callers after the
root exports shrank.
…provider plugin registry

registerProvider was not exported in 2.0 (custom providers are passed as an
instance), so the open-enum config path for plugin provider names is removed.
Titles taken from <title> or <h1> kept references such as &#8212;, so a URL
add, a spider crawl and the docs connector stored titles like
"textwrap &#8212; Python documentation".
…n indexed

disconnect of a docs connection deleted every document whose URL started with
the site URL, including pages added with `libscope add <url>`. The URL was
also matched with LIKE, so "_" and "%" in it acted as wildcards. Match the
prefix exactly and only for pages the docs connector stored (source type
library, added by the crawler).
…default export

Under ESM, import() of the CommonJS grammar packages returns module.exports as
the default export, so TreeSitterChunker passed { typescript, tsx } to
setLanguage and every TypeScript chunk failed with "Invalid language object".

The docs and the install hints now pin tree-sitter@0.21 and the matching
grammar versions: the latest grammars need a newer tree-sitter and npm refuses
them next to libscope's peer range. The code-indexing example no longer nests a
template literal inside its template-literal source string.
When no chunk contains every query word, the keyword search retried with any
word. "how to authenticate" then matched every chunk containing "to", and the
fused ranking put those chunks above the semantic match. The OR retry now
skips stopwords (shared with tag suggestions in core/stopwords.ts) unless the
query has nothing else.
…s the clone

publish --submit created feature/add-<pack> in the local registry clone and then
ran a plain `git push`, which fails for a new branch without an upstream (git's
default settings). The cleanup then ran `git checkout main`, which fails for a
registry whose branch is master, so the clone stayed on the feature branch with
a dirty tree; after a successful --submit it also stayed there, so the next
normal publish went to the feature branch.

Push the commit explicitly to refs/heads/feature/add-<pack>, always check out
the clone's previous branch again and delete the local feature branch, and use
the published version (not the pack file's) in the default commit message.
…-submit

`registry create` committed on git's default branch (often master), so the
documented `git push -u origin main` failed and the remote stayed empty.
`registry publish --submit` ran a plain `git push` on the new feature branch,
which has no upstream, and failed before its own `push -u`. commitAndPush now
pushes HEAD with -u, and the clone returns to its base branch after a submit.
Titles taken from <title> and <h1> kept their HTML character references, so
https://docs.python.org/3/tutorial/ was indexed as "The Python Tutorial
&#8212; Python 3.14 documentation". URL ingestion, the spider and the docs
connector now decode numeric and common named references.
- Python SDK quick start: create the "python" topic before add_text uses it
  (the server answers 404 for an unknown topic); note that ask needs an LLM.
- README SDK example: note that ask needs an LLM.
- Programmatic usage: askStream throws a ConfigError without an LLM,
  including with passthrough.
- Connectors: a --schedule-only connect works on a saved connection; a new
  one also needs its settings.
- REST reference: a task result string starts with "kind".
- Architecture: renameTopic exists in core/topics.ts; only the operation is
  hypothetical.
…design

Both verification branches fixed HTML entities in titles and publish --submit;
keep the first (already merged) implementation, keep this branch's registry
create on main, and make commitAndPush push new branches with -u origin HEAD.
@vercel

vercel Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
libscope Ignored Ignored Preview Oct 8, 2026 6:05pm UTC

…e addons

Lint/typecheck, the npm publish job and the Docker builder stage need no native addon. The test job rebuilds better-sqlite3 and sharp (loaded by @xenova/transformers in the neural retrieval suite); the build job rebuilds better-sqlite3 for docs:check. Sort the apt package names in the Dockerfile.
Format the blocks of a file and the four doc files with Promise.all instead of awaiting in a loop, and use String.raw for the escaped pipe. Output, problems and exit code are unchanged.
AsyncLibscopeClient.wait_for_task no longer takes timeout; wrap it in asyncio.wait_for or asyncio.timeout. The synchronous client keeps timeout. Use a constant for the /tags path.
- Replace await-in-loop (S9382) with shared helpers in src/utils/async.ts
  (forEachSequential, mapSequential, runConcurrent) or recursion for
  pagination, retries, polling and crawl loops; order is kept everywhere.
- Rewrite super-linear regexes (S8786) as linear scanners with the same
  matches: Obsidian wikilinks, first markdown heading, trailing slashes and
  embedded credentials in registry URLs.
- Split connectSettings, syncConfluenceSpace and globToRegExp (S3776).
- Remove nested ternaries and nested template literals (S3358, S4624).
- Use top-level await in the CLI and MCP entry points (S7785); the CLI
  still runs only when it is the process entry point.
- Combine Array#push calls, use Number directly, a Set for watch
  extensions, replaceAll, a readonly scheduler map and an async wrapper
  instead of a trivial promise.
New test files for the async helpers, the wikilink and heading scanners,
registry URL credential checks, glob brace edge cases, connect settings per
connector type, paged document views and OneNote device-code polling.
Escape the Unicode line separators in markdown.ts, pass promises to
Promise.all in gen-docs, and keep one call inside pytest.raises.
@sonarqubecloud

sonarqubecloud Bot commented Oct 8, 2026

Copy link
Copy Markdown

@RobertLD
RobertLD merged commit c6e5ba0 into main Oct 8, 2026
20 checks passed
@RobertLD
RobertLD deleted the feat/simplify-redesign branch October 8, 2026 18:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant