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
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook Middleware
|
|
3
|
+
*
|
|
4
|
+
* HTTP middleware for Harper's scope.server.http() that routes
|
|
5
|
+
* /webhooks/* requests to the appropriate webhook handler.
|
|
6
|
+
*/
|
|
7
|
+
import { readBody } from "../http-utils.js";
|
|
8
|
+
import { submitTriage, findBySourceId } from "../core/triage.js";
|
|
9
|
+
import { validateSignature as validateGitHubSignature, parsePayload as parseGitHubPayload, } from "./github.js";
|
|
10
|
+
import { validateApiKey as validateDatadogKey, parsePayload as parseDatadogPayload, } from "./datadog.js";
|
|
11
|
+
// In-memory delivery ID dedup to prevent webhook replay attacks.
|
|
12
|
+
// Bounded to MAX_SEEN entries; cleared when full (per-worker, resets on restart).
|
|
13
|
+
const SEEN_DELIVERIES = new Set();
|
|
14
|
+
const MAX_SEEN = 10_000;
|
|
15
|
+
function isDuplicateDelivery(deliveryId) {
|
|
16
|
+
if (SEEN_DELIVERIES.has(deliveryId))
|
|
17
|
+
return true;
|
|
18
|
+
if (SEEN_DELIVERIES.size >= MAX_SEEN)
|
|
19
|
+
SEEN_DELIVERIES.clear();
|
|
20
|
+
SEEN_DELIVERIES.add(deliveryId);
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Create a webhook middleware function for Harper's scope.server.http().
|
|
25
|
+
*
|
|
26
|
+
* Reads webhook config from scope.options and watches for changes
|
|
27
|
+
* to support secret rotation without restart.
|
|
28
|
+
*/
|
|
29
|
+
export function createWebhookMiddleware(scope) {
|
|
30
|
+
const scopeLogger = scope.logger;
|
|
31
|
+
// Read initial config
|
|
32
|
+
let webhookConfig = scope.options.getAll()?.webhooks || {};
|
|
33
|
+
// Watch for config changes (supports secret rotation)
|
|
34
|
+
scope.options.on("change", (_key, _value, config) => {
|
|
35
|
+
webhookConfig = config?.webhooks || {};
|
|
36
|
+
scopeLogger?.debug?.("Webhook configuration updated");
|
|
37
|
+
});
|
|
38
|
+
return async (request, next) => {
|
|
39
|
+
const pathname = request.pathname || "";
|
|
40
|
+
// Only handle /webhooks/* routes
|
|
41
|
+
if (!pathname.startsWith("/webhooks/")) {
|
|
42
|
+
return next(request);
|
|
43
|
+
}
|
|
44
|
+
// Only accept POST
|
|
45
|
+
if (request.method !== "POST") {
|
|
46
|
+
return jsonResponse(405, { error: "Method not allowed" });
|
|
47
|
+
}
|
|
48
|
+
// Route to the appropriate handler
|
|
49
|
+
if (pathname === "/webhooks/github") {
|
|
50
|
+
return handleGitHub(request, webhookConfig, scopeLogger);
|
|
51
|
+
}
|
|
52
|
+
if (pathname === "/webhooks/datadog") {
|
|
53
|
+
return handleDatadog(request, webhookConfig, scopeLogger);
|
|
54
|
+
}
|
|
55
|
+
return jsonResponse(404, { error: "Unknown webhook endpoint" });
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
async function handleGitHub(request, config, scopeLogger) {
|
|
59
|
+
const secret = config.github?.secret;
|
|
60
|
+
if (!secret) {
|
|
61
|
+
return jsonResponse(503, { error: "GitHub webhook not configured" });
|
|
62
|
+
}
|
|
63
|
+
// Read raw body
|
|
64
|
+
const rawBody = await readBody(request);
|
|
65
|
+
// Validate signature
|
|
66
|
+
const signature = getHeader(request, "x-hub-signature-256");
|
|
67
|
+
if (!validateGitHubSignature(rawBody, signature, secret)) {
|
|
68
|
+
return jsonResponse(401, { error: "Invalid signature" });
|
|
69
|
+
}
|
|
70
|
+
// Replay protection — reject duplicate delivery IDs
|
|
71
|
+
const deliveryId = getHeader(request, "x-github-delivery");
|
|
72
|
+
if (deliveryId && isDuplicateDelivery(deliveryId)) {
|
|
73
|
+
return jsonResponse(200, { status: "duplicate", deliveryId });
|
|
74
|
+
}
|
|
75
|
+
// Parse payload
|
|
76
|
+
let payload;
|
|
77
|
+
try {
|
|
78
|
+
payload = JSON.parse(rawBody);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return jsonResponse(400, { error: "Invalid JSON" });
|
|
82
|
+
}
|
|
83
|
+
const event = getHeader(request, "x-github-event");
|
|
84
|
+
const result = parseGitHubPayload(event, payload);
|
|
85
|
+
if (!result) {
|
|
86
|
+
// Event type we don't handle — acknowledge without creating triage item
|
|
87
|
+
return jsonResponse(200, { status: "ignored" });
|
|
88
|
+
}
|
|
89
|
+
return submitResult(result, scopeLogger);
|
|
90
|
+
}
|
|
91
|
+
async function handleDatadog(request, config, scopeLogger) {
|
|
92
|
+
const configuredKey = config.datadog?.apiKey;
|
|
93
|
+
if (!configuredKey) {
|
|
94
|
+
return jsonResponse(503, { error: "Datadog webhook not configured" });
|
|
95
|
+
}
|
|
96
|
+
// Read raw body
|
|
97
|
+
const rawBody = await readBody(request);
|
|
98
|
+
// Validate API key from DD-API-KEY or X-API-Key header
|
|
99
|
+
const apiKey = getHeader(request, "dd-api-key") || getHeader(request, "x-api-key");
|
|
100
|
+
if (!validateDatadogKey(apiKey, configuredKey)) {
|
|
101
|
+
return jsonResponse(401, { error: "Invalid API key" });
|
|
102
|
+
}
|
|
103
|
+
// Parse payload
|
|
104
|
+
let payload;
|
|
105
|
+
try {
|
|
106
|
+
payload = JSON.parse(rawBody);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return jsonResponse(400, { error: "Invalid JSON" });
|
|
110
|
+
}
|
|
111
|
+
const result = parseDatadogPayload(payload);
|
|
112
|
+
return submitResult(result, scopeLogger);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Submit a webhook result to the triage queue, checking for duplicates first.
|
|
116
|
+
*/
|
|
117
|
+
async function submitResult(result, scopeLogger) {
|
|
118
|
+
// Idempotency check
|
|
119
|
+
const existing = await findBySourceId(result.sourceId);
|
|
120
|
+
if (existing) {
|
|
121
|
+
return jsonResponse(200, { status: "duplicate", triageId: existing.id });
|
|
122
|
+
}
|
|
123
|
+
const item = await submitTriage(result.source, result.summary, result.rawPayload, result.sourceId);
|
|
124
|
+
scopeLogger?.info?.(`Webhook triage item created: ${item.id} (${result.source})`);
|
|
125
|
+
return jsonResponse(200, { status: "accepted", triageId: item.id });
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Get a header value from Harper's request, case-insensitive.
|
|
129
|
+
*/
|
|
130
|
+
function getHeader(request, name) {
|
|
131
|
+
const headers = request.headers;
|
|
132
|
+
if (!headers)
|
|
133
|
+
return "";
|
|
134
|
+
// Try direct access (case-sensitive)
|
|
135
|
+
const direct = headers[name] ?? headers[name.toLowerCase()];
|
|
136
|
+
if (direct !== undefined) {
|
|
137
|
+
return Array.isArray(direct) ? direct[0] : String(direct);
|
|
138
|
+
}
|
|
139
|
+
// Try .get() method (Harper's Headers class)
|
|
140
|
+
if (typeof headers.get === "function") {
|
|
141
|
+
const val = headers.get(name);
|
|
142
|
+
if (val !== undefined && val !== null)
|
|
143
|
+
return String(val);
|
|
144
|
+
}
|
|
145
|
+
// Fallback: iterate to find case-insensitive match
|
|
146
|
+
const lowerName = name.toLowerCase();
|
|
147
|
+
if (typeof headers === "object") {
|
|
148
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
149
|
+
if (key.toLowerCase() === lowerName && value !== undefined) {
|
|
150
|
+
return Array.isArray(value) ? value[0] : String(value);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return "";
|
|
155
|
+
}
|
|
156
|
+
function jsonResponse(status, body) {
|
|
157
|
+
return new Response(JSON.stringify(body), {
|
|
158
|
+
status,
|
|
159
|
+
headers: { "Content-Type": "application/json" },
|
|
160
|
+
});
|
|
161
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for webhook handlers.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Result from parsing a webhook payload.
|
|
6
|
+
* Returned by GitHub/Datadog handlers for the middleware to submit to triage.
|
|
7
|
+
*/
|
|
8
|
+
export interface WebhookResult {
|
|
9
|
+
/** Source identifier (e.g., "github-webhook", "datadog-webhook") */
|
|
10
|
+
source: string;
|
|
11
|
+
/** Deduplication key for idempotency */
|
|
12
|
+
sourceId: string;
|
|
13
|
+
/** Human-readable summary for triage review */
|
|
14
|
+
summary: string;
|
|
15
|
+
/** Original raw payload */
|
|
16
|
+
rawPayload: unknown;
|
|
17
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "harper-knowledge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Knowledge base plugin for Harper with MCP server integration",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
},
|
|
13
|
+
"./config": "./config.yaml"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"dev": "tsc --watch",
|
|
18
|
+
"test": "npm run build && node --test \"test/**/*.test.js\"",
|
|
19
|
+
"test:watch": "npm run build && node --test --watch \"test/**/*.test.js\"",
|
|
20
|
+
"test:coverage": "npm run build && node --enable-source-maps --test --experimental-test-coverage --test-coverage-exclude='test/**' --test-coverage-exclude='**/harperdb/**' \"test/**/*.test.js\"",
|
|
21
|
+
"lint": "eslint . --ignore-pattern 'dist/**'",
|
|
22
|
+
"format": "prettier .",
|
|
23
|
+
"format:check": "prettier --check .",
|
|
24
|
+
"format:write": "prettier --write .",
|
|
25
|
+
"model:download": "node scripts/download-model.js",
|
|
26
|
+
"model:test": "node scripts/download-model.js --test",
|
|
27
|
+
"prepublishOnly": "npm run build"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"harper",
|
|
31
|
+
"harperdb",
|
|
32
|
+
"plugin",
|
|
33
|
+
"knowledge-base",
|
|
34
|
+
"mcp",
|
|
35
|
+
"ai",
|
|
36
|
+
"embeddings",
|
|
37
|
+
"vector-search"
|
|
38
|
+
],
|
|
39
|
+
"author": "Nathan Heskew <nathan@heskew.dev> (https://heskew.dev)",
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "https://github.com/heskew/harper-knowledge"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
47
|
+
"jose": "^6.1.3",
|
|
48
|
+
"node-llama-cpp": "^3.6.0",
|
|
49
|
+
"zod": "^4.0.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@harperdb/code-guidelines": "0.0.6",
|
|
53
|
+
"@types/node": "25.2.0",
|
|
54
|
+
"eslint": "9.39.2",
|
|
55
|
+
"prettier": "3.8.1",
|
|
56
|
+
"typescript": "5.9.3"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"harperdb": ">=4.7.0"
|
|
60
|
+
},
|
|
61
|
+
"engines": {
|
|
62
|
+
"node": ">=22"
|
|
63
|
+
},
|
|
64
|
+
"files": [
|
|
65
|
+
"dist",
|
|
66
|
+
"schema",
|
|
67
|
+
"web",
|
|
68
|
+
"config.yaml",
|
|
69
|
+
"README.md",
|
|
70
|
+
"LICENSE"
|
|
71
|
+
]
|
|
72
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
## Knowledge Base Plugin Schema
|
|
2
|
+
## Defines all tables for the knowledge base in the "kb" database
|
|
3
|
+
|
|
4
|
+
## Core knowledge entries with vector embeddings for semantic search
|
|
5
|
+
type KnowledgeEntry @table(database: "kb") @export {
|
|
6
|
+
id: ID @primaryKey
|
|
7
|
+
title: String @indexed
|
|
8
|
+
content: String
|
|
9
|
+
tags: [String]
|
|
10
|
+
appliesTo: Any
|
|
11
|
+
source: String
|
|
12
|
+
sourceUrl: String
|
|
13
|
+
confidence: String @indexed
|
|
14
|
+
addedBy: String
|
|
15
|
+
reviewedBy: String
|
|
16
|
+
embedding: [Float] @indexed(type: "HNSW", distance: "cosine")
|
|
17
|
+
supersedesId: ID
|
|
18
|
+
supersedes: KnowledgeEntry @relationship(from: "supersedesId")
|
|
19
|
+
supersededById: ID
|
|
20
|
+
supersededBy: KnowledgeEntry @relationship(from: "supersededById")
|
|
21
|
+
siblingIds: [ID]
|
|
22
|
+
siblings: [KnowledgeEntry] @relationship(from: "siblingIds")
|
|
23
|
+
relatedIds: [ID]
|
|
24
|
+
relatedEntries: [KnowledgeEntry] @relationship(from: "relatedIds")
|
|
25
|
+
customerContext: Any
|
|
26
|
+
deprecated: Boolean @indexed
|
|
27
|
+
createdAt: Date @createdTime
|
|
28
|
+
updatedAt: Date @updatedTime
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
## Webhook intake queue for new knowledge submissions
|
|
32
|
+
## 7-day TTL (604800 seconds)
|
|
33
|
+
type TriageItem @table(database: "kb", expiration: 604800) {
|
|
34
|
+
id: ID @primaryKey
|
|
35
|
+
source: String @indexed
|
|
36
|
+
sourceId: String @indexed
|
|
37
|
+
rawPayload: Any
|
|
38
|
+
summary: String
|
|
39
|
+
status: String @indexed
|
|
40
|
+
matchedEntryId: ID
|
|
41
|
+
matchedEntry: KnowledgeEntry @relationship(from: "matchedEntryId")
|
|
42
|
+
draftEntryId: ID
|
|
43
|
+
draftEntry: KnowledgeEntry @relationship(from: "draftEntryId")
|
|
44
|
+
action: String
|
|
45
|
+
processedBy: String
|
|
46
|
+
createdAt: Date @createdTime
|
|
47
|
+
processedAt: Date
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
## Tag registry with descriptions and usage counts
|
|
51
|
+
type KnowledgeTag @table(database: "kb") {
|
|
52
|
+
id: ID @primaryKey
|
|
53
|
+
description: String
|
|
54
|
+
entryCount: Int
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
## Search query analytics log
|
|
58
|
+
## 30-day TTL (2592000 seconds)
|
|
59
|
+
type QueryLog @table(database: "kb", expiration: 2592000) {
|
|
60
|
+
id: ID @primaryKey
|
|
61
|
+
query: String
|
|
62
|
+
context: Any
|
|
63
|
+
source: String
|
|
64
|
+
resultCount: Int
|
|
65
|
+
topResultId: ID
|
|
66
|
+
topResult: KnowledgeEntry @relationship(from: "topResultId")
|
|
67
|
+
createdAt: Date @createdTime
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
## API keys for webhooks and service accounts
|
|
71
|
+
type ServiceKey @table(database: "kb") {
|
|
72
|
+
id: ID @primaryKey
|
|
73
|
+
name: String
|
|
74
|
+
keyHash: String
|
|
75
|
+
role: String
|
|
76
|
+
permissions: Any
|
|
77
|
+
createdBy: String
|
|
78
|
+
createdAt: Date @createdTime
|
|
79
|
+
lastUsedAt: Date
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
## OAuth 2.1 Dynamic Client Registration (RFC 7591)
|
|
83
|
+
type OAuthClient @table(database: "kb") {
|
|
84
|
+
id: ID @primaryKey
|
|
85
|
+
clientSecret: String
|
|
86
|
+
clientName: String
|
|
87
|
+
redirectUris: [String]
|
|
88
|
+
grantTypes: [String]
|
|
89
|
+
responseTypes: [String]
|
|
90
|
+
scope: String
|
|
91
|
+
createdAt: Date @createdTime
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
## OAuth 2.1 Authorization Codes (5-minute TTL)
|
|
95
|
+
type OAuthCode @table(database: "kb", expiration: 300) {
|
|
96
|
+
id: ID @primaryKey
|
|
97
|
+
clientId: String @indexed
|
|
98
|
+
userId: String
|
|
99
|
+
scope: String
|
|
100
|
+
codeChallenge: String
|
|
101
|
+
codeChallengeMethod: String
|
|
102
|
+
redirectUri: String
|
|
103
|
+
createdAt: Date @createdTime
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
## OAuth 2.1 Refresh Tokens (30-day TTL)
|
|
107
|
+
type OAuthRefreshToken @table(database: "kb", expiration: 2592000) {
|
|
108
|
+
id: ID @primaryKey
|
|
109
|
+
clientId: String @indexed
|
|
110
|
+
userId: String
|
|
111
|
+
scope: String
|
|
112
|
+
createdAt: Date @createdTime
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
## RSA signing key pair for JWT access tokens
|
|
116
|
+
type OAuthSigningKey @table(database: "kb") {
|
|
117
|
+
id: ID @primaryKey
|
|
118
|
+
publicKeyJwk: Any
|
|
119
|
+
privateKeyJwk: Any
|
|
120
|
+
algorithm: String
|
|
121
|
+
createdAt: Date @createdTime
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
## Edit history for knowledge entries — append-only audit log
|
|
125
|
+
type KnowledgeEntryEdit @table(database: "kb") {
|
|
126
|
+
id: ID @primaryKey
|
|
127
|
+
entryId: ID @indexed
|
|
128
|
+
entry: KnowledgeEntry @relationship(from: "entryId")
|
|
129
|
+
editedBy: String @indexed
|
|
130
|
+
editSummary: String
|
|
131
|
+
previousSnapshot: Any
|
|
132
|
+
changedFields: [String]
|
|
133
|
+
createdAt: Date @createdTime
|
|
134
|
+
}
|