langflower 0.0.9 → 0.1.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/README.md +96 -70
- package/docs/public/README.md +2 -2
- package/docs/public/configuration.md +4 -0
- package/docs/public/extending.md +18 -2
- package/docs/public/getting-started.md +7 -0
- package/docs/public/how-it-works.md +18 -11
- package/docs/public/product.md +35 -25
- package/package.json +2 -2
- package/ui-dist/chunk-QI4IYILJ.js +4 -0
- package/ui-dist/{chunk-7UMVYN3A.js → chunk-UG3GFV4Y.js} +1 -1
- package/ui-dist/index.html +2 -2
- package/ui-dist/main-ODIDCCNX.js +315 -0
- package/ui-dist/styles-2JMBAQC3.css +1 -0
- package/vendor/common-nodes/dist/ai/features/llm-loop/llm-loop-reducer.js +0 -12
- package/vendor/common-nodes/dist/ai/features/llm-loop/llm-loop-types.d.ts +2 -7
- package/vendor/common-nodes/dist/ai/features/llm-loop/llm-loop-types.js +1 -1
- package/vendor/common-nodes/dist/ai/features/llm-loop/run-agent-loop.d.ts +0 -3
- package/vendor/common-nodes/dist/ai/features/llm-loop/run-agent-loop.js +22 -36
- package/vendor/common-nodes/dist/ai/features/llm-loop/run-llm-loop.d.ts +1 -7
- package/vendor/common-nodes/dist/ai/features/llm-loop/run-llm-loop.js +12 -35
- package/vendor/common-nodes/dist/ai/features/llm-session/llm-session-shell.d.ts +5 -12
- package/vendor/common-nodes/dist/ai/features/llm-session/llm-session-shell.js +48 -21
- package/vendor/common-nodes/dist/ai/features/llm-session/run-session-machine.d.ts +11 -0
- package/vendor/common-nodes/dist/ai/features/llm-session/run-session-machine.js +2 -2
- package/vendor/common-nodes/dist/ai/features/path-choice/run-reactive-path-choice-loop.d.ts +0 -3
- package/vendor/common-nodes/dist/ai/features/path-choice/run-reactive-path-choice-loop.js +2 -15
- package/vendor/common-nodes/dist/ai/features/run-host-services.d.ts +10 -0
- package/vendor/common-nodes/dist/ai/features/sub-agent-protocol.d.ts +1 -0
- package/vendor/common-nodes/dist/ai/features/sub-agent-protocol.js +1 -0
- package/vendor/common-nodes/dist/ai/features/ui-schema/llm-recovery-ui-schema.d.ts +1 -1
- package/vendor/common-nodes/dist/ai/features/ui-schema/llm-recovery-ui-schema.js +1 -1
- package/vendor/common-nodes/dist/ai/features/wait-for-subagent-result.d.ts +1 -0
- package/vendor/common-nodes/dist/ai/features/wait-for-subagent-result.js +1 -0
- package/vendor/common-nodes/dist/ai/nodes/critique/node.d.ts +3 -3
- package/vendor/common-nodes/dist/ai/nodes/critique/node.js +10 -19
- package/vendor/common-nodes/dist/ai/nodes/fake-llm/node.d.ts +3 -3
- package/vendor/common-nodes/dist/ai/nodes/fake-llm/node.js +10 -11
- package/vendor/common-nodes/dist/ai/nodes/openai-llm/node.d.ts +3 -3
- package/vendor/common-nodes/dist/ai/nodes/openai-llm/node.js +7 -10
- package/vendor/common-nodes/dist/ai/nodes/review/node.d.ts +3 -3
- package/vendor/common-nodes/dist/ai/nodes/review/node.js +12 -19
- package/vendor/common-nodes/dist/ai/nodes/sub-agent/node.d.ts +5 -5
- package/vendor/common-nodes/dist/ai/nodes/sub-agent/node.js +251 -96
- package/vendor/common-nodes/dist/catalog.js +12 -0
- package/vendor/common-nodes/dist/crawl/crawl/node.js +11 -3
- package/vendor/common-nodes/dist/crawl/crawl-tools/node.js +5 -1
- package/vendor/common-nodes/dist/crawl/extract-links/node.js +7 -1
- package/vendor/common-nodes/dist/crawl/fetch-url/node.js +13 -4
- package/vendor/common-nodes/dist/crawl/save-page/node.js +11 -3
- package/vendor/common-nodes/dist/embeddings/create-embedding.d.ts +39 -0
- package/vendor/common-nodes/dist/embeddings/create-embedding.js +154 -0
- package/vendor/common-nodes/dist/embeddings/embed-provider/node.d.ts +53 -0
- package/vendor/common-nodes/dist/embeddings/embed-provider/node.js +126 -0
- package/vendor/common-nodes/dist/embeddings/embed-similarity/node.d.ts +20 -0
- package/vendor/common-nodes/dist/embeddings/embed-similarity/node.js +81 -0
- package/vendor/common-nodes/dist/embeddings/embed-text/node.d.ts +53 -0
- package/vendor/common-nodes/dist/embeddings/embed-text/node.js +91 -0
- package/vendor/common-nodes/dist/embeddings/from-embedding.d.ts +8 -0
- package/vendor/common-nodes/dist/embeddings/from-embedding.js +24 -0
- package/vendor/common-nodes/dist/embeddings/resolve-embedding-provider-model.d.ts +11 -0
- package/vendor/common-nodes/dist/embeddings/resolve-embedding-provider-model.js +8 -0
- package/vendor/common-nodes/dist/flow/checkpoint/node.js +7 -3
- package/vendor/common-nodes/dist/flow/delay/node.d.ts +2 -6
- package/vendor/common-nodes/dist/flow/delay/node.js +13 -9
- package/vendor/common-nodes/dist/flow/loop/node.js +7 -1
- package/vendor/common-nodes/dist/flow/merge/node.js +7 -1
- package/vendor/common-nodes/dist/flow/repeat/node.js +5 -1
- package/vendor/common-nodes/dist/flow/router/node.js +7 -1
- package/vendor/common-nodes/dist/hitl/chat-input/node.d.ts +2 -2
- package/vendor/common-nodes/dist/hitl/chat-input/node.js +8 -4
- package/vendor/common-nodes/dist/hitl/review-gate/node.js +11 -5
- package/vendor/common-nodes/dist/langflower-tools/node.js +5 -1
- package/vendor/common-nodes/dist/logic/assert/node.js +7 -1
- package/vendor/common-nodes/dist/logic/compare/node.js +7 -1
- package/vendor/common-nodes/dist/logic/gate/node.js +7 -1
- package/vendor/common-nodes/dist/logic/if/node.js +7 -1
- package/vendor/common-nodes/dist/logic/switch/node.js +7 -1
- package/vendor/common-nodes/dist/mcp/mcp-http/node.d.ts +4 -4
- package/vendor/common-nodes/dist/mcp/mcp-http/node.js +61 -60
- package/vendor/common-nodes/dist/mcp/mcp-stdio/node.d.ts +4 -4
- package/vendor/common-nodes/dist/mcp/mcp-stdio/node.js +58 -57
- package/vendor/common-nodes/dist/memory/memory-tools/node.js +5 -1
- package/vendor/common-nodes/dist/output/finish/node.js +13 -1
- package/vendor/common-nodes/dist/output/preview/node.js +9 -2
- package/vendor/common-nodes/dist/primitives/boolean/node.js +7 -1
- package/vendor/common-nodes/dist/primitives/number/node.js +7 -1
- package/vendor/common-nodes/dist/primitives/string/node.js +7 -1
- package/vendor/common-nodes/dist/primitives/string-multiline/node.d.ts +17 -0
- package/vendor/common-nodes/dist/primitives/string-multiline/node.js +31 -0
- package/vendor/common-nodes/dist/text/append-file/node.js +11 -3
- package/vendor/common-nodes/dist/text/concat/node.js +5 -1
- package/vendor/common-nodes/dist/text/read-file/node.js +9 -3
- package/vendor/common-nodes/dist/text/split-paced/node.d.ts +21 -0
- package/vendor/common-nodes/dist/text/split-paced/node.js +93 -0
- package/vendor/common-nodes/dist/text/write-file/node.js +11 -3
- package/vendor/common-nodes/dist/tools/collect-agent-tool-handles.d.ts +4 -4
- package/vendor/common-nodes/dist/tools/collect-agent-tool-handles.js +3 -11
- package/vendor/common-nodes/dist/tools/inventory-tool-round.d.ts +0 -10
- package/vendor/common-nodes/dist/tools/inventory-tool-round.js +0 -84
- package/vendor/common-nodes/dist/tools/tool-collection/node.d.ts +20 -0
- package/vendor/common-nodes/dist/tools/tool-collection/node.js +45 -0
- package/vendor/common-nodes/package.json +5 -5
- package/vendor/compiler/package.json +1 -1
- package/vendor/node-sdk/dist/node-factory/define-embed/embed-handle.d.ts +25 -0
- package/vendor/node-sdk/dist/node-factory/define-embed/embed-handle.js +25 -0
- package/vendor/node-sdk/dist/node-factory/define-llm-node/default-llm-ports.d.ts +3 -7
- package/vendor/node-sdk/dist/node-factory/define-llm-node/default-llm-ports.js +4 -44
- package/vendor/node-sdk/dist/node-factory/define-llm-node/define-llm-node.d.ts +3 -5
- package/vendor/node-sdk/dist/node-factory/define-llm-node/define-llm-node.js +3 -5
- package/vendor/node-sdk/dist/node-factory/define-llm-node/llm-inventory-wire.d.ts +1 -0
- package/vendor/node-sdk/dist/node-factory/define-llm-node/llm-inventory-wire.js +1 -0
- package/vendor/node-sdk/dist/node-factory/define-mcp/mcp-handle.d.ts +4 -4
- package/vendor/node-sdk/dist/node-factory/define-mcp/mcp-handle.js +2 -1
- package/vendor/node-sdk/dist/node-factory/define-node/define-node.d.ts +2 -0
- package/vendor/node-sdk/dist/node-factory/define-node/define-node.js +4 -1
- package/vendor/node-sdk/dist/node-factory/define-reactive-node/define-reactive-node.d.ts +3 -1
- package/vendor/node-sdk/dist/node-factory/define-reactive-node/define-reactive-node.js +2 -1
- package/vendor/node-sdk/dist/node-factory/define-reactive-node/io-helpers.d.ts +15 -0
- package/vendor/node-sdk/dist/node-factory/define-reactive-node/io-helpers.js +17 -1
- package/vendor/node-sdk/dist/node-factory/define-reactive-node/port-meta.d.ts +17 -4
- package/vendor/node-sdk/dist/node-factory/define-reactive-node/test/samples/delay-node.js +4 -2
- package/vendor/node-sdk/dist/node-factory/define-reactive-node/types.d.ts +6 -7
- package/vendor/node-sdk/dist/node-factory/define-reactive-node/ui-schema-inference.d.ts +1 -1
- package/vendor/node-sdk/dist/testing/create-node-harness.d.ts +30 -0
- package/vendor/node-sdk/dist/testing/create-node-harness.js +68 -0
- package/vendor/node-sdk/package.json +5 -5
- package/vendor/runtime/dist/port-feed-override.d.ts +13 -0
- package/vendor/runtime/dist/port-feed-override.js +21 -0
- package/vendor/runtime/dist/port-signal-from-response.d.ts +15 -0
- package/vendor/runtime/dist/port-signal-from-response.js +48 -0
- package/vendor/runtime/dist/runtime-runner.d.ts +8 -3
- package/vendor/runtime/dist/runtime-runner.js +102 -81
- package/vendor/runtime/dist/runtime.d.ts +3 -2
- package/vendor/runtime/dist/runtime.js +1 -1
- package/vendor/runtime/dist/testing/workflows/workflow-events.d.ts +2 -0
- package/vendor/runtime/dist/testing/workflows/workflow-events.js +7 -5
- package/vendor/runtime/dist/types.d.ts +26 -9
- package/vendor/runtime/dist/types.js +3 -0
- package/vendor/runtime/package.json +1 -1
- package/vendor/server/dist/bootstrap/project-bootstrap.service.d.ts +1 -1
- package/vendor/server/dist/bootstrap/seed-skeleton.d.ts +2 -2
- package/vendor/server/dist/bootstrap/seed-skeleton.js +17 -2
- package/vendor/server/dist/bridge/bind-embed-context.d.ts +7 -0
- package/vendor/server/dist/bridge/bind-embed-context.js +16 -0
- package/vendor/server/dist/bridge/build-execution-context.d.ts +2 -2
- package/vendor/server/dist/bridge/build-execution-context.js +8 -4
- package/vendor/server/dist/bridge/emit-bootstrap.js +4 -1
- package/vendor/server/dist/bridge/get-live-wired-tools.d.ts +3 -2
- package/vendor/server/dist/bridge/get-live-wired-tools.js +8 -5
- package/vendor/server/dist/bridge/settings-draft-controller.js +7 -3
- package/vendor/server/dist/bridge/wire-config-handlers.js +2 -0
- package/vendor/server/dist/bridge/wire-editor-handlers.js +14 -0
- package/vendor/server/dist/bridge/wire-runner-handlers.js +2 -2
- package/vendor/server/dist/bridge/wire-workflow-handlers.js +23 -9
- package/vendor/server/dist/checkpoint/run-checkpoint-session.js +3 -3
- package/vendor/server/dist/config/langflower-config.service.d.ts +2 -0
- package/vendor/server/dist/config/langflower-config.service.js +30 -0
- package/vendor/server/dist/session/build-session-bootstrap.js +1 -0
- package/vendor/server/dist/session/langflower-session.d.ts +3 -1
- package/vendor/server/dist/session/langflower-session.js +3 -1
- package/vendor/server/dist/session/reset-session-execution-feed.d.ts +6 -0
- package/vendor/server/dist/session/reset-session-execution-feed.js +8 -0
- package/vendor/server/dist/workflow/apply-editor-mutation.js +6 -0
- package/vendor/server/dist/workflow/workflow-persisted-inputs.js +8 -6
- package/vendor/server/skeleton/instructions.md +15 -3
- package/vendor/server/skeleton/nodes/hello-embed/README.md +185 -0
- package/vendor/server/skeleton/nodes/hello-embed/hello-embed.nodes.test.ts +181 -0
- package/vendor/server/skeleton/nodes/hello-embed/ingest.ts +126 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/chunk-markdown.test.ts +40 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/chunk-markdown.ts +123 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/ingest-search.test.ts +180 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/ingest.ts +128 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/paths.test.ts +20 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/paths.ts +70 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/schema.ts +21 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/search.ts +254 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/text-embedder.ts +14 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/vectors.ts +126 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/walk-markdown.test.ts +62 -0
- package/vendor/server/skeleton/nodes/hello-embed/lib/walk-markdown.ts +67 -0
- package/vendor/server/skeleton/nodes/hello-embed/package.json +11 -0
- package/vendor/server/skeleton/nodes/hello-embed/search-handle.ts +119 -0
- package/vendor/server/skeleton/nodes/hello-embed/search.ts +98 -0
- package/vendor/server/skeleton/nodes/hello-embed/tsconfig.json +14 -0
- package/vendor/server/skeleton/nodes/my-nodes/README.md +13 -3
- package/vendor/server/skeleton/nodes/my-nodes/package.json +1 -1
- package/vendor/server/skeleton/schemas/langflower-config.schema.json +4 -0
- package/vendor/server/skeleton/skills/langflower-helper/SKILL.md +125 -38
- package/vendor/server/skeleton/skills/langflower-helper/architecture.md +17 -6
- package/vendor/server/skeleton/skills/langflower-helper/layout.md +24 -17
- package/vendor/server/skeleton/skills/langflower-node-writer/SKILL.md +8 -1
- package/vendor/server/skeleton/skills/langflower-workflow-writer/SKILL.md +12 -8
- package/vendor/server/skeleton/workflows/kb-create.json +15 -43
- package/vendor/server/skeleton/workflows/kb-ingest.json +88 -0
- package/vendor/server/skeleton/workflows/kb-manual-search.json +105 -0
- package/vendor/server/skeleton/workflows/kb-navigate.json +8 -22
- package/vendor/server/skeleton/workflows/kb-rag.json +192 -0
- package/vendor/server/skeleton/workflows/kb-tool.json +111 -0
- package/vendor/server/skeleton/workflows/simple-coder.json +18 -46
- package/vendor/server/skeleton/workflows/starter.json +8 -22
- package/vendor/shared/dist/execution/derive-run-settle-outcome.js +2 -2
- package/vendor/shared/dist/execution/derive-run-settle-outcome.test.js +11 -8
- package/vendor/shared/dist/langflower-bus-config.d.ts +22 -6
- package/vendor/shared/dist/langflower-bus-config.js +23 -7
- package/vendor/shared/dist/langflower-config/merge-langflower-config-layers.test.js +8 -0
- package/vendor/shared/dist/langflower-config/settings-draft.d.ts +5 -0
- package/vendor/shared/dist/langflower-config/settings-draft.js +15 -7
- package/vendor/shared/dist/langflower-ws-waits.d.ts +3 -1
- package/vendor/shared/dist/langflower-ws-waits.js +2 -2
- package/vendor/shared/dist/langflower.d.ts +1 -1
- package/vendor/shared/dist/langflower.js +1 -1
- package/vendor/shared/dist/types/langflower-bootstrap.d.ts +6 -0
- package/vendor/shared/dist/types/langflower-config.d.ts +17 -0
- package/vendor/shared/dist/types/langflower-palette.d.ts +2 -1
- package/vendor/tools/dist/harness-types.d.ts +1 -1
- package/vendor/tools/dist/mcp/build-mcp-handle.d.ts +3 -21
- package/vendor/tools/dist/mcp/build-mcp-handle.js +2 -11
- package/vendor/tools/dist/mcp/create-system-mcp-handles.d.ts +9 -6
- package/vendor/tools/dist/mcp/create-system-mcp-handles.js +21 -17
- package/vendor/tools/package.json +1 -0
- package/ui-dist/chunk-MDGXX73Z.js +0 -4
- package/ui-dist/main-2JJGCNTD.js +0 -313
- package/ui-dist/styles-EOXODOI7.css +0 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# hello-embed
|
|
2
|
+
|
|
3
|
+
Sample custom-node pack: index project markdown into a local SQLite store
|
|
4
|
+
(vectors + FTS5), retrieve with cosine + keyword (RRF), then pack full chunks
|
|
5
|
+
as Question + Context for a wire or an LLM tool.
|
|
6
|
+
|
|
7
|
+
Use the seeded workflows as-is for simple “search my `.md` files” tasks, or
|
|
8
|
+
copy this pack when you need a different corpus, chunker, or ranking.
|
|
9
|
+
|
|
10
|
+
## What it is
|
|
11
|
+
|
|
12
|
+
Three nodes in this folder, four workflows in `.langflower/workflows/`:
|
|
13
|
+
|
|
14
|
+
| Node | Role |
|
|
15
|
+
| --------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
16
|
+
| `hello-embed-ingest` | Walk `**/*.md`, split on headings, embed one chunk at a time → sqlite. Streaming `progress` + `finish`. |
|
|
17
|
+
| `hello-embed-search` | `query` + `embed` → `hits` (JSON) and `text` (`Question` + full-chunk `Context`). Hybrid cosine + FTS5, RRF. |
|
|
18
|
+
| `hello-embed-search-handle` | Same retrieve as an LLM tool: `project_search` on `tools`. |
|
|
19
|
+
|
|
20
|
+
| Workflow | Graph |
|
|
21
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
22
|
+
| `kb-ingest` | Embed provider → ingest → Finish. Progress in the feed. |
|
|
23
|
+
| `kb-manual-search` | Chat / string query → search → Preview. |
|
|
24
|
+
| `kb-tool` | `project_search` wired into an agent `tools` port. |
|
|
25
|
+
| `kb-rag` | Hybrid retrieve → packed `search.text` as LLM `userPrompt`; `project_search` + grep/read; Review ⇄ feedback; Approve → Finish. |
|
|
26
|
+
|
|
27
|
+
The index file defaults to `.langflower/.cache/hello-embed/kb.sqlite` (outside
|
|
28
|
+
this pack, so bootstrap force-seed does not wipe vectors). All three nodes
|
|
29
|
+
share that path via `DEFAULT_SQLITE_PATH` in `lib/paths.ts`.
|
|
30
|
+
|
|
31
|
+
Walk skips `node_modules`, `.git`, and `.langflower/.cache`. Embeddings come
|
|
32
|
+
from a wired `common-embed-provider` (`EmbedHandle`). The pack never sees API
|
|
33
|
+
keys.
|
|
34
|
+
|
|
35
|
+
## How to use
|
|
36
|
+
|
|
37
|
+
1. **Settings → Default embedding model** (OpenAI-compatible embeddings).
|
|
38
|
+
2. Run **KB ingest**. Re-run it after you add/edit markdown or switch the
|
|
39
|
+
embedding model (dimension mismatch is rejected). An index built before
|
|
40
|
+
embeddings `encoding_format: 'float'` may store all-zero vectors — search
|
|
41
|
+
then scores every hit `0.00`; wipe by running ingest again on a current
|
|
42
|
+
Langflower build.
|
|
43
|
+
3. Then run **KB manual search**, **KB tool**, or **KB RAG**.
|
|
44
|
+
|
|
45
|
+
`langflower start` compiles custom packs on startup, including this one.
|
|
46
|
+
After you edit the pack, Helper `compile_custom_nodes`, Custom → **Update**,
|
|
47
|
+
or restart.
|
|
48
|
+
|
|
49
|
+
Place **one** `common-embed-provider` and fan-out **embed** into ingest and/or
|
|
50
|
+
search nodes. Ingest uses the handle with role `document`; search / the tool
|
|
51
|
+
use role `query`.
|
|
52
|
+
|
|
53
|
+
Optional panel fields: `sqlitePath`, ingest `sourceDir` (empty = project
|
|
54
|
+
root), search `topK`.
|
|
55
|
+
|
|
56
|
+
## KB RAG
|
|
57
|
+
|
|
58
|
+
RAG means **retrieval-augmented generation**. An LLM does not automatically
|
|
59
|
+
know your private or recently changed project documents. RAG finds the most
|
|
60
|
+
relevant passages first and sends them to the LLM together with the question.
|
|
61
|
+
The model can then answer from that context instead of relying only on what it
|
|
62
|
+
learned during training.
|
|
63
|
+
|
|
64
|
+
RAG is useful when answers must come from a specific knowledge base. It can
|
|
65
|
+
find synonyms and close-in-meaning phrases that plain text matching may miss,
|
|
66
|
+
and it gives the model evidence it can quote and cite. It does not train or
|
|
67
|
+
change the model; it supplies relevant information for the current answer.
|
|
68
|
+
|
|
69
|
+
### Two phases
|
|
70
|
+
|
|
71
|
+
RAG has a preparation phase and a question-answering phase.
|
|
72
|
+
|
|
73
|
+
1. **Ingest the documents.** Read the knowledge base, split it into useful
|
|
74
|
+
passages, create embeddings, and save a searchable index. Run **KB ingest**
|
|
75
|
+
before using any search or RAG workflow. Without the index there are no
|
|
76
|
+
passages to retrieve, so `kb-rag` cannot answer from the project docs.
|
|
77
|
+
Run ingest again after the docs or embedding model change.
|
|
78
|
+
2. **Retrieve, then generate.** Embed the question, search the index, rank the
|
|
79
|
+
best passages, and pack them into `Context`. Send the question and Context
|
|
80
|
+
to the LLM, which writes an answer grounded in those passages.
|
|
81
|
+
|
|
82
|
+
### Glossary
|
|
83
|
+
|
|
84
|
+
- **Ingest.** Walk the corpus once, split it, embed it, and write an index. In
|
|
85
|
+
this pack: `hello-embed-ingest` / **KB ingest**.
|
|
86
|
+
- **Chunks.** The retrieval unit — a slice of a document, not the whole file.
|
|
87
|
+
Split so one hit is about one thing: a whole README in Context can drown the
|
|
88
|
+
answer, while a tiny fragment may not contain enough evidence. This pack
|
|
89
|
+
uses one markdown heading plus its body.
|
|
90
|
+
- **Vectors.** Fixed-length lists of numbers produced by an embedding model.
|
|
91
|
+
Texts with close meanings have nearby vectors.
|
|
92
|
+
- **FTS5.** SQLite full-text search. It matches exact keywords and tokens in
|
|
93
|
+
headings and bodies.
|
|
94
|
+
- **Cosine similarity.** A way to measure how close two vectors are. This
|
|
95
|
+
sample uses it to rank chunks by meaning.
|
|
96
|
+
- **RRF** (reciprocal rank fusion). A method for combining the vector-search
|
|
97
|
+
and keyword-search rankings into one result list.
|
|
98
|
+
- **Reranking.** A second scoring step that reorders retrieved passages and
|
|
99
|
+
removes weak results before they are sent to the LLM.
|
|
100
|
+
- **HyDE** (Hypothetical Document Embeddings). The LLM drafts a possible
|
|
101
|
+
answer, and that draft is embedded and used to search for related passages.
|
|
102
|
+
|
|
103
|
+
### Implemented in this sample
|
|
104
|
+
|
|
105
|
+
- **Document ingest.** `hello-embed-ingest` reads project markdown and creates
|
|
106
|
+
a fresh local index.
|
|
107
|
+
- **Chunking.** Each markdown heading and its body becomes one chunk. Chunks
|
|
108
|
+
keep retrieval focused: a whole file may cover too many topics, while a tiny
|
|
109
|
+
fragment may not contain enough evidence.
|
|
110
|
+
- **Embeddings.** The embedding model turns each chunk and the question into
|
|
111
|
+
vectors. Texts with similar meanings get nearby vectors, so search can find
|
|
112
|
+
a relevant passage even when it uses different words.
|
|
113
|
+
- **Searchable index.** SQLite stores the prepared chunks, vectors, and
|
|
114
|
+
text-search data. Questions query this store instead of rebuilding
|
|
115
|
+
embeddings for the whole corpus.
|
|
116
|
+
- **Hybrid retrieval.** Cosine similarity finds passages with similar
|
|
117
|
+
meaning. FTS5 finds exact words such as API names. RRF combines both ranked
|
|
118
|
+
lists so either kind of match can reach the final results.
|
|
119
|
+
- **Context packing.** The best full chunks are formatted as `Question` +
|
|
120
|
+
`Context`. The LLM receives complete section bodies rather than isolated
|
|
121
|
+
matching lines.
|
|
122
|
+
- **Grounded generation.** `kb-rag` always retrieves before the first LLM
|
|
123
|
+
turn. The system prompt asks the agent to answer from Context and cite each
|
|
124
|
+
source by path and heading.
|
|
125
|
+
- **Optional multi-hop retrieval.** After reading the initial Context, the
|
|
126
|
+
agent can call `project_search` with a new query based on what it learned.
|
|
127
|
+
This creates a second retrieval hop when needed, but the tool may not be
|
|
128
|
+
called on every run.
|
|
129
|
+
|
|
130
|
+
### Extra behavior in `kb-rag`
|
|
131
|
+
|
|
132
|
+
- `grep`, `read`, and `glob` let the agent open the source around a retrieved
|
|
133
|
+
path when it needs more detail.
|
|
134
|
+
- Review sends human feedback back to the same agent session; Approve sends
|
|
135
|
+
the accepted response to Finish.
|
|
136
|
+
|
|
137
|
+
For comparison, **KB manual search** stops after showing retrieved hits.
|
|
138
|
+
**KB tool** gives the model a search tool but lets it decide whether to use it.
|
|
139
|
+
**KB RAG** always performs the first retrieval before generation.
|
|
140
|
+
|
|
141
|
+
### Not in this sample
|
|
142
|
+
|
|
143
|
+
- **Reranking** with a second model to remove weak results before building
|
|
144
|
+
Context (TBD-009 in `docs/TBD.md`).
|
|
145
|
+
- **HyDE or query rewriting** to improve retrieval for vague questions.
|
|
146
|
+
- **Overlapping or token-window chunks** for documents that do not divide
|
|
147
|
+
cleanly at headings.
|
|
148
|
+
- **Token-budgeted Context** to fit results into smaller model windows.
|
|
149
|
+
- **Automated evaluation** for retrieval quality and answer faithfulness.
|
|
150
|
+
|
|
151
|
+
Copy the pack if you need those. This workflow is a compact RAG example, not a
|
|
152
|
+
complete knowledge-base product.
|
|
153
|
+
|
|
154
|
+
## Adapt for your case
|
|
155
|
+
|
|
156
|
+
Keep this folder as the sample. For a domain-specific index, copy the pack,
|
|
157
|
+
rename the `type` strings, and change the pieces you actually need:
|
|
158
|
+
|
|
159
|
+
| You need | Start from | Typical change |
|
|
160
|
+
| ------------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
161
|
+
| Other files (`.ts`, PDFs, XML) | `lib/walk-markdown.ts` | Filter, parser, skip list |
|
|
162
|
+
| Different chunking | `lib/chunk-markdown.ts` | Size windows, code symbols, breadcrumbs |
|
|
163
|
+
| Another store or path | `lib/schema.ts`, `lib/paths.ts` | Table layout, default path, wipe vs upsert |
|
|
164
|
+
| Hybrid retrieve | `lib/search.ts`, `lib/schema.ts` | Cosine + FTS5 fused with RRF (`k = 60`). `hits[].score` is RRF, not raw cosine. Candidate pool `max(topK * 4, 32)` then slice top-K. |
|
|
165
|
+
| Graph search (Preview / Finish) | `search.ts` + `kb-manual-search` | `text` is Question + full-chunk Context (same packing as RAG) |
|
|
166
|
+
| Agent-callable search | `search-handle.ts` + `kb-tool` | ToolHandle `invoke` → packed `text`; sqlite path from params |
|
|
167
|
+
| Retrieve-then-generate | `kb-rag` | Forced hybrid retrieve into LLM `userPrompt`. Wire `hello-embed-search-handle` for a second retrieve; grep/read for files. Review `feedback` → agent; Approve `response` → Finish. Do **not** zip with `common-concat` (`multi: 'zip'` stalls) |
|
|
168
|
+
| Streaming ingest progress | `ingest.ts` | `defineReactiveNode`; `progress` `{ role: 'progress', streaming: true }` (not `result` bubbles); `finish` `{ role: 'none'` } |
|
|
169
|
+
| Safer Stop / resume | ingest embed loop | Sequential one-chunk `embedTexts` (this sample) |
|
|
170
|
+
|
|
171
|
+
Contracts to keep:
|
|
172
|
+
|
|
173
|
+
- Wire **`EmbedHandle`** from `common-embed-provider`, not a raw HTTP client
|
|
174
|
+
and not a `ToolHandle`.
|
|
175
|
+
- Close over resolved `sqlitePath` from `ctx.params` in the tool handler —
|
|
176
|
+
do not put pack paths on `ToolHandlerContext`.
|
|
177
|
+
- One shared default path constant; do not duplicate the string in nodes or
|
|
178
|
+
workflows.
|
|
179
|
+
- Internal `from './file.ts'` needs `allowImportingTsExtensions` (already set
|
|
180
|
+
here, with `noEmit`). Without it `tsc --noEmit` fails and the pack does
|
|
181
|
+
not compile.
|
|
182
|
+
|
|
183
|
+
Host peers stay `@langflower/node-sdk` / `rxjs` / `@rx-evo/stateful-observable`
|
|
184
|
+
(same as `my-nodes`). Add author `dependencies` only if you pull extra
|
|
185
|
+
libraries, then `npm install` inside the pack.
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { EmbedHandle, ToolHandle } from '@langflower/node-sdk';
|
|
2
|
+
import { createNodeHarness } from '@langflower/node-sdk/testing';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
7
|
+
import ingestNode from './ingest.ts';
|
|
8
|
+
import { DEFAULT_SQLITE_PATH } from './lib/paths.ts';
|
|
9
|
+
import { l2Normalize } from './lib/vectors.ts';
|
|
10
|
+
import searchNode from './search.ts';
|
|
11
|
+
import searchHandleNode from './search-handle.ts';
|
|
12
|
+
|
|
13
|
+
const sqliteDefaultOf = (definition: {
|
|
14
|
+
readonly uiSchema: readonly {
|
|
15
|
+
readonly field: string;
|
|
16
|
+
readonly default?: unknown;
|
|
17
|
+
}[];
|
|
18
|
+
}): unknown =>
|
|
19
|
+
definition.uiSchema.find((item) => item.field === 'sqlitePath')?.default;
|
|
20
|
+
|
|
21
|
+
const fakeHandle = (): EmbedHandle => ({
|
|
22
|
+
dim: 8,
|
|
23
|
+
embedTexts: async (texts) =>
|
|
24
|
+
texts.map((text) => {
|
|
25
|
+
const values = new Array<number>(8).fill(0);
|
|
26
|
+
if (text.toLowerCase().includes('alpha')) {
|
|
27
|
+
values[0] = 1;
|
|
28
|
+
} else if (text.toLowerCase().includes('beta')) {
|
|
29
|
+
values[1] = 1;
|
|
30
|
+
} else {
|
|
31
|
+
values[7] = 1;
|
|
32
|
+
}
|
|
33
|
+
return l2Normalize(values);
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('hello-embed nodes', () => {
|
|
38
|
+
let root: string;
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'hello-embed-node-'));
|
|
42
|
+
await fs.writeFile(
|
|
43
|
+
path.join(root, 'notes.md'),
|
|
44
|
+
'# Alpha\nalpha body\n',
|
|
45
|
+
'utf8',
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(async () => {
|
|
50
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('shares DEFAULT_SQLITE_PATH as uiSchema default', () => {
|
|
54
|
+
expect(sqliteDefaultOf(ingestNode)).toBe(DEFAULT_SQLITE_PATH);
|
|
55
|
+
expect(sqliteDefaultOf(searchNode)).toBe(DEFAULT_SQLITE_PATH);
|
|
56
|
+
expect(sqliteDefaultOf(searchHandleNode)).toBe(DEFAULT_SQLITE_PATH);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('marks ingest progress as a growing progress stream', () => {
|
|
60
|
+
const progress = ingestNode.outputsConfigs.find(
|
|
61
|
+
(port) => port.portId === 'progress',
|
|
62
|
+
);
|
|
63
|
+
expect(progress?.feed).toEqual({
|
|
64
|
+
role: 'progress',
|
|
65
|
+
streaming: true,
|
|
66
|
+
});
|
|
67
|
+
const finish = ingestNode.outputsConfigs.find(
|
|
68
|
+
(port) => port.portId === 'finish',
|
|
69
|
+
);
|
|
70
|
+
expect(finish?.feed).toEqual({ role: 'none' });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('ingests then searches through createNodeHarness', async () => {
|
|
74
|
+
const sqliteRel = 'index.sqlite';
|
|
75
|
+
const ingest = createNodeHarness(ingestNode, {
|
|
76
|
+
projectDir: root,
|
|
77
|
+
params: { sqlitePath: sqliteRel },
|
|
78
|
+
});
|
|
79
|
+
const progress = ingest.collect<string>('progress');
|
|
80
|
+
const finished = ingest.next<boolean>('finish');
|
|
81
|
+
ingest.send('embed', fakeHandle());
|
|
82
|
+
ingest.send('trigger', true);
|
|
83
|
+
await expect(finished).resolves.toBe(true);
|
|
84
|
+
expect(progress.values.some((line) => line.includes('notes.md'))).toBe(
|
|
85
|
+
true,
|
|
86
|
+
);
|
|
87
|
+
ingest.dispose();
|
|
88
|
+
|
|
89
|
+
const search = createNodeHarness(searchNode, {
|
|
90
|
+
projectDir: root,
|
|
91
|
+
params: { sqlitePath: sqliteRel, topK: 8 },
|
|
92
|
+
});
|
|
93
|
+
const text = search.next<string>('text');
|
|
94
|
+
const hits = search.next<readonly { heading: string }[]>('hits');
|
|
95
|
+
search.send('embed', fakeHandle());
|
|
96
|
+
search.send('query', 'alpha');
|
|
97
|
+
await expect(text).resolves.toContain('Question:\nalpha');
|
|
98
|
+
await expect(text).resolves.toContain('Context:');
|
|
99
|
+
await expect(text).resolves.toContain('alpha body');
|
|
100
|
+
await expect(hits).resolves.toEqual(
|
|
101
|
+
expect.arrayContaining([
|
|
102
|
+
expect.objectContaining({ heading: 'Alpha' }),
|
|
103
|
+
]),
|
|
104
|
+
);
|
|
105
|
+
search.dispose();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('search-handle emits project_search against the same sqlite path', async () => {
|
|
109
|
+
const sqliteRel = 'index.sqlite';
|
|
110
|
+
const ingest = createNodeHarness(ingestNode, {
|
|
111
|
+
projectDir: root,
|
|
112
|
+
params: { sqlitePath: sqliteRel },
|
|
113
|
+
});
|
|
114
|
+
const finished = ingest.next<boolean>('finish');
|
|
115
|
+
ingest.send('embed', fakeHandle());
|
|
116
|
+
ingest.send('trigger', true);
|
|
117
|
+
await finished;
|
|
118
|
+
ingest.dispose();
|
|
119
|
+
|
|
120
|
+
const handle = createNodeHarness(searchHandleNode, {
|
|
121
|
+
projectDir: root,
|
|
122
|
+
params: { sqlitePath: sqliteRel },
|
|
123
|
+
});
|
|
124
|
+
const toolsP = handle.next<readonly ToolHandle[]>('tools');
|
|
125
|
+
handle.send('embed', fakeHandle());
|
|
126
|
+
const tools = await toolsP;
|
|
127
|
+
const projectSearch = tools.find(
|
|
128
|
+
(tool) => tool.toolId === 'project_search',
|
|
129
|
+
);
|
|
130
|
+
expect(projectSearch).toBeDefined();
|
|
131
|
+
const text = await projectSearch?.invoke(
|
|
132
|
+
{ query: 'alpha' },
|
|
133
|
+
{
|
|
134
|
+
projectDir: root,
|
|
135
|
+
runId: 'test',
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
expect(text).toContain('Question:\nalpha');
|
|
139
|
+
expect(text).toContain('alpha body');
|
|
140
|
+
handle.dispose();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('search-handle returns full chunk bodies', async () => {
|
|
144
|
+
const longBody = `alpha ${'y'.repeat(280)}`;
|
|
145
|
+
await fs.writeFile(
|
|
146
|
+
path.join(root, 'notes.md'),
|
|
147
|
+
`# Alpha\n${longBody}\n`,
|
|
148
|
+
'utf8',
|
|
149
|
+
);
|
|
150
|
+
const sqliteRel = 'index.sqlite';
|
|
151
|
+
const ingest = createNodeHarness(ingestNode, {
|
|
152
|
+
projectDir: root,
|
|
153
|
+
params: { sqlitePath: sqliteRel },
|
|
154
|
+
});
|
|
155
|
+
const finished = ingest.next<boolean>('finish');
|
|
156
|
+
ingest.send('embed', fakeHandle());
|
|
157
|
+
ingest.send('trigger', true);
|
|
158
|
+
await finished;
|
|
159
|
+
ingest.dispose();
|
|
160
|
+
|
|
161
|
+
const handle = createNodeHarness(searchHandleNode, {
|
|
162
|
+
projectDir: root,
|
|
163
|
+
params: { sqlitePath: sqliteRel },
|
|
164
|
+
});
|
|
165
|
+
const toolsP = handle.next<readonly ToolHandle[]>('tools');
|
|
166
|
+
handle.send('embed', fakeHandle());
|
|
167
|
+
const tools = await toolsP;
|
|
168
|
+
const projectSearch = tools.find(
|
|
169
|
+
(tool) => tool.toolId === 'project_search',
|
|
170
|
+
);
|
|
171
|
+
const packed = await projectSearch?.invoke(
|
|
172
|
+
{ query: 'alpha' },
|
|
173
|
+
{
|
|
174
|
+
projectDir: root,
|
|
175
|
+
runId: 'test',
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
expect(packed).toContain(longBody);
|
|
179
|
+
handle.dispose();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineReactiveNode,
|
|
3
|
+
EMBED_HANDLE_WIRE_TYPE,
|
|
4
|
+
isEmbedHandle,
|
|
5
|
+
} from '@langflower/node-sdk';
|
|
6
|
+
import { filter, from, map, switchMap } from 'rxjs';
|
|
7
|
+
import { runIngest, type IngestEvent } from './lib/ingest.ts';
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_SQLITE_PATH,
|
|
10
|
+
resolveSqlitePath,
|
|
11
|
+
resolveUnderProject,
|
|
12
|
+
} from './lib/paths.ts';
|
|
13
|
+
import { textEmbedderFromHandle } from './lib/text-embedder.ts';
|
|
14
|
+
|
|
15
|
+
const asString = (value: unknown, fallback: string): string => {
|
|
16
|
+
if (typeof value === 'string') {
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
return fallback;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type IngestBundle = {
|
|
23
|
+
readonly embedInput: unknown;
|
|
24
|
+
readonly projectDir: string;
|
|
25
|
+
readonly sqlitePath: string;
|
|
26
|
+
readonly sourceDir: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const ingestEvents = (bundle: IngestBundle): AsyncIterable<IngestEvent> => {
|
|
30
|
+
if (!isEmbedHandle(bundle.embedInput)) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
'hello-embed-ingest requires a wired embed input from common-embed-provider.',
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
if (bundle.projectDir.length === 0) {
|
|
36
|
+
throw new Error('hello-embed-ingest requires ctx.projectDir.');
|
|
37
|
+
}
|
|
38
|
+
return runIngest({
|
|
39
|
+
sqlitePath: resolveSqlitePath(bundle.projectDir, bundle.sqlitePath),
|
|
40
|
+
sourceDir: resolveUnderProject(bundle.projectDir, bundle.sourceDir),
|
|
41
|
+
embedder: textEmbedderFromHandle(bundle.embedInput, 'document'),
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Walk project markdown, embed heading chunks, write sqlite vectors.
|
|
47
|
+
*/
|
|
48
|
+
export default defineReactiveNode({
|
|
49
|
+
type: 'hello-embed-ingest',
|
|
50
|
+
displayName: 'Hello Embed Ingest',
|
|
51
|
+
category: 'Hello Embed',
|
|
52
|
+
description: `
|
|
53
|
+
Index project markdown into a local SQLite vector store.
|
|
54
|
+
|
|
55
|
+
Walks \`**/*.md\` (skips node_modules, .git, .langflower/.cache), splits on headings, and embeds one chunk at a time. Progress is a technical stream (\`feed.role: 'progress'\`, \`streaming: true\` — same growing layout as reasoning, caption PROGRESS, not result bubbles). Wire **embed** from common-embed-provider. **finish** fires when the index is written.
|
|
56
|
+
`.trim(),
|
|
57
|
+
uiSchema: [
|
|
58
|
+
{
|
|
59
|
+
field: 'sqlitePath',
|
|
60
|
+
type: 'string',
|
|
61
|
+
label: 'SQLite path',
|
|
62
|
+
default: DEFAULT_SQLITE_PATH,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
field: 'sourceDir',
|
|
66
|
+
type: 'string',
|
|
67
|
+
label: 'Source folder',
|
|
68
|
+
default: '',
|
|
69
|
+
},
|
|
70
|
+
] as const,
|
|
71
|
+
bind(ctx, { makeInput, configureOutput, combineInputs }) {
|
|
72
|
+
const trigger = makeInput<unknown>('trigger', {
|
|
73
|
+
name: 'trigger',
|
|
74
|
+
dynamic: true,
|
|
75
|
+
required: true,
|
|
76
|
+
description: 'Emit to run ingest.',
|
|
77
|
+
});
|
|
78
|
+
const embed = makeInput<unknown>('embed', {
|
|
79
|
+
name: 'embed',
|
|
80
|
+
wireType: EMBED_HANDLE_WIRE_TYPE,
|
|
81
|
+
required: true,
|
|
82
|
+
description: 'Wire from common-embed-provider (fan-out OK).',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const session$ = combineInputs(
|
|
86
|
+
[trigger, embed, ctx],
|
|
87
|
+
([_trigger, embedInput, ec]) => ({
|
|
88
|
+
embedInput,
|
|
89
|
+
projectDir: String(ec.projectDir ?? ''),
|
|
90
|
+
sqlitePath: asString(ec.params.sqlitePath, DEFAULT_SQLITE_PATH),
|
|
91
|
+
sourceDir: asString(ec.params.sourceDir, ''),
|
|
92
|
+
}),
|
|
93
|
+
).pipeValue(switchMap((bundle) => from(ingestEvents(bundle))));
|
|
94
|
+
|
|
95
|
+
const progress$ = session$.pipeValue(
|
|
96
|
+
filter(
|
|
97
|
+
(event): event is Extract<IngestEvent, { kind: 'progress' }> =>
|
|
98
|
+
event.kind === 'progress',
|
|
99
|
+
),
|
|
100
|
+
map((event) =>
|
|
101
|
+
event.text.endsWith('\n') ? event.text : `${event.text}\n`,
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
const finish$ = session$.pipeValue(
|
|
105
|
+
filter(
|
|
106
|
+
(event): event is Extract<IngestEvent, { kind: 'finish' }> =>
|
|
107
|
+
event.kind === 'finish',
|
|
108
|
+
),
|
|
109
|
+
map(() => true),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
inputs: [trigger, embed],
|
|
114
|
+
outputs: [
|
|
115
|
+
configureOutput('progress', progress$, {
|
|
116
|
+
wireType: 'string',
|
|
117
|
+
feed: { role: 'progress', streaming: true },
|
|
118
|
+
}),
|
|
119
|
+
configureOutput('finish', finish$, {
|
|
120
|
+
wireType: 'boolean',
|
|
121
|
+
feed: { role: 'none' },
|
|
122
|
+
}),
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { chunkMarkdown } from './chunk-markdown.ts';
|
|
3
|
+
|
|
4
|
+
describe('chunkMarkdown', () => {
|
|
5
|
+
it('keeps a heading-less file as one intro chunk', () => {
|
|
6
|
+
const chunks = chunkMarkdown('notes.md', 'hello world\n');
|
|
7
|
+
expect(chunks).toHaveLength(1);
|
|
8
|
+
expect(chunks[0]?.heading).toBe('');
|
|
9
|
+
expect(chunks[0]?.id).toBe('notes.md#intro#0');
|
|
10
|
+
expect(chunks[0]?.text).toBe('hello world');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('splits on headings and builds breadcrumbs', () => {
|
|
14
|
+
const source = [
|
|
15
|
+
'preamble',
|
|
16
|
+
'# Alpha',
|
|
17
|
+
'a body',
|
|
18
|
+
'## Beta',
|
|
19
|
+
'b body',
|
|
20
|
+
'# Gamma',
|
|
21
|
+
'c body',
|
|
22
|
+
].join('\n');
|
|
23
|
+
const chunks = chunkMarkdown('doc.md', source);
|
|
24
|
+
expect(chunks.map((chunk) => chunk.heading)).toEqual([
|
|
25
|
+
'',
|
|
26
|
+
'Alpha',
|
|
27
|
+
'Alpha > Beta',
|
|
28
|
+
'Gamma',
|
|
29
|
+
]);
|
|
30
|
+
expect(chunks[2]?.id).toBe('doc.md#alpha-beta#2');
|
|
31
|
+
expect(chunks[2]?.embedText).toContain('doc.md');
|
|
32
|
+
expect(chunks[2]?.text).toBe('b body');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('skips empty heading bodies', () => {
|
|
36
|
+
const chunks = chunkMarkdown('empty.md', '# Only\n\n# Next\ntext\n');
|
|
37
|
+
expect(chunks).toHaveLength(1);
|
|
38
|
+
expect(chunks[0]?.heading).toBe('Next');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { MAX_CHUNKS_PER_FILE } from './paths.ts';
|
|
2
|
+
|
|
3
|
+
export type MarkdownChunk = {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly path: string;
|
|
6
|
+
readonly heading: string;
|
|
7
|
+
readonly text: string;
|
|
8
|
+
readonly embedText: string;
|
|
9
|
+
readonly truncated?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const HEADING_RE = /^(#{1,6})\s+(.*)$/;
|
|
13
|
+
|
|
14
|
+
const slugHeading = (heading: string): string => {
|
|
15
|
+
const slug = heading
|
|
16
|
+
.trim()
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
19
|
+
.replace(/^-+|-+$/g, '');
|
|
20
|
+
return slug.length > 0 ? slug : 'intro';
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const displayHeading = (heading: string): string =>
|
|
24
|
+
heading.length > 0 ? heading : '(intro)';
|
|
25
|
+
|
|
26
|
+
type OpenSection = {
|
|
27
|
+
readonly depth: number;
|
|
28
|
+
readonly title: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const breadcrumbOf = (stack: readonly OpenSection[]): string =>
|
|
32
|
+
stack.map((section) => section.title).join(' > ');
|
|
33
|
+
|
|
34
|
+
const flushBody = (
|
|
35
|
+
relPath: string,
|
|
36
|
+
heading: string,
|
|
37
|
+
body: string,
|
|
38
|
+
ordinal: number,
|
|
39
|
+
out: MarkdownChunk[],
|
|
40
|
+
): number => {
|
|
41
|
+
const text = body.trim();
|
|
42
|
+
if (text.length === 0) {
|
|
43
|
+
return ordinal;
|
|
44
|
+
}
|
|
45
|
+
const id = `${relPath}#${slugHeading(heading)}#${String(ordinal)}`;
|
|
46
|
+
out.push({
|
|
47
|
+
id,
|
|
48
|
+
path: relPath,
|
|
49
|
+
heading,
|
|
50
|
+
text,
|
|
51
|
+
embedText: `${relPath}\n${displayHeading(heading)}\n\n${text}`,
|
|
52
|
+
});
|
|
53
|
+
return ordinal + 1;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Split markdown into heading chunks. Preamble before the first heading
|
|
58
|
+
* uses heading `""`. Heading-less files become one chunk.
|
|
59
|
+
*/
|
|
60
|
+
export const chunkMarkdown = (
|
|
61
|
+
relPath: string,
|
|
62
|
+
source: string,
|
|
63
|
+
): readonly MarkdownChunk[] => {
|
|
64
|
+
const lines = source.replaceAll('\r\n', '\n').split('\n');
|
|
65
|
+
const out: MarkdownChunk[] = [];
|
|
66
|
+
const stack: OpenSection[] = [];
|
|
67
|
+
let currentHeading = '';
|
|
68
|
+
let body: string[] = [];
|
|
69
|
+
let ordinal = 0;
|
|
70
|
+
let truncated: string | undefined;
|
|
71
|
+
|
|
72
|
+
const flush = (): void => {
|
|
73
|
+
if (out.length >= MAX_CHUNKS_PER_FILE) {
|
|
74
|
+
if (truncated === undefined) {
|
|
75
|
+
truncated = `capped ${relPath} at ${String(MAX_CHUNKS_PER_FILE)} chunks`;
|
|
76
|
+
}
|
|
77
|
+
body = [];
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
ordinal = flushBody(
|
|
81
|
+
relPath,
|
|
82
|
+
currentHeading,
|
|
83
|
+
body.join('\n'),
|
|
84
|
+
ordinal,
|
|
85
|
+
out,
|
|
86
|
+
);
|
|
87
|
+
body = [];
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
for (const line of lines) {
|
|
91
|
+
const match = HEADING_RE.exec(line);
|
|
92
|
+
if (match === null) {
|
|
93
|
+
body.push(line);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
flush();
|
|
97
|
+
if (out.length >= MAX_CHUNKS_PER_FILE) {
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
const marks = match[1] ?? '#';
|
|
101
|
+
const title = (match[2] ?? '').trim();
|
|
102
|
+
const depth = marks.length;
|
|
103
|
+
while (
|
|
104
|
+
stack.length > 0 &&
|
|
105
|
+
(stack[stack.length - 1]?.depth ?? 0) >= depth
|
|
106
|
+
) {
|
|
107
|
+
stack.pop();
|
|
108
|
+
}
|
|
109
|
+
stack.push({ depth, title });
|
|
110
|
+
currentHeading = breadcrumbOf(stack);
|
|
111
|
+
}
|
|
112
|
+
if (out.length < MAX_CHUNKS_PER_FILE) {
|
|
113
|
+
flush();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (truncated !== undefined && out[0] !== undefined) {
|
|
117
|
+
const last = out[out.length - 1];
|
|
118
|
+
if (last !== undefined) {
|
|
119
|
+
out[out.length - 1] = { ...last, truncated };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
};
|