mcp-astgl-knowledge 1.0.2 → 1.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 +146 -38
- package/data/knowledge.db +0 -0
- package/dist/alerts.d.ts +22 -0
- package/dist/alerts.js +433 -0
- package/dist/alerts.js.map +1 -0
- package/dist/citation-test.d.ts +14 -0
- package/dist/citation-test.js +298 -0
- package/dist/citation-test.js.map +1 -0
- package/dist/daily-report.d.ts +15 -0
- package/dist/daily-report.js +441 -0
- package/dist/daily-report.js.map +1 -0
- package/dist/discover.js +3 -1
- package/dist/discover.js.map +1 -1
- package/dist/freshness.d.ts +20 -0
- package/dist/freshness.js +508 -0
- package/dist/freshness.js.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +253 -14
- package/dist/index.js.map +1 -1
- package/dist/ingest-projects.d.ts +16 -0
- package/dist/ingest-projects.js +196 -0
- package/dist/ingest-projects.js.map +1 -0
- package/dist/knowledge-db.d.ts +13 -0
- package/dist/knowledge-db.js +156 -0
- package/dist/knowledge-db.js.map +1 -0
- package/dist/pipeline.d.ts +12 -0
- package/dist/pipeline.js +83 -0
- package/dist/pipeline.js.map +1 -0
- package/dist/query-log.d.ts +15 -0
- package/dist/query-log.js +93 -0
- package/dist/query-log.js.map +1 -0
- package/dist/rate-limit.d.ts +34 -0
- package/dist/rate-limit.js +206 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/related-articles.d.ts +15 -0
- package/dist/related-articles.js +217 -0
- package/dist/related-articles.js.map +1 -0
- package/dist/search.d.ts +13 -4
- package/dist/search.js +274 -39
- package/dist/search.js.map +1 -1
- package/dist/structure.d.ts +11 -0
- package/dist/structure.js +451 -0
- package/dist/structure.js.map +1 -0
- package/dist/types.d.ts +65 -0
- package/dist/types.js.map +1 -1
- package/package.json +10 -2
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge database module for incremental updates.
|
|
3
|
+
* Provides UPSERT operations against knowledge.db without
|
|
4
|
+
* destroying existing data (unlike ingest.ts's DROP/CREATE).
|
|
5
|
+
*/
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { existsSync, mkdirSync } from "fs";
|
|
8
|
+
import Database from "better-sqlite3";
|
|
9
|
+
import * as sqliteVec from "sqlite-vec";
|
|
10
|
+
const DB_PATH = join(import.meta.dirname, "..", "data", "knowledge.db");
|
|
11
|
+
let db = null;
|
|
12
|
+
// WHAT: Open knowledge.db and run idempotent schema migrations
|
|
13
|
+
// WHY: Adds columns needed by the structuring pipeline without breaking existing data
|
|
14
|
+
export function initKnowledgeDb() {
|
|
15
|
+
const dataDir = join(import.meta.dirname, "..", "data");
|
|
16
|
+
if (!existsSync(dataDir))
|
|
17
|
+
mkdirSync(dataDir, { recursive: true });
|
|
18
|
+
if (!existsSync(DB_PATH)) {
|
|
19
|
+
throw new Error(`Knowledge database not found at ${DB_PATH}. Run 'npm run ingest' first to create the base schema.`);
|
|
20
|
+
}
|
|
21
|
+
db = new Database(DB_PATH);
|
|
22
|
+
sqliteVec.load(db);
|
|
23
|
+
runMigrations(db);
|
|
24
|
+
return db;
|
|
25
|
+
}
|
|
26
|
+
// WHAT: Add new columns and tables needed by the structuring pipeline
|
|
27
|
+
// WHY: ALTER TABLE with try/catch is idempotent — safe to re-run after ingest.ts rebuilds
|
|
28
|
+
function runMigrations(database) {
|
|
29
|
+
const alterColumns = [
|
|
30
|
+
"ALTER TABLE articles ADD COLUMN source_url TEXT",
|
|
31
|
+
"ALTER TABLE articles ADD COLUMN content_type TEXT DEFAULT 'article'",
|
|
32
|
+
"ALTER TABLE articles ADD COLUMN json_ld TEXT",
|
|
33
|
+
"ALTER TABLE articles ADD COLUMN processed_at TEXT",
|
|
34
|
+
"ALTER TABLE articles ADD COLUMN pub_date TEXT",
|
|
35
|
+
"ALTER TABLE articles ADD COLUMN last_reviewed_at TEXT",
|
|
36
|
+
"ALTER TABLE articles ADD COLUMN freshness_status TEXT DEFAULT 'current'",
|
|
37
|
+
];
|
|
38
|
+
for (const sql of alterColumns) {
|
|
39
|
+
try {
|
|
40
|
+
database.exec(sql);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Column already exists — expected after first run
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
database.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_articles_url ON articles(url)");
|
|
47
|
+
database.exec(`
|
|
48
|
+
CREATE TABLE IF NOT EXISTS article_qa (
|
|
49
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
50
|
+
article_url TEXT NOT NULL,
|
|
51
|
+
question TEXT NOT NULL,
|
|
52
|
+
answer TEXT NOT NULL,
|
|
53
|
+
UNIQUE(article_url, question)
|
|
54
|
+
)
|
|
55
|
+
`);
|
|
56
|
+
// WHAT: Track ecosystem versions for freshness detection
|
|
57
|
+
// WHY: Comparing current vs previous version detects when articles may be outdated
|
|
58
|
+
database.exec(`
|
|
59
|
+
CREATE TABLE IF NOT EXISTS ecosystem_snapshots (
|
|
60
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
61
|
+
check_type TEXT NOT NULL,
|
|
62
|
+
package_name TEXT NOT NULL,
|
|
63
|
+
current_version TEXT NOT NULL,
|
|
64
|
+
previous_version TEXT,
|
|
65
|
+
checked_at TEXT NOT NULL,
|
|
66
|
+
UNIQUE(check_type, package_name)
|
|
67
|
+
)
|
|
68
|
+
`);
|
|
69
|
+
}
|
|
70
|
+
// WHAT: Insert or replace an article in knowledge.db
|
|
71
|
+
// WHY: Keyed on url so discovered content can coexist with local articles
|
|
72
|
+
export function upsertArticle(database, article) {
|
|
73
|
+
const existing = database
|
|
74
|
+
.prepare("SELECT id FROM articles WHERE url = ?")
|
|
75
|
+
.get(article.url);
|
|
76
|
+
if (existing) {
|
|
77
|
+
database
|
|
78
|
+
.prepare(`UPDATE articles SET title = ?, description = ?, slug = ?,
|
|
79
|
+
source_url = ?, content_type = ?, json_ld = ?, processed_at = ?,
|
|
80
|
+
pub_date = COALESCE(?, pub_date)
|
|
81
|
+
WHERE id = ?`)
|
|
82
|
+
.run(article.title, article.description, article.slug, article.sourceUrl, article.contentType, article.jsonLd, article.processedAt, article.pubDate || null, existing.id);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
database
|
|
86
|
+
.prepare(`INSERT INTO articles (title, description, url, slug, source_url, content_type, json_ld, processed_at, pub_date)
|
|
87
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
88
|
+
.run(article.title, article.description, article.url, article.slug, article.sourceUrl, article.contentType, article.jsonLd, article.processedAt, article.pubDate || null);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// WHAT: Update freshness status for an article
|
|
92
|
+
// WHY: Freshness checker sets status without re-processing the full article
|
|
93
|
+
export function updateFreshnessStatus(database, articleUrl, status, reviewedAt) {
|
|
94
|
+
if (reviewedAt) {
|
|
95
|
+
database
|
|
96
|
+
.prepare("UPDATE articles SET freshness_status = ?, last_reviewed_at = ? WHERE url = ?")
|
|
97
|
+
.run(status, reviewedAt, articleUrl);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
database
|
|
101
|
+
.prepare("UPDATE articles SET freshness_status = ? WHERE url = ?")
|
|
102
|
+
.run(status, articleUrl);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// WHAT: Delete existing chunks + vectors for an article, insert new ones
|
|
106
|
+
// WHY: sqlite-vec doesn't support INSERT OR REPLACE — delete-then-insert in a transaction
|
|
107
|
+
export function replaceChunksForArticle(database, articleUrl, chunks, embeddings) {
|
|
108
|
+
const replaceAll = database.transaction(() => {
|
|
109
|
+
// Find existing chunk IDs for this article
|
|
110
|
+
const existingChunks = database
|
|
111
|
+
.prepare("SELECT id FROM chunks WHERE article_url = ?")
|
|
112
|
+
.all(articleUrl);
|
|
113
|
+
// Delete vectors for those chunks
|
|
114
|
+
const deleteVec = database.prepare("DELETE FROM vec_chunks WHERE chunk_id = ?");
|
|
115
|
+
for (const chunk of existingChunks) {
|
|
116
|
+
deleteVec.run(chunk.id);
|
|
117
|
+
}
|
|
118
|
+
// Delete the chunks themselves
|
|
119
|
+
database
|
|
120
|
+
.prepare("DELETE FROM chunks WHERE article_url = ?")
|
|
121
|
+
.run(articleUrl);
|
|
122
|
+
// Insert new chunks and vectors
|
|
123
|
+
const insertChunk = database.prepare(`INSERT INTO chunks (article_title, article_url, article_order, section_heading, chunk_type, content)
|
|
124
|
+
VALUES (?, ?, ?, ?, ?, ?)`);
|
|
125
|
+
const insertVec = database.prepare("INSERT INTO vec_chunks (chunk_id, embedding) VALUES (?, ?)");
|
|
126
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
127
|
+
const chunk = chunks[i];
|
|
128
|
+
const result = insertChunk.run(chunk.articleTitle, chunk.articleUrl, chunk.articleOrder, chunk.sectionHeading, chunk.chunkType, chunk.content);
|
|
129
|
+
const chunkId = result.lastInsertRowid;
|
|
130
|
+
insertVec.run(BigInt(chunkId), new Float32Array(embeddings[i]));
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
replaceAll();
|
|
134
|
+
}
|
|
135
|
+
// WHAT: Insert extracted Q&A pairs for an article
|
|
136
|
+
// WHY: Normalizes Q&A data for potential future querying
|
|
137
|
+
export function upsertQaPairs(database, articleUrl, pairs) {
|
|
138
|
+
// Clear existing pairs for this article
|
|
139
|
+
database
|
|
140
|
+
.prepare("DELETE FROM article_qa WHERE article_url = ?")
|
|
141
|
+
.run(articleUrl);
|
|
142
|
+
const insert = database.prepare("INSERT OR IGNORE INTO article_qa (article_url, question, answer) VALUES (?, ?, ?)");
|
|
143
|
+
const insertAll = database.transaction(() => {
|
|
144
|
+
for (const pair of pairs) {
|
|
145
|
+
insert.run(articleUrl, pair.question, pair.answer);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
insertAll();
|
|
149
|
+
}
|
|
150
|
+
export function closeKnowledgeDb() {
|
|
151
|
+
if (db) {
|
|
152
|
+
db.close();
|
|
153
|
+
db = null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
//# sourceMappingURL=knowledge-db.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"knowledge-db.js","sourceRoot":"","sources":["../src/knowledge-db.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Unified content pipeline: Discovery → Structuring → Indexed.
|
|
4
|
+
*
|
|
5
|
+
* WHAT: Runs discover.ts then structure.ts in sequence with smart skipping
|
|
6
|
+
* WHY: Single entry point for the full publish → queryable pipeline,
|
|
7
|
+
* skips structuring when no new content saves LLM compute
|
|
8
|
+
*
|
|
9
|
+
* Usage: npm run pipeline
|
|
10
|
+
* Designed to run as a single OpenClaw cron job (every 6 hours).
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
package/dist/pipeline.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Unified content pipeline: Discovery → Structuring → Indexed.
|
|
4
|
+
*
|
|
5
|
+
* WHAT: Runs discover.ts then structure.ts in sequence with smart skipping
|
|
6
|
+
* WHY: Single entry point for the full publish → queryable pipeline,
|
|
7
|
+
* skips structuring when no new content saves LLM compute
|
|
8
|
+
*
|
|
9
|
+
* Usage: npm run pipeline
|
|
10
|
+
* Designed to run as a single OpenClaw cron job (every 6 hours).
|
|
11
|
+
*/
|
|
12
|
+
import { execSync } from "child_process";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
const PROJECT_DIR = join(import.meta.dirname, "..");
|
|
15
|
+
// WHAT: Run a script and capture its JSON stdout
|
|
16
|
+
// WHY: Each step is its own process — isolates DB handles and crashes
|
|
17
|
+
function runStep(script) {
|
|
18
|
+
return execSync(`tsx ${script}`, {
|
|
19
|
+
cwd: PROJECT_DIR,
|
|
20
|
+
encoding: "utf-8",
|
|
21
|
+
// WHAT: Pipe stdout (JSON) back, let stderr flow to console
|
|
22
|
+
// WHY: stderr has progress logs, stdout has the structured summary
|
|
23
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
24
|
+
timeout: 300_000, // 5 min per step
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
async function main() {
|
|
28
|
+
const pipelineStart = performance.now();
|
|
29
|
+
console.error("=== Content Pipeline Started ===\n");
|
|
30
|
+
const summary = {
|
|
31
|
+
timestamp: new Date().toISOString(),
|
|
32
|
+
discovery: { error: "not run" },
|
|
33
|
+
structuring: { skipped: true, reason: "not run" },
|
|
34
|
+
duration_ms: 0,
|
|
35
|
+
};
|
|
36
|
+
// --- Step 1: Discovery ---
|
|
37
|
+
console.error("--- Step 1: Content Discovery ---");
|
|
38
|
+
let newItems = 0;
|
|
39
|
+
try {
|
|
40
|
+
const discoveryOutput = runStep("src/discover.ts");
|
|
41
|
+
const discoveryResult = JSON.parse(discoveryOutput);
|
|
42
|
+
summary.discovery = discoveryResult;
|
|
43
|
+
newItems = discoveryResult.totals.new;
|
|
44
|
+
console.error(`\nDiscovery complete: ${newItems} new, ${discoveryResult.totals.updated} updated, ${discoveryResult.totals.skipped} skipped\n`);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
48
|
+
console.error(`\nDiscovery FAILED: ${message}\n`);
|
|
49
|
+
summary.discovery = { error: message };
|
|
50
|
+
}
|
|
51
|
+
// --- Step 2: Structuring (conditional) ---
|
|
52
|
+
if (newItems > 0) {
|
|
53
|
+
console.error("--- Step 2: Content Structuring ---");
|
|
54
|
+
try {
|
|
55
|
+
const structuringOutput = runStep("src/structure.ts");
|
|
56
|
+
const structuringResult = JSON.parse(structuringOutput);
|
|
57
|
+
summary.structuring = structuringResult;
|
|
58
|
+
console.error(`\nStructuring complete: ${structuringResult.processed} processed, ${structuringResult.failed} failed\n`);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
62
|
+
console.error(`\nStructuring FAILED: ${message}\n`);
|
|
63
|
+
summary.structuring = { error: message };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else if ("error" in summary.discovery) {
|
|
67
|
+
console.error("--- Step 2: Skipped (discovery failed) ---\n");
|
|
68
|
+
summary.structuring = { skipped: true, reason: "discovery failed" };
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
console.error("--- Step 2: Skipped (no new content) ---\n");
|
|
72
|
+
summary.structuring = { skipped: true, reason: "no new content discovered" };
|
|
73
|
+
}
|
|
74
|
+
summary.duration_ms = Math.round(performance.now() - pipelineStart);
|
|
75
|
+
console.error(`=== Content Pipeline Complete (${(summary.duration_ms / 1000).toFixed(1)}s) ===`);
|
|
76
|
+
// JSON summary to stdout for OpenClaw logging
|
|
77
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
78
|
+
}
|
|
79
|
+
main().catch((err) => {
|
|
80
|
+
console.error("Pipeline fatal error:", err);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
});
|
|
83
|
+
//# sourceMappingURL=pipeline.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pipeline.js","sourceRoot":"","sources":["../src/pipeline.ts"],"names":[],"mappings":";AACA;;;;;;;;;GASG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AACzC,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,MAAM,WAAW,GAAG,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;AA0BpD,iDAAiD;AACjD,sEAAsE;AACtE,SAAS,OAAO,CAAC,MAAc;IAC7B,OAAO,QAAQ,CAAC,OAAO,MAAM,EAAE,EAAE;QAC/B,GAAG,EAAE,WAAW;QAChB,QAAQ,EAAE,OAAO;QACjB,4DAA4D;QAC5D,mEAAmE;QACnE,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,CAAC;QAClC,OAAO,EAAE,OAAO,EAAE,iBAAiB;KACpC,CAAC,CAAC;AACL,CAAC;AAED,KAAK,UAAU,IAAI;IACjB,MAAM,aAAa,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC;IACxC,OAAO,CAAC,KAAK,CAAC,oCAAoC,CAAC,CAAC;IAEpD,MAAM,OAAO,GAAoB;QAC/B,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;QACnC,SAAS,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE;QAC/B,WAAW,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE;QACjD,WAAW,EAAE,CAAC;KACf,CAAC;IAEF,4BAA4B;IAC5B,OAAO,CAAC,KAAK,CAAC,mCAAmC,CAAC,CAAC;IACnD,IAAI,QAAQ,GAAG,CAAC,CAAC;IAEjB,IAAI,CAAC;QACH,MAAM,eAAe,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;QACnD,MAAM,eAAe,GAAqB,IAAI,CAAC,KAAK,CAAC,eAAe,CAAC,CAAC;QACtE,OAAO,CAAC,SAAS,GAAG,eAAe,CAAC;QACpC,QAAQ,GAAG,eAAe,CAAC,MAAM,CAAC,GAAG,CAAC;QACtC,OAAO,CAAC,KAAK,CACX,yBAAyB,QAAQ,SAAS,eAAe,CAAC,MAAM,CAAC,OAAO,aAAa,eAAe,CAAC,MAAM,CAAC,OAAO,YAAY,CAChI,CAAC;IACJ,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,OAAO,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QACjE,OAAO,CAAC,KAAK,CAAC,uBAAuB,OAAO,IAAI,CAAC,CAAC;QAClD,OAAO,CAAC,SAAS,GAAG,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC;IACzC,CAAC;IAED,4CAA4C;IAC5C,IAAI,QAAQ,GAAG,CAAC,EAAE,CAAC;QACjB,OAAO,CAAC,KAAK,CAAC,qCAAqC,CAAC,CAAC;QACrD,IAAI,CAAC;YACH,MAAM,iBAAiB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAC;YACtD,MAAM,iBAAiB,GAAuB,IAAI,CAAC,KAAK,CAAC,iBAAiB,CAAC,CAAC;YAC5E,OAAO,CAAC,WAAW,GAAG,iBAAiB,CAAC;YACxC,OAAO,CAAC,KAAK,CACX,2BAA2B,iBAAiB,CAAC,SAAS,eAAe,iBAAiB,CAAC,MAAM,WAAW,CACzG,CAAC;QACJ,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,OAAO,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YACjE,OAAO,CAAC,KAAK,CAAC,yBAAyB,OAAO,IAAI,CAAC,CAAC;YACpD,OAAO,CAAC,WAAW,GAAG,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC;QAC3C,CAAC;IACH,CAAC;SAAM,IAAI,OAAO,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC;QACxC,OAAO,CAAC,KAAK,CAAC,8CAA8C,CAAC,CAAC;QAC9D,OAAO,CAAC,WAAW,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,kBAAkB,EAAE,CAAC;IACtE,CAAC;SAAM,CAAC;QACN,OAAO,CAAC,KAAK,CAAC,4CAA4C,CAAC,CAAC;QAC5D,OAAO,CAAC,WAAW,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,2BAA2B,EAAE,CAAC;IAC/E,CAAC;IAED,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,KAAK,CAAC,WAAW,CAAC,GAAG,EAAE,GAAG,aAAa,CAAC,CAAC;IACpE,OAAO,CAAC,KAAK,CACX,kCAAkC,CAAC,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,QAAQ,CAClF,CAAC;IAEF,8CAA8C;IAC9C,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;AAChD,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;IACnB,OAAO,CAAC,KAAK,CAAC,uBAAuB,EAAE,GAAG,CAAC,CAAC;IAC5C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query logging module for MCP analytics.
|
|
3
|
+
*
|
|
4
|
+
* WHAT: Logs every MCP tool invocation with timing, params, and cited content
|
|
5
|
+
* WHY: Enables analytics on what users ask, which content gets cited, and response quality
|
|
6
|
+
*
|
|
7
|
+
* Performance:
|
|
8
|
+
* Entries are buffered in memory and flushed to SQLite in batches
|
|
9
|
+
* (every 5 seconds or when buffer hits 20 entries) to avoid blocking responses.
|
|
10
|
+
*/
|
|
11
|
+
import Database from "better-sqlite3";
|
|
12
|
+
import type { QueryLogEntry } from "./types.js";
|
|
13
|
+
export declare function initQueryLog(): InstanceType<typeof Database>;
|
|
14
|
+
export declare function logQuery(entry: QueryLogEntry): void;
|
|
15
|
+
export declare function closeQueryLog(): void;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query logging module for MCP analytics.
|
|
3
|
+
*
|
|
4
|
+
* WHAT: Logs every MCP tool invocation with timing, params, and cited content
|
|
5
|
+
* WHY: Enables analytics on what users ask, which content gets cited, and response quality
|
|
6
|
+
*
|
|
7
|
+
* Performance:
|
|
8
|
+
* Entries are buffered in memory and flushed to SQLite in batches
|
|
9
|
+
* (every 5 seconds or when buffer hits 20 entries) to avoid blocking responses.
|
|
10
|
+
*/
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { existsSync, mkdirSync } from "fs";
|
|
13
|
+
import Database from "better-sqlite3";
|
|
14
|
+
const LOG_DB_PATH = join(import.meta.dirname, "..", "data", "query-log.db");
|
|
15
|
+
const FLUSH_INTERVAL_MS = 5_000;
|
|
16
|
+
const FLUSH_BATCH_SIZE = 20;
|
|
17
|
+
let logDb = null;
|
|
18
|
+
let buffer = [];
|
|
19
|
+
let flushTimer = null;
|
|
20
|
+
export function initQueryLog() {
|
|
21
|
+
const dataDir = join(import.meta.dirname, "..", "data");
|
|
22
|
+
if (!existsSync(dataDir))
|
|
23
|
+
mkdirSync(dataDir, { recursive: true });
|
|
24
|
+
logDb = new Database(LOG_DB_PATH);
|
|
25
|
+
logDb.exec(`
|
|
26
|
+
CREATE TABLE IF NOT EXISTS query_log (
|
|
27
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
28
|
+
timestamp TEXT NOT NULL,
|
|
29
|
+
client_id TEXT NOT NULL,
|
|
30
|
+
tool_name TEXT NOT NULL,
|
|
31
|
+
query_params TEXT NOT NULL,
|
|
32
|
+
content_cited TEXT,
|
|
33
|
+
response_time_ms INTEGER NOT NULL,
|
|
34
|
+
confidence_score REAL
|
|
35
|
+
)
|
|
36
|
+
`);
|
|
37
|
+
// WHAT: Index on timestamp + tool_name for common analytics queries
|
|
38
|
+
// WHY: Most reports filter by date range and/or tool
|
|
39
|
+
logDb.exec("CREATE INDEX IF NOT EXISTS idx_query_log_ts ON query_log(timestamp)");
|
|
40
|
+
logDb.exec("CREATE INDEX IF NOT EXISTS idx_query_log_tool ON query_log(tool_name)");
|
|
41
|
+
// WHAT: Start periodic flush timer
|
|
42
|
+
// WHY: Ensures buffered entries are written even during low-traffic periods
|
|
43
|
+
flushTimer = setInterval(flushBuffer, FLUSH_INTERVAL_MS);
|
|
44
|
+
flushTimer.unref(); // Don't keep process alive just for logging
|
|
45
|
+
// WHAT: Flush on process exit
|
|
46
|
+
// WHY: Don't lose buffered entries when the MCP server shuts down
|
|
47
|
+
process.on("beforeExit", flushBuffer);
|
|
48
|
+
return logDb;
|
|
49
|
+
}
|
|
50
|
+
// WHAT: Buffer log entries and flush when threshold is reached
|
|
51
|
+
// WHY: Removes ~1-5ms synchronous INSERT from every tool response
|
|
52
|
+
export function logQuery(entry) {
|
|
53
|
+
if (!logDb)
|
|
54
|
+
return;
|
|
55
|
+
buffer.push(entry);
|
|
56
|
+
if (buffer.length >= FLUSH_BATCH_SIZE) {
|
|
57
|
+
flushBuffer();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// WHAT: Write all buffered entries to SQLite in a single transaction
|
|
61
|
+
// WHY: Batch INSERT in a transaction is ~10x faster than individual INSERTs
|
|
62
|
+
function flushBuffer() {
|
|
63
|
+
if (!logDb || buffer.length === 0)
|
|
64
|
+
return;
|
|
65
|
+
const entries = buffer.splice(0);
|
|
66
|
+
try {
|
|
67
|
+
const insert = logDb.prepare(`INSERT INTO query_log (timestamp, client_id, tool_name, query_params, content_cited, response_time_ms, confidence_score)
|
|
68
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
69
|
+
const insertAll = logDb.transaction(() => {
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
insert.run(entry.timestamp, entry.clientId, entry.toolName, entry.queryParams, entry.contentCited, entry.responseTimeMs, entry.confidenceScore);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
insertAll();
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
// WHAT: Log error but don't crash — analytics loss is acceptable
|
|
78
|
+
// WHY: Query logging should never block or crash the MCP server
|
|
79
|
+
console.error(`Query log flush failed (${entries.length} entries lost):`, err instanceof Error ? err.message : err);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function closeQueryLog() {
|
|
83
|
+
if (flushTimer) {
|
|
84
|
+
clearInterval(flushTimer);
|
|
85
|
+
flushTimer = null;
|
|
86
|
+
}
|
|
87
|
+
flushBuffer(); // Final flush
|
|
88
|
+
if (logDb) {
|
|
89
|
+
logDb.close();
|
|
90
|
+
logDb = null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=query-log.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query-log.js","sourceRoot":"","sources":["../src/query-log.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limiting and registration module.
|
|
3
|
+
*
|
|
4
|
+
* WHAT: Enforces per-client daily query limits with public/registered tiers
|
|
5
|
+
* WHY: Protects server resources while incentivizing email registration for higher limits
|
|
6
|
+
*
|
|
7
|
+
* Tiers:
|
|
8
|
+
* Public: 50 queries/day (anonymous, persistent client ID)
|
|
9
|
+
* Registered: 500 queries/day (API key via ASTGL_API_KEY env var)
|
|
10
|
+
*/
|
|
11
|
+
export type Tier = "public" | "registered";
|
|
12
|
+
export interface RateLimitResult {
|
|
13
|
+
allowed: boolean;
|
|
14
|
+
tier: Tier;
|
|
15
|
+
clientId: string;
|
|
16
|
+
used: number;
|
|
17
|
+
limit: number;
|
|
18
|
+
remaining: number;
|
|
19
|
+
}
|
|
20
|
+
export interface RegistrationResult {
|
|
21
|
+
success: boolean;
|
|
22
|
+
apiKey?: string;
|
|
23
|
+
email?: string;
|
|
24
|
+
message: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function resolveClient(): {
|
|
27
|
+
clientId: string;
|
|
28
|
+
tier: Tier;
|
|
29
|
+
};
|
|
30
|
+
export declare function initRateLimitDb(): void;
|
|
31
|
+
export declare function checkRateLimit(clientId: string, tier: Tier): RateLimitResult;
|
|
32
|
+
export declare function rateLimitMessage(result: RateLimitResult): string;
|
|
33
|
+
export declare function registerClient(email: string): RegistrationResult;
|
|
34
|
+
export declare function closeRateLimitDb(): void;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limiting and registration module.
|
|
3
|
+
*
|
|
4
|
+
* WHAT: Enforces per-client daily query limits with public/registered tiers
|
|
5
|
+
* WHY: Protects server resources while incentivizing email registration for higher limits
|
|
6
|
+
*
|
|
7
|
+
* Tiers:
|
|
8
|
+
* Public: 50 queries/day (anonymous, persistent client ID)
|
|
9
|
+
* Registered: 500 queries/day (API key via ASTGL_API_KEY env var)
|
|
10
|
+
*/
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
13
|
+
import { homedir } from "os";
|
|
14
|
+
import { randomUUID, createHash } from "crypto";
|
|
15
|
+
import Database from "better-sqlite3";
|
|
16
|
+
const DATA_DIR = join(import.meta.dirname, "..", "data");
|
|
17
|
+
const RATE_DB_PATH = join(DATA_DIR, "query-log.db");
|
|
18
|
+
const CLIENT_ID_FILE = join(homedir(), ".astgl-client-id");
|
|
19
|
+
const PUBLIC_LIMIT = 50;
|
|
20
|
+
const REGISTERED_LIMIT = 500;
|
|
21
|
+
let rateLimitDb = null;
|
|
22
|
+
// --- Client Identity ---
|
|
23
|
+
// WHAT: Get or create a persistent anonymous client ID
|
|
24
|
+
// WHY: MCP stdio servers are ephemeral — need a stable ID across sessions for rate limiting
|
|
25
|
+
function getOrCreateAnonymousId() {
|
|
26
|
+
if (existsSync(CLIENT_ID_FILE)) {
|
|
27
|
+
const id = readFileSync(CLIENT_ID_FILE, "utf-8").trim();
|
|
28
|
+
if (id)
|
|
29
|
+
return id;
|
|
30
|
+
}
|
|
31
|
+
const id = `anon_${randomUUID()}`;
|
|
32
|
+
try {
|
|
33
|
+
writeFileSync(CLIENT_ID_FILE, id, "utf-8");
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// WHAT: Fall back to hostname-based ID if file write fails
|
|
37
|
+
// WHY: Some environments restrict home directory writes (containers, CI)
|
|
38
|
+
return `anon_${createHash("sha256").update(homedir()).digest("hex").slice(0, 16)}`;
|
|
39
|
+
}
|
|
40
|
+
return id;
|
|
41
|
+
}
|
|
42
|
+
// WHAT: Resolve the effective client ID and tier
|
|
43
|
+
// WHY: API key takes precedence over anonymous ID; determines rate limit tier
|
|
44
|
+
export function resolveClient() {
|
|
45
|
+
const apiKey = process.env.ASTGL_API_KEY;
|
|
46
|
+
if (apiKey && apiKey.startsWith("astgl_")) {
|
|
47
|
+
// Validate API key against registrations
|
|
48
|
+
if (rateLimitDb) {
|
|
49
|
+
const reg = rateLimitDb
|
|
50
|
+
.prepare("SELECT email FROM registrations WHERE api_key = ?")
|
|
51
|
+
.get(apiKey);
|
|
52
|
+
if (reg) {
|
|
53
|
+
return { clientId: apiKey, tier: "registered" };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Invalid key — fall through to anonymous
|
|
57
|
+
console.error(`Warning: ASTGL_API_KEY provided but not found in registrations.`);
|
|
58
|
+
}
|
|
59
|
+
return { clientId: getOrCreateAnonymousId(), tier: "public" };
|
|
60
|
+
}
|
|
61
|
+
// --- Rate Limiting ---
|
|
62
|
+
export function initRateLimitDb() {
|
|
63
|
+
if (!existsSync(DATA_DIR))
|
|
64
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
65
|
+
// WHAT: Reuse query-log.db for rate limit tracking
|
|
66
|
+
// WHY: Query counts are already there — just need a registrations table
|
|
67
|
+
rateLimitDb = new Database(RATE_DB_PATH);
|
|
68
|
+
// Ensure query_log table exists (may not if server hasn't been used yet)
|
|
69
|
+
rateLimitDb.exec(`
|
|
70
|
+
CREATE TABLE IF NOT EXISTS query_log (
|
|
71
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
72
|
+
timestamp TEXT NOT NULL,
|
|
73
|
+
client_id TEXT NOT NULL,
|
|
74
|
+
tool_name TEXT NOT NULL,
|
|
75
|
+
query_params TEXT NOT NULL,
|
|
76
|
+
content_cited TEXT,
|
|
77
|
+
response_time_ms INTEGER NOT NULL,
|
|
78
|
+
confidence_score REAL
|
|
79
|
+
)
|
|
80
|
+
`);
|
|
81
|
+
rateLimitDb.exec("CREATE INDEX IF NOT EXISTS idx_query_log_ts ON query_log(timestamp)");
|
|
82
|
+
rateLimitDb.exec("CREATE INDEX IF NOT EXISTS idx_query_log_tool ON query_log(tool_name)");
|
|
83
|
+
rateLimitDb.exec("CREATE INDEX IF NOT EXISTS idx_query_log_client ON query_log(client_id)");
|
|
84
|
+
rateLimitDb.exec(`
|
|
85
|
+
CREATE TABLE IF NOT EXISTS registrations (
|
|
86
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
87
|
+
email TEXT NOT NULL UNIQUE,
|
|
88
|
+
api_key TEXT NOT NULL UNIQUE,
|
|
89
|
+
created_at TEXT NOT NULL
|
|
90
|
+
)
|
|
91
|
+
`);
|
|
92
|
+
}
|
|
93
|
+
// --- Rate Limit Cache ---
|
|
94
|
+
// WHAT: Cache rate limit results for 5 seconds per client
|
|
95
|
+
// WHY: At 500 queries/day (~1 every 3 min), a 5s cache has zero practical
|
|
96
|
+
// accuracy impact but eliminates a COUNT(*) query from most requests
|
|
97
|
+
const RATE_CACHE_TTL_MS = 5_000;
|
|
98
|
+
const rateLimitCache = new Map();
|
|
99
|
+
// WHAT: Check if a client has queries remaining today
|
|
100
|
+
// WHY: Enforces tier-based daily limits without blocking the server
|
|
101
|
+
export function checkRateLimit(clientId, tier) {
|
|
102
|
+
const limit = tier === "registered" ? REGISTERED_LIMIT : PUBLIC_LIMIT;
|
|
103
|
+
if (!rateLimitDb) {
|
|
104
|
+
return { allowed: true, tier, clientId, used: 0, limit, remaining: limit };
|
|
105
|
+
}
|
|
106
|
+
// Check cache first
|
|
107
|
+
const cacheKey = `${clientId}:${tier}`;
|
|
108
|
+
const cached = rateLimitCache.get(cacheKey);
|
|
109
|
+
if (cached && cached.expiry > Date.now()) {
|
|
110
|
+
return cached.result;
|
|
111
|
+
}
|
|
112
|
+
// WHAT: Count queries for this client today (UTC)
|
|
113
|
+
// WHY: Daily reset at midnight UTC keeps limits predictable
|
|
114
|
+
const today = new Date().toISOString().split("T")[0];
|
|
115
|
+
const todayStart = `${today}T00:00:00.000Z`;
|
|
116
|
+
const tomorrowStart = `${today}T23:59:59.999Z`;
|
|
117
|
+
const row = rateLimitDb
|
|
118
|
+
.prepare(`SELECT COUNT(*) as count FROM query_log
|
|
119
|
+
WHERE client_id = ? AND timestamp >= ? AND timestamp <= ?`)
|
|
120
|
+
.get(clientId, todayStart, tomorrowStart);
|
|
121
|
+
const used = row.count;
|
|
122
|
+
const remaining = Math.max(0, limit - used);
|
|
123
|
+
const result = {
|
|
124
|
+
allowed: used < limit,
|
|
125
|
+
tier,
|
|
126
|
+
clientId,
|
|
127
|
+
used,
|
|
128
|
+
limit,
|
|
129
|
+
remaining,
|
|
130
|
+
};
|
|
131
|
+
rateLimitCache.set(cacheKey, {
|
|
132
|
+
result,
|
|
133
|
+
expiry: Date.now() + RATE_CACHE_TTL_MS,
|
|
134
|
+
});
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
// WHAT: Format a rate limit exceeded message for the AI client
|
|
138
|
+
// WHY: Clear messaging helps the AI explain the situation and suggest registration
|
|
139
|
+
export function rateLimitMessage(result) {
|
|
140
|
+
if (result.tier === "public") {
|
|
141
|
+
return [
|
|
142
|
+
`Rate limit exceeded: ${result.used}/${result.limit} queries used today (public tier).`,
|
|
143
|
+
"",
|
|
144
|
+
"To get 500 queries/day, register with your email using the `register` tool.",
|
|
145
|
+
"Then add the API key to your MCP config as ASTGL_API_KEY.",
|
|
146
|
+
"",
|
|
147
|
+
"Rate limits reset at midnight UTC.",
|
|
148
|
+
].join("\n");
|
|
149
|
+
}
|
|
150
|
+
return [
|
|
151
|
+
`Rate limit exceeded: ${result.used}/${result.limit} queries used today (registered tier).`,
|
|
152
|
+
"",
|
|
153
|
+
"Rate limits reset at midnight UTC.",
|
|
154
|
+
].join("\n");
|
|
155
|
+
}
|
|
156
|
+
// --- Registration ---
|
|
157
|
+
// WHAT: Register an email and generate an API key
|
|
158
|
+
// WHY: Email capture builds an audience; API key enables higher rate limits
|
|
159
|
+
export function registerClient(email) {
|
|
160
|
+
if (!rateLimitDb) {
|
|
161
|
+
return { success: false, message: "Registration database not available." };
|
|
162
|
+
}
|
|
163
|
+
// Basic email validation
|
|
164
|
+
if (!email || !email.includes("@") || !email.includes(".")) {
|
|
165
|
+
return { success: false, message: "Please provide a valid email address." };
|
|
166
|
+
}
|
|
167
|
+
const normalized = email.trim().toLowerCase();
|
|
168
|
+
// Check if already registered
|
|
169
|
+
const existing = rateLimitDb
|
|
170
|
+
.prepare("SELECT api_key FROM registrations WHERE email = ?")
|
|
171
|
+
.get(normalized);
|
|
172
|
+
if (existing) {
|
|
173
|
+
return {
|
|
174
|
+
success: true,
|
|
175
|
+
apiKey: existing.api_key,
|
|
176
|
+
email: normalized,
|
|
177
|
+
message: `Already registered. Your API key: ${existing.api_key}`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
// Generate API key
|
|
181
|
+
const apiKey = `astgl_${randomUUID().replace(/-/g, "")}`;
|
|
182
|
+
rateLimitDb
|
|
183
|
+
.prepare("INSERT INTO registrations (email, api_key, created_at) VALUES (?, ?, ?)")
|
|
184
|
+
.run(normalized, apiKey, new Date().toISOString());
|
|
185
|
+
return {
|
|
186
|
+
success: true,
|
|
187
|
+
apiKey,
|
|
188
|
+
email: normalized,
|
|
189
|
+
message: [
|
|
190
|
+
`Registered successfully! Your API key: ${apiKey}`,
|
|
191
|
+
"",
|
|
192
|
+
"Add it to your MCP server config to unlock 500 queries/day:",
|
|
193
|
+
"",
|
|
194
|
+
'```json',
|
|
195
|
+
'"env": { "ASTGL_API_KEY": "' + apiKey + '" }',
|
|
196
|
+
'```',
|
|
197
|
+
].join("\n"),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
export function closeRateLimitDb() {
|
|
201
|
+
if (rateLimitDb) {
|
|
202
|
+
rateLimitDb.close();
|
|
203
|
+
rateLimitDb = null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
//# sourceMappingURL=rate-limit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limit.js","sourceRoot":"","sources":["../src/rate-limit.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Internal linking automation via vector similarity.
|
|
4
|
+
*
|
|
5
|
+
* WHAT: Computes pairwise article similarity and injects related article links
|
|
6
|
+
* WHY: Cross-referencing boosts SEO, AI discoverability, and reader engagement
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* npm run related Compute + print JSON map
|
|
10
|
+
* npm run related -- --inject Also inject into Astro markdown frontmatter
|
|
11
|
+
* npm run related -- --top 3 Number of related articles per article (default: 3)
|
|
12
|
+
*
|
|
13
|
+
* Requires: Ollama running with nomic-embed-text
|
|
14
|
+
*/
|
|
15
|
+
export {};
|