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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +276 -0
  3. package/config.yaml +17 -0
  4. package/dist/core/embeddings.d.ts +29 -0
  5. package/dist/core/embeddings.js +199 -0
  6. package/dist/core/entries.d.ts +85 -0
  7. package/dist/core/entries.js +235 -0
  8. package/dist/core/history.d.ts +30 -0
  9. package/dist/core/history.js +119 -0
  10. package/dist/core/search.d.ts +23 -0
  11. package/dist/core/search.js +306 -0
  12. package/dist/core/tags.d.ts +32 -0
  13. package/dist/core/tags.js +76 -0
  14. package/dist/core/triage.d.ts +55 -0
  15. package/dist/core/triage.js +126 -0
  16. package/dist/http-utils.d.ts +37 -0
  17. package/dist/http-utils.js +132 -0
  18. package/dist/index.d.ts +21 -0
  19. package/dist/index.js +76 -0
  20. package/dist/mcp/server.d.ts +24 -0
  21. package/dist/mcp/server.js +124 -0
  22. package/dist/mcp/tools.d.ts +13 -0
  23. package/dist/mcp/tools.js +497 -0
  24. package/dist/oauth/authorize.d.ts +27 -0
  25. package/dist/oauth/authorize.js +438 -0
  26. package/dist/oauth/github.d.ts +28 -0
  27. package/dist/oauth/github.js +62 -0
  28. package/dist/oauth/keys.d.ts +33 -0
  29. package/dist/oauth/keys.js +100 -0
  30. package/dist/oauth/metadata.d.ts +21 -0
  31. package/dist/oauth/metadata.js +55 -0
  32. package/dist/oauth/middleware.d.ts +22 -0
  33. package/dist/oauth/middleware.js +64 -0
  34. package/dist/oauth/register.d.ts +14 -0
  35. package/dist/oauth/register.js +83 -0
  36. package/dist/oauth/token.d.ts +15 -0
  37. package/dist/oauth/token.js +178 -0
  38. package/dist/oauth/validate.d.ts +30 -0
  39. package/dist/oauth/validate.js +52 -0
  40. package/dist/resources/HistoryResource.d.ts +38 -0
  41. package/dist/resources/HistoryResource.js +38 -0
  42. package/dist/resources/KnowledgeEntryResource.d.ts +64 -0
  43. package/dist/resources/KnowledgeEntryResource.js +157 -0
  44. package/dist/resources/QueryLogResource.d.ts +20 -0
  45. package/dist/resources/QueryLogResource.js +57 -0
  46. package/dist/resources/ServiceKeyResource.d.ts +51 -0
  47. package/dist/resources/ServiceKeyResource.js +132 -0
  48. package/dist/resources/TagResource.d.ts +25 -0
  49. package/dist/resources/TagResource.js +32 -0
  50. package/dist/resources/TriageResource.d.ts +51 -0
  51. package/dist/resources/TriageResource.js +107 -0
  52. package/dist/types.d.ts +317 -0
  53. package/dist/types.js +7 -0
  54. package/dist/webhooks/datadog.d.ts +26 -0
  55. package/dist/webhooks/datadog.js +120 -0
  56. package/dist/webhooks/github.d.ts +24 -0
  57. package/dist/webhooks/github.js +167 -0
  58. package/dist/webhooks/middleware.d.ts +14 -0
  59. package/dist/webhooks/middleware.js +161 -0
  60. package/dist/webhooks/types.d.ts +17 -0
  61. package/dist/webhooks/types.js +4 -0
  62. package/package.json +72 -0
  63. package/schema/knowledge.graphql +134 -0
  64. package/web/index.html +735 -0
  65. package/web/js/app.js +461 -0
  66. package/web/js/detail.js +223 -0
  67. package/web/js/editor.js +303 -0
  68. package/web/js/search.js +238 -0
  69. 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
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Shared types for webhook handlers.
3
+ */
4
+ export {};
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
+ }