harper-knowledge 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/LICENSE +21 -0
- package/README.md +276 -0
- package/config.yaml +17 -0
- package/dist/core/embeddings.d.ts +29 -0
- package/dist/core/embeddings.js +199 -0
- package/dist/core/entries.d.ts +85 -0
- package/dist/core/entries.js +235 -0
- package/dist/core/history.d.ts +30 -0
- package/dist/core/history.js +119 -0
- package/dist/core/search.d.ts +23 -0
- package/dist/core/search.js +306 -0
- package/dist/core/tags.d.ts +32 -0
- package/dist/core/tags.js +76 -0
- package/dist/core/triage.d.ts +55 -0
- package/dist/core/triage.js +126 -0
- package/dist/http-utils.d.ts +37 -0
- package/dist/http-utils.js +132 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +76 -0
- package/dist/mcp/server.d.ts +24 -0
- package/dist/mcp/server.js +124 -0
- package/dist/mcp/tools.d.ts +13 -0
- package/dist/mcp/tools.js +497 -0
- package/dist/oauth/authorize.d.ts +27 -0
- package/dist/oauth/authorize.js +438 -0
- package/dist/oauth/github.d.ts +28 -0
- package/dist/oauth/github.js +62 -0
- package/dist/oauth/keys.d.ts +33 -0
- package/dist/oauth/keys.js +100 -0
- package/dist/oauth/metadata.d.ts +21 -0
- package/dist/oauth/metadata.js +55 -0
- package/dist/oauth/middleware.d.ts +22 -0
- package/dist/oauth/middleware.js +64 -0
- package/dist/oauth/register.d.ts +14 -0
- package/dist/oauth/register.js +83 -0
- package/dist/oauth/token.d.ts +15 -0
- package/dist/oauth/token.js +178 -0
- package/dist/oauth/validate.d.ts +30 -0
- package/dist/oauth/validate.js +52 -0
- package/dist/resources/HistoryResource.d.ts +38 -0
- package/dist/resources/HistoryResource.js +38 -0
- package/dist/resources/KnowledgeEntryResource.d.ts +64 -0
- package/dist/resources/KnowledgeEntryResource.js +157 -0
- package/dist/resources/QueryLogResource.d.ts +20 -0
- package/dist/resources/QueryLogResource.js +57 -0
- package/dist/resources/ServiceKeyResource.d.ts +51 -0
- package/dist/resources/ServiceKeyResource.js +132 -0
- package/dist/resources/TagResource.d.ts +25 -0
- package/dist/resources/TagResource.js +32 -0
- package/dist/resources/TriageResource.d.ts +51 -0
- package/dist/resources/TriageResource.js +107 -0
- package/dist/types.d.ts +317 -0
- package/dist/types.js +7 -0
- package/dist/webhooks/datadog.d.ts +26 -0
- package/dist/webhooks/datadog.js +120 -0
- package/dist/webhooks/github.d.ts +24 -0
- package/dist/webhooks/github.js +167 -0
- package/dist/webhooks/middleware.d.ts +14 -0
- package/dist/webhooks/middleware.js +161 -0
- package/dist/webhooks/types.d.ts +17 -0
- package/dist/webhooks/types.js +4 -0
- package/package.json +72 -0
- package/schema/knowledge.graphql +134 -0
- package/web/index.html +735 -0
- package/web/js/app.js +461 -0
- package/web/js/detail.js +223 -0
- package/web/js/editor.js +303 -0
- package/web/js/search.js +238 -0
- package/web/js/triage.js +305 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nathan Heskew
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# harper-knowledge
|
|
2
|
+
|
|
3
|
+
Knowledge base for [Harper](https://harper.fast/), built on Harper, with MCP server integration.
|
|
4
|
+
|
|
5
|
+
A Harper sub-component plugin that provides searchable, scoped knowledge entries with vector embeddings for semantic search. Exposes a REST API, MCP endpoint, and web UI.
|
|
6
|
+
|
|
7
|
+
## Consumers
|
|
8
|
+
|
|
9
|
+
- **Support team** — finding solutions, patterns, gotchas, customer edge cases
|
|
10
|
+
- **DX lab "Harper expert"** — backing knowledge for the AI expert role in Gas Town labs
|
|
11
|
+
- **Claude Code / IDE assistants** — Harper context via MCP without per-project CLAUDE.md files
|
|
12
|
+
- **Any MCP client** — Cursor, VS Code + Copilot, JetBrains, ChatGPT, Gemini, etc.
|
|
13
|
+
|
|
14
|
+
## Quick Start
|
|
15
|
+
|
|
16
|
+
### Prerequisites
|
|
17
|
+
|
|
18
|
+
- [Harper](https://harper.fast/) >= 4.7.0
|
|
19
|
+
- Node.js >= 22
|
|
20
|
+
|
|
21
|
+
### Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install harper-knowledge
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Configure
|
|
28
|
+
|
|
29
|
+
Add to your parent application's `config.yaml`:
|
|
30
|
+
|
|
31
|
+
```yaml
|
|
32
|
+
"harper-knowledge":
|
|
33
|
+
package: "harper-knowledge"
|
|
34
|
+
embeddingModel: nomic-embed-text # default
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Download the Embedding Model
|
|
38
|
+
|
|
39
|
+
The plugin uses [nomic-embed-text](https://huggingface.co/nomic-ai) for vector embeddings, run locally via [node-llama-cpp](https://github.com/withcatai/node-llama-cpp). The model is downloaded to `~/hdb/models/` on first startup, but you can pre-download it:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Download the default model
|
|
43
|
+
npm run model:download
|
|
44
|
+
|
|
45
|
+
# Download and verify with a test embedding
|
|
46
|
+
npm run model:test
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Run
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
harperdb dev .
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Embedding Models
|
|
56
|
+
|
|
57
|
+
Two [Nomic](https://huggingface.co/nomic-ai) embedding models are supported, both run entirely on CPU with no cloud dependency via [node-llama-cpp](https://github.com/withcatai/node-llama-cpp). On first plugin startup (or `npm run model:download`), the configured model is downloaded from Hugging Face to `~/hdb/models/`. A file lock prevents multiple Harper worker threads from downloading simultaneously.
|
|
58
|
+
|
|
59
|
+
### nomic-embed-text v1.5 (default)
|
|
60
|
+
|
|
61
|
+
| | |
|
|
62
|
+
| ---------------- | ------------------------------------------------------------------------------------------------- |
|
|
63
|
+
| **Model** | [nomic-ai/nomic-embed-text-v1.5-GGUF](https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF) |
|
|
64
|
+
| **Config key** | `nomic-embed-text` |
|
|
65
|
+
| **Parameters** | 137M |
|
|
66
|
+
| **Dimensions** | 768 |
|
|
67
|
+
| **Quantization** | Q4_K_M (~135 MB) |
|
|
68
|
+
| **Context** | 8192 tokens |
|
|
69
|
+
| **License** | Apache 2.0 |
|
|
70
|
+
|
|
71
|
+
### nomic-embed-text v2 MoE
|
|
72
|
+
|
|
73
|
+
| | |
|
|
74
|
+
| ---------------- | ----------------------------------------------------------------------------------------------------- |
|
|
75
|
+
| **Model** | [nomic-ai/nomic-embed-text-v2-moe-GGUF](https://huggingface.co/nomic-ai/nomic-embed-text-v2-moe-GGUF) |
|
|
76
|
+
| **Config key** | `nomic-embed-text-v2-moe` |
|
|
77
|
+
| **Parameters** | 475M (Mixture of Experts) |
|
|
78
|
+
| **Dimensions** | 768 |
|
|
79
|
+
| **Quantization** | Q4_K_M |
|
|
80
|
+
| **Context** | 8192 tokens |
|
|
81
|
+
| **License** | Apache 2.0 |
|
|
82
|
+
|
|
83
|
+
The v2 MoE model is larger but produces higher-quality embeddings, especially for longer and more nuanced content.
|
|
84
|
+
|
|
85
|
+
### Switching models
|
|
86
|
+
|
|
87
|
+
```yaml
|
|
88
|
+
"harper-knowledge":
|
|
89
|
+
package: "harper-knowledge"
|
|
90
|
+
embeddingModel: nomic-embed-text # v1.5 (default)
|
|
91
|
+
# embeddingModel: nomic-embed-text-v2-moe # v2 MoE
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Architecture
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
harper-knowledge
|
|
98
|
+
├── src/
|
|
99
|
+
│ ├── index.ts ← plugin entry: handleApplication()
|
|
100
|
+
│ ├── core/ ← shared logic
|
|
101
|
+
│ │ ├── embeddings.ts ← model download, init, vector generation
|
|
102
|
+
│ │ ├── entries.ts ← CRUD + relationship management
|
|
103
|
+
│ │ ├── history.ts ← edit history audit log
|
|
104
|
+
│ │ ├── search.ts ← keyword / semantic / hybrid search
|
|
105
|
+
│ │ ├── tags.ts ← tag registry with counts
|
|
106
|
+
│ │ └── triage.ts ← webhook intake queue
|
|
107
|
+
│ ├── resources/ ← REST Resource classes
|
|
108
|
+
│ │ ├── KnowledgeEntryResource.ts
|
|
109
|
+
│ │ ├── TriageResource.ts
|
|
110
|
+
│ │ ├── TagResource.ts
|
|
111
|
+
│ │ ├── QueryLogResource.ts
|
|
112
|
+
│ │ ├── ServiceKeyResource.ts
|
|
113
|
+
│ │ └── HistoryResource.ts
|
|
114
|
+
│ ├── mcp/ ← MCP server (Streamable HTTP)
|
|
115
|
+
│ │ ├── server.ts
|
|
116
|
+
│ │ └── tools.ts
|
|
117
|
+
│ ├── oauth/ ← OAuth 2.1 authorization server
|
|
118
|
+
│ │ ├── authorize.ts
|
|
119
|
+
│ │ ├── keys.ts
|
|
120
|
+
│ │ ├── metadata.ts
|
|
121
|
+
│ │ ├── middleware.ts
|
|
122
|
+
│ │ ├── register.ts
|
|
123
|
+
│ │ ├── token.ts
|
|
124
|
+
│ │ └── validate.ts
|
|
125
|
+
│ ├── webhooks/ ← webhook intake (GitHub, Datadog)
|
|
126
|
+
│ │ ├── middleware.ts
|
|
127
|
+
│ │ ├── github.ts
|
|
128
|
+
│ │ └── datadog.ts
|
|
129
|
+
│ └── types.ts
|
|
130
|
+
├── schema/
|
|
131
|
+
│ └── knowledge.graphql ← table definitions (database: "kb")
|
|
132
|
+
├── web/ ← static web UI
|
|
133
|
+
├── scripts/
|
|
134
|
+
│ └── download-model.js ← standalone model download/test
|
|
135
|
+
├── config.yaml
|
|
136
|
+
├── package.json
|
|
137
|
+
└── test/
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Both REST and MCP run in the Harper process, both call the same core functions with zero overhead.
|
|
141
|
+
|
|
142
|
+
## REST API
|
|
143
|
+
|
|
144
|
+
| Endpoint | Method | Auth | Description |
|
|
145
|
+
| ----------------------- | --------------- | ---------- | ------------------------- |
|
|
146
|
+
| `/Knowledge/<id>` | GET | Public | Get entry by ID |
|
|
147
|
+
| `/Knowledge/?query=...` | GET | Public | Search entries |
|
|
148
|
+
| `/Knowledge/` | POST | Required | Create entry |
|
|
149
|
+
| `/Knowledge/<id>` | PUT | Required | Update entry |
|
|
150
|
+
| `/Knowledge/<id>` | DELETE | Team | Deprecate entry |
|
|
151
|
+
| `/KnowledgeTag/` | GET | Public | List all tags |
|
|
152
|
+
| `/Triage/` | GET | Team | List pending triage items |
|
|
153
|
+
| `/Triage/` | POST | Service/AI | Submit triage item |
|
|
154
|
+
| `/Triage/<id>` | PUT | Team | Process triage item |
|
|
155
|
+
| `/QueryLog/` | GET | Team | Search analytics |
|
|
156
|
+
| `/ServiceKey/` | GET/POST/DELETE | Team | API key management |
|
|
157
|
+
| `/History/<entryId>` | GET | Public | Edit history for an entry |
|
|
158
|
+
|
|
159
|
+
### Search Parameters
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
GET /Knowledge/?query=MQTT+auth&tags=mqtt,config&limit=10&mode=keyword&context={"harper":"5.0","storageEngine":"lmdb"}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
- `query` — search text (required)
|
|
166
|
+
- `tags` — comma-separated tag filter
|
|
167
|
+
- `limit` — max results (default 10)
|
|
168
|
+
- `mode` — `keyword`, `semantic`, or `hybrid` (default)
|
|
169
|
+
- `context` — JSON applicability context for result boosting
|
|
170
|
+
|
|
171
|
+
## MCP Endpoint
|
|
172
|
+
|
|
173
|
+
Connect any MCP-compatible client to `/mcp`:
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
{
|
|
177
|
+
"mcpServers": {
|
|
178
|
+
"harper-kb": {
|
|
179
|
+
"url": "https://kb.harper.fast:9926/mcp"
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Tools
|
|
186
|
+
|
|
187
|
+
| Tool | Description |
|
|
188
|
+
| --------------------- | ----------------------------------------------------------------- |
|
|
189
|
+
| `knowledge_search` | Search with keyword/semantic/hybrid modes + applicability context |
|
|
190
|
+
| `knowledge_add` | Add a new entry (auto-tagged `ai-generated`) |
|
|
191
|
+
| `knowledge_get` | Get entry by ID with full relationship chain |
|
|
192
|
+
| `knowledge_update` | Update an entry with edit history tracking |
|
|
193
|
+
| `knowledge_related` | Find related entries (explicit + semantic similarity) |
|
|
194
|
+
| `knowledge_list_tags` | List all tags with counts |
|
|
195
|
+
| `knowledge_triage` | Submit to triage queue for review |
|
|
196
|
+
| `knowledge_history` | Get edit history for an entry (who changed what, when, why) |
|
|
197
|
+
|
|
198
|
+
## Schema
|
|
199
|
+
|
|
200
|
+
Tables in the `kb` database:
|
|
201
|
+
|
|
202
|
+
- **KnowledgeEntry** — core entries with HNSW vector index, `@relationship` directives for supersession/siblings/related, `@createdTime`/`@updatedTime`
|
|
203
|
+
- **KnowledgeEntryEdit** — append-only edit history audit log
|
|
204
|
+
- **TriageItem** — webhook intake queue (7-day TTL)
|
|
205
|
+
- **KnowledgeTag** — tag name as primary key with entry counts
|
|
206
|
+
- **QueryLog** — search analytics (30-day TTL)
|
|
207
|
+
- **ServiceKey** — API keys with scrypt-hashed secrets
|
|
208
|
+
- **OAuthClient** — dynamic client registrations (RFC 7591)
|
|
209
|
+
- **OAuthCode** — authorization codes (5-minute TTL)
|
|
210
|
+
- **OAuthRefreshToken** — refresh tokens (30-day TTL)
|
|
211
|
+
- **OAuthSigningKey** — RSA key pair for JWT signing
|
|
212
|
+
|
|
213
|
+
### Applicability Scoping
|
|
214
|
+
|
|
215
|
+
Entries carry an `appliesTo` scope:
|
|
216
|
+
|
|
217
|
+
```json
|
|
218
|
+
{
|
|
219
|
+
"harper": ">=4.0 <5.0",
|
|
220
|
+
"storageEngine": "lmdb",
|
|
221
|
+
"node": ">=22",
|
|
222
|
+
"platform": "linux"
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Search results are boosted or demoted (never hidden) based on the caller's context.
|
|
227
|
+
|
|
228
|
+
### Entry Relationships
|
|
229
|
+
|
|
230
|
+
- **Supersedes** — "This replaces that for newer versions"
|
|
231
|
+
- **Siblings** — "Same topic, different config" (e.g., LMDB vs RocksDB behavior)
|
|
232
|
+
- **Related** — loose "see also" association
|
|
233
|
+
|
|
234
|
+
## Auth Model
|
|
235
|
+
|
|
236
|
+
| Role | Read | Write | Review | Manage |
|
|
237
|
+
| ----------------- | ---- | ---------------------------- | ------ | ------ |
|
|
238
|
+
| `team` | Yes | Yes | Yes | Yes |
|
|
239
|
+
| `ai_agent` | Yes | Yes (flagged `ai-generated`) | No | No |
|
|
240
|
+
| `service_account` | Yes | Triage queue only | No | No |
|
|
241
|
+
|
|
242
|
+
MCP uses OAuth 2.1 with PKCE for authentication. MCP clients discover auth requirements via `/.well-known/oauth-protected-resource`, register dynamically, and authenticate through a browser-based login flow (GitHub OAuth primary, Harper credentials fallback). The web UI uses GitHub OAuth via `@harperfast/oauth` with Harper credentials as fallback.
|
|
243
|
+
|
|
244
|
+
## Development
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
# Build
|
|
248
|
+
npm run build
|
|
249
|
+
|
|
250
|
+
# Run tests (202 tests)
|
|
251
|
+
npm test
|
|
252
|
+
|
|
253
|
+
# Test with coverage
|
|
254
|
+
npm run test:coverage
|
|
255
|
+
|
|
256
|
+
# Download embedding model
|
|
257
|
+
npm run model:download
|
|
258
|
+
|
|
259
|
+
# Download + verify embedding model
|
|
260
|
+
npm run model:test
|
|
261
|
+
|
|
262
|
+
# Watch mode
|
|
263
|
+
npm run dev
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Testing
|
|
267
|
+
|
|
268
|
+
Tests use Node.js built-in test runner (`node:test`) with mock Harper globals (in-memory tables). Tests run against compiled output in `dist/`.
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
npm test
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## License
|
|
275
|
+
|
|
276
|
+
MIT
|
package/config.yaml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Knowledge Base Plugin Configuration
|
|
2
|
+
# This file defines the plugin entry point and default settings
|
|
3
|
+
# All settings can be overridden in your application's config.yaml
|
|
4
|
+
|
|
5
|
+
# Plugin entry point (required by Harper)
|
|
6
|
+
pluginModule: "dist/index.js"
|
|
7
|
+
|
|
8
|
+
# GraphQL schema for knowledge base tables
|
|
9
|
+
graphqlSchema:
|
|
10
|
+
files: "schema/knowledge.graphql"
|
|
11
|
+
|
|
12
|
+
# Static web UI files
|
|
13
|
+
static:
|
|
14
|
+
files: "web/**"
|
|
15
|
+
|
|
16
|
+
# Default settings (optional - these are used if not specified in app config)
|
|
17
|
+
embeddingModel: "nomic-embed-text"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedding Model Management
|
|
3
|
+
*
|
|
4
|
+
* Downloads and initializes the nomic-embed-text model for generating
|
|
5
|
+
* vector embeddings. Uses file-based locking to prevent concurrent
|
|
6
|
+
* downloads across worker threads.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Initialize the embedding model.
|
|
10
|
+
* Downloads the model to ~/hdb/models/ if not present.
|
|
11
|
+
* Uses file-based locking so only one thread downloads.
|
|
12
|
+
*
|
|
13
|
+
* @param config - Plugin configuration with embeddingModel name
|
|
14
|
+
*/
|
|
15
|
+
export declare function initEmbeddingModel(config: {
|
|
16
|
+
embeddingModel: string;
|
|
17
|
+
}): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Generate an embedding vector for the given text.
|
|
20
|
+
*
|
|
21
|
+
* @param text - Text to generate embedding for
|
|
22
|
+
* @returns Embedding vector as array of numbers
|
|
23
|
+
* @throws Error if the model has not been initialized
|
|
24
|
+
*/
|
|
25
|
+
export declare function generateEmbedding(text: string): Promise<number[]>;
|
|
26
|
+
/**
|
|
27
|
+
* Clean up embedding model resources.
|
|
28
|
+
*/
|
|
29
|
+
export declare function dispose(): Promise<void>;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedding Model Management
|
|
3
|
+
*
|
|
4
|
+
* Downloads and initializes the nomic-embed-text model for generating
|
|
5
|
+
* vector embeddings. Uses file-based locking to prevent concurrent
|
|
6
|
+
* downloads across worker threads.
|
|
7
|
+
*/
|
|
8
|
+
import { writeFile, readFile, unlink, mkdir } from "node:fs/promises";
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
// Module-level state for the loaded model and context
|
|
13
|
+
let llama = null;
|
|
14
|
+
let embeddingModel = null;
|
|
15
|
+
let embeddingContext = null;
|
|
16
|
+
// Model configuration: Hugging Face model identifiers for node-llama-cpp
|
|
17
|
+
const MODEL_CONFIGS = {
|
|
18
|
+
"nomic-embed-text": {
|
|
19
|
+
repo: "nomic-ai/nomic-embed-text-v1.5-GGUF",
|
|
20
|
+
file: "nomic-embed-text-v1.5.Q4_K_M.gguf",
|
|
21
|
+
},
|
|
22
|
+
"nomic-embed-text-v2-moe": {
|
|
23
|
+
repo: "nomic-ai/nomic-embed-text-v2-moe-GGUF",
|
|
24
|
+
file: "nomic-embed-text-v2-moe.Q4_K_M.gguf",
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Get the directory where models are stored.
|
|
29
|
+
*/
|
|
30
|
+
function getModelsDir() {
|
|
31
|
+
return path.join(os.homedir(), "hdb", "models");
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Get the lock file path for download synchronization.
|
|
35
|
+
*/
|
|
36
|
+
function getLockFilePath(modelName) {
|
|
37
|
+
return path.join(getModelsDir(), `${modelName}.lock`);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Get the model URI for node-llama-cpp downloads.
|
|
41
|
+
*/
|
|
42
|
+
function getModelUri(modelName) {
|
|
43
|
+
const config = MODEL_CONFIGS[modelName];
|
|
44
|
+
if (!config) {
|
|
45
|
+
throw new Error(`Unknown embedding model: ${modelName}. Supported: ${Object.keys(MODEL_CONFIGS).join(", ")}`);
|
|
46
|
+
}
|
|
47
|
+
return `hf:${config.repo}/${config.file}`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Acquire a file-based lock for model download.
|
|
51
|
+
* Returns true if lock was acquired, false if another thread holds it.
|
|
52
|
+
*/
|
|
53
|
+
async function acquireDownloadLock(modelName) {
|
|
54
|
+
const lockPath = getLockFilePath(modelName);
|
|
55
|
+
try {
|
|
56
|
+
if (existsSync(lockPath)) {
|
|
57
|
+
// Check if lock is stale (older than 10 minutes)
|
|
58
|
+
const lockContent = await readFile(lockPath, "utf-8");
|
|
59
|
+
const lockTime = parseInt(lockContent, 10);
|
|
60
|
+
if (!isNaN(lockTime) && Date.now() - lockTime < 10 * 60 * 1000) {
|
|
61
|
+
return false; // Lock is held and not stale
|
|
62
|
+
}
|
|
63
|
+
// Stale lock — remove and re-acquire
|
|
64
|
+
}
|
|
65
|
+
await writeFile(lockPath, String(Date.now()), { flag: "wx" }).catch(async () => {
|
|
66
|
+
// wx flag failed (file exists), try overwriting stale lock
|
|
67
|
+
await writeFile(lockPath, String(Date.now()));
|
|
68
|
+
});
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Release the download lock.
|
|
77
|
+
*/
|
|
78
|
+
async function releaseDownloadLock(modelName) {
|
|
79
|
+
const lockPath = getLockFilePath(modelName);
|
|
80
|
+
try {
|
|
81
|
+
await unlink(lockPath);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Lock file already removed — safe to ignore
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Wait for another thread's download to complete.
|
|
89
|
+
* Polls the lock file until it disappears or the model file appears.
|
|
90
|
+
*/
|
|
91
|
+
async function waitForDownload(modelName, modelPath) {
|
|
92
|
+
const lockPath = getLockFilePath(modelName);
|
|
93
|
+
const maxWait = 10 * 60 * 1000; // 10 minutes
|
|
94
|
+
const pollInterval = 2000; // 2 seconds
|
|
95
|
+
const start = Date.now();
|
|
96
|
+
while (Date.now() - start < maxWait) {
|
|
97
|
+
if (existsSync(modelPath) && !existsSync(lockPath)) {
|
|
98
|
+
return; // Download completed
|
|
99
|
+
}
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`Timed out waiting for model download: ${modelName}`);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Download the embedding model if not already present.
|
|
106
|
+
* Uses file-based locking so only one worker thread downloads.
|
|
107
|
+
*/
|
|
108
|
+
async function downloadModelIfNeeded(modelName) {
|
|
109
|
+
const modelUri = getModelUri(modelName);
|
|
110
|
+
const modelsDir = getModelsDir();
|
|
111
|
+
// Ensure models directory exists
|
|
112
|
+
await mkdir(modelsDir, { recursive: true });
|
|
113
|
+
// Use node-llama-cpp to resolve the actual file path and download if needed.
|
|
114
|
+
// node-llama-cpp prefixes filenames (e.g., hf_nomic-ai_<file>.gguf),
|
|
115
|
+
// so we use its entrypointFilePath rather than guessing the name.
|
|
116
|
+
const { createModelDownloader } = (await import("node-llama-cpp"));
|
|
117
|
+
const downloader = await createModelDownloader({
|
|
118
|
+
modelUri,
|
|
119
|
+
dirPath: modelsDir,
|
|
120
|
+
skipExisting: true,
|
|
121
|
+
});
|
|
122
|
+
const modelPath = downloader.entrypointFilePath;
|
|
123
|
+
// Already downloaded
|
|
124
|
+
if (existsSync(modelPath)) {
|
|
125
|
+
return modelPath;
|
|
126
|
+
}
|
|
127
|
+
// Try to acquire download lock
|
|
128
|
+
const acquired = await acquireDownloadLock(modelName);
|
|
129
|
+
if (!acquired) {
|
|
130
|
+
// Another thread is downloading — wait for it
|
|
131
|
+
logger?.info?.(`Another thread is downloading ${modelName}, waiting...`);
|
|
132
|
+
await waitForDownload(modelName, modelPath);
|
|
133
|
+
return modelPath;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
logger?.info?.(`Downloading embedding model: ${modelName} from ${modelUri}`);
|
|
137
|
+
const resultPath = await downloader.download();
|
|
138
|
+
logger?.info?.(`Model ${modelName} downloaded successfully to ${resultPath}`);
|
|
139
|
+
return resultPath;
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
await releaseDownloadLock(modelName);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Initialize the embedding model.
|
|
147
|
+
* Downloads the model to ~/hdb/models/ if not present.
|
|
148
|
+
* Uses file-based locking so only one thread downloads.
|
|
149
|
+
*
|
|
150
|
+
* @param config - Plugin configuration with embeddingModel name
|
|
151
|
+
*/
|
|
152
|
+
export async function initEmbeddingModel(config) {
|
|
153
|
+
const modelName = config.embeddingModel || "nomic-embed-text";
|
|
154
|
+
if (embeddingModel) {
|
|
155
|
+
logger?.debug?.("Embedding model already initialized");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const modelPath = await downloadModelIfNeeded(modelName);
|
|
159
|
+
// Load the model with node-llama-cpp
|
|
160
|
+
const { getLlama } = (await import("node-llama-cpp"));
|
|
161
|
+
llama = await getLlama({ progressLogs: false });
|
|
162
|
+
embeddingModel = await llama.loadModel({ modelPath });
|
|
163
|
+
embeddingContext = await embeddingModel.createEmbeddingContext({
|
|
164
|
+
contextSize: "auto",
|
|
165
|
+
});
|
|
166
|
+
logger?.info?.(`Embedding model ${modelName} loaded successfully`);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Generate an embedding vector for the given text.
|
|
170
|
+
*
|
|
171
|
+
* @param text - Text to generate embedding for
|
|
172
|
+
* @returns Embedding vector as array of numbers
|
|
173
|
+
* @throws Error if the model has not been initialized
|
|
174
|
+
*/
|
|
175
|
+
export async function generateEmbedding(text) {
|
|
176
|
+
if (!embeddingContext) {
|
|
177
|
+
throw new Error("Embedding model not initialized. Call initEmbeddingModel() first.");
|
|
178
|
+
}
|
|
179
|
+
const result = await embeddingContext.getEmbeddingFor(text);
|
|
180
|
+
return Array.from(result.vector);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Clean up embedding model resources.
|
|
184
|
+
*/
|
|
185
|
+
export async function dispose() {
|
|
186
|
+
if (embeddingContext && !embeddingContext.disposed) {
|
|
187
|
+
await embeddingContext.dispose();
|
|
188
|
+
}
|
|
189
|
+
embeddingContext = null;
|
|
190
|
+
if (embeddingModel) {
|
|
191
|
+
await embeddingModel.dispose();
|
|
192
|
+
}
|
|
193
|
+
embeddingModel = null;
|
|
194
|
+
if (llama) {
|
|
195
|
+
await llama.dispose();
|
|
196
|
+
}
|
|
197
|
+
llama = null;
|
|
198
|
+
logger?.info?.("Embedding model disposed");
|
|
199
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge Entry Management
|
|
3
|
+
*
|
|
4
|
+
* CRUD operations for knowledge base entries. Handles embedding generation,
|
|
5
|
+
* tag synchronization, and relationship management.
|
|
6
|
+
*/
|
|
7
|
+
import type { KnowledgeEntry, KnowledgeEntryInput, KnowledgeEntryUpdate } from "../types.ts";
|
|
8
|
+
/**
|
|
9
|
+
* Strip embedding vectors from an entry to keep responses compact.
|
|
10
|
+
* Embeddings are large float arrays not useful in API responses.
|
|
11
|
+
*/
|
|
12
|
+
export declare function stripEmbedding<T extends {
|
|
13
|
+
embedding?: number[];
|
|
14
|
+
}>(entry: T): Omit<T, "embedding">;
|
|
15
|
+
/**
|
|
16
|
+
* Create a new knowledge entry.
|
|
17
|
+
*
|
|
18
|
+
* Generates an embedding from title + content, synchronizes tags,
|
|
19
|
+
* and stores the entry. A UUID is generated if no id is provided.
|
|
20
|
+
*
|
|
21
|
+
* @param data - Entry data to create
|
|
22
|
+
* @returns The created knowledge entry
|
|
23
|
+
*/
|
|
24
|
+
export declare function createEntry(data: KnowledgeEntryInput): Promise<KnowledgeEntry>;
|
|
25
|
+
/**
|
|
26
|
+
* Get a knowledge entry by ID.
|
|
27
|
+
*
|
|
28
|
+
* @param id - Entry ID
|
|
29
|
+
* @returns The entry, or null if not found
|
|
30
|
+
*/
|
|
31
|
+
export declare function getEntry(id: string): Promise<KnowledgeEntry | null>;
|
|
32
|
+
/**
|
|
33
|
+
* Update an existing knowledge entry.
|
|
34
|
+
*
|
|
35
|
+
* Merges the update data with the existing entry. If title or content changed,
|
|
36
|
+
* regenerates the embedding. Synchronizes tag counts if tags changed.
|
|
37
|
+
* Optionally logs the edit to the history table.
|
|
38
|
+
*
|
|
39
|
+
* @param id - ID of the entry to update
|
|
40
|
+
* @param data - Fields to update
|
|
41
|
+
* @param options - Optional edit tracking metadata
|
|
42
|
+
* @returns The updated entry
|
|
43
|
+
* @throws Error if the entry does not exist
|
|
44
|
+
*/
|
|
45
|
+
export declare function updateEntry(id: string, data: KnowledgeEntryUpdate, options?: {
|
|
46
|
+
editedBy?: string;
|
|
47
|
+
editSummary?: string;
|
|
48
|
+
}): Promise<KnowledgeEntry>;
|
|
49
|
+
/**
|
|
50
|
+
* Mark an entry as deprecated.
|
|
51
|
+
*
|
|
52
|
+
* @param id - ID of the entry to deprecate
|
|
53
|
+
* @throws Error if the entry does not exist
|
|
54
|
+
*/
|
|
55
|
+
export declare function deprecateEntry(id: string): Promise<void>;
|
|
56
|
+
/**
|
|
57
|
+
* Link a new entry as superseding an old entry.
|
|
58
|
+
*
|
|
59
|
+
* Sets newEntry.supersedesId = oldId and oldEntry.supersededById = newId.
|
|
60
|
+
*
|
|
61
|
+
* @param newId - ID of the new (superseding) entry
|
|
62
|
+
* @param oldId - ID of the old (superseded) entry
|
|
63
|
+
* @throws Error if either entry does not exist
|
|
64
|
+
*/
|
|
65
|
+
export declare function linkSupersedes(newId: string, oldId: string): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Link multiple entries as siblings.
|
|
68
|
+
*
|
|
69
|
+
* For each entry, adds all other entry IDs to its siblingIds array (deduplicated).
|
|
70
|
+
*
|
|
71
|
+
* @param ids - IDs of entries to link as siblings
|
|
72
|
+
* @throws Error if any entry does not exist
|
|
73
|
+
*/
|
|
74
|
+
export declare function linkSiblings(ids: string[]): Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* Link two entries as related.
|
|
77
|
+
*
|
|
78
|
+
* Adds relatedId to the entry's relatedIds array (deduplicated).
|
|
79
|
+
* This is a one-directional link; call twice for bidirectional.
|
|
80
|
+
*
|
|
81
|
+
* @param id - ID of the entry to add a related link to
|
|
82
|
+
* @param relatedId - ID of the related entry
|
|
83
|
+
* @throws Error if the entry does not exist
|
|
84
|
+
*/
|
|
85
|
+
export declare function linkRelated(id: string, relatedId: string): Promise<void>;
|