@serticode/thoth 1.0.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 +153 -0
- package/dist/index.js +3209 -0
- package/package.json +51 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3209 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/infrastructure/config/config-loader.ts
|
|
7
|
+
import { readFileSync, existsSync } from "fs";
|
|
8
|
+
import { join, resolve } from "path";
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
|
|
11
|
+
// src/infrastructure/config/config.schema.ts
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
var thothConfigSchema = z.object({
|
|
14
|
+
provider: z.enum(["openai", "groq", "gemini", "ollama", "anthropic"]).optional(),
|
|
15
|
+
chatModel: z.string().optional(),
|
|
16
|
+
embeddingModel: z.string().optional(),
|
|
17
|
+
local: z.boolean().optional(),
|
|
18
|
+
dbPath: z.string().optional(),
|
|
19
|
+
logLevel: z.string().optional(),
|
|
20
|
+
export: z.object({
|
|
21
|
+
format: z.enum(["md", "html", "txt", "rss"]).optional(),
|
|
22
|
+
outputDir: z.string().optional()
|
|
23
|
+
}).optional()
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// src/infrastructure/config/config-loader.ts
|
|
27
|
+
var CONFIG_FILENAME = "thoth.json";
|
|
28
|
+
function configWarn(message) {
|
|
29
|
+
process.stderr.write(`\x1B[33m[config]\x1B[0m ${message}
|
|
30
|
+
`);
|
|
31
|
+
}
|
|
32
|
+
function findConfigFiles() {
|
|
33
|
+
const paths = [];
|
|
34
|
+
const cwdConfig = resolve(process.cwd(), CONFIG_FILENAME);
|
|
35
|
+
if (existsSync(cwdConfig)) {
|
|
36
|
+
paths.push(cwdConfig);
|
|
37
|
+
}
|
|
38
|
+
const homeDir = homedir();
|
|
39
|
+
const homeConfig = join(homeDir, ".thoth", CONFIG_FILENAME);
|
|
40
|
+
if (existsSync(homeConfig)) {
|
|
41
|
+
paths.push(homeConfig);
|
|
42
|
+
}
|
|
43
|
+
return paths;
|
|
44
|
+
}
|
|
45
|
+
function readAndParseConfig(filePath) {
|
|
46
|
+
try {
|
|
47
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
48
|
+
const parsed = JSON.parse(raw);
|
|
49
|
+
const result = thothConfigSchema.safeParse(parsed);
|
|
50
|
+
if (!result.success) {
|
|
51
|
+
configWarn(`Config file ${filePath} has invalid fields: ${result.error.message}`);
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
return result.data;
|
|
55
|
+
} catch (error2) {
|
|
56
|
+
configWarn(`Could not read config file ${filePath}: ${error2 instanceof Error ? error2.message : String(error2)}`);
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function loadConfig() {
|
|
61
|
+
const configFiles = findConfigFiles();
|
|
62
|
+
let config = {};
|
|
63
|
+
for (const filePath of configFiles) {
|
|
64
|
+
const partial = readAndParseConfig(filePath);
|
|
65
|
+
config = { ...config, ...partial };
|
|
66
|
+
}
|
|
67
|
+
return config;
|
|
68
|
+
}
|
|
69
|
+
var DEFAULTS = {
|
|
70
|
+
logLevel: "error"
|
|
71
|
+
};
|
|
72
|
+
function resolveConfig(cliLocal, cliProvider) {
|
|
73
|
+
const fileConfig = loadConfig();
|
|
74
|
+
const envLocal = process.env.THOTH_LOCAL === "true";
|
|
75
|
+
const logLevel = process.env.LOG_LEVEL ?? fileConfig.logLevel ?? DEFAULTS.logLevel;
|
|
76
|
+
const dbPath = process.env.THOTH_DB_PATH ?? fileConfig.dbPath;
|
|
77
|
+
const local = cliLocal ?? envLocal ?? fileConfig.local ?? false;
|
|
78
|
+
const provider = cliProvider ?? process.env.THOTH_PROVIDER ?? fileConfig.provider;
|
|
79
|
+
const chatModel2 = process.env.THOTH_CHAT_MODEL ?? fileConfig.chatModel;
|
|
80
|
+
const embeddingModel2 = process.env.THOTH_EMBEDDING_MODEL ?? fileConfig.embeddingModel;
|
|
81
|
+
return {
|
|
82
|
+
logLevel,
|
|
83
|
+
dbPath,
|
|
84
|
+
local,
|
|
85
|
+
provider,
|
|
86
|
+
chatModel: chatModel2,
|
|
87
|
+
embeddingModel: embeddingModel2,
|
|
88
|
+
export: fileConfig.export
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function applyConfig(config) {
|
|
92
|
+
if (config.logLevel) process.env.LOG_LEVEL = config.logLevel;
|
|
93
|
+
if (config.dbPath) process.env.THOTH_DB_PATH = config.dbPath;
|
|
94
|
+
if (config.provider) process.env.THOTH_PROVIDER = config.provider;
|
|
95
|
+
if (config.chatModel) process.env.THOTH_CHAT_MODEL = config.chatModel;
|
|
96
|
+
if (config.embeddingModel) process.env.THOTH_EMBEDDING_MODEL = config.embeddingModel;
|
|
97
|
+
if (config.local !== void 0) process.env.THOTH_LOCAL = config.local ? "true" : "false";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/infrastructure/persistence/database.ts
|
|
101
|
+
import Database from "better-sqlite3";
|
|
102
|
+
import * as sqliteVec from "sqlite-vec";
|
|
103
|
+
import { join as join2, dirname } from "path";
|
|
104
|
+
import { homedir as homedir3 } from "os";
|
|
105
|
+
import { mkdirSync, chmodSync } from "fs";
|
|
106
|
+
|
|
107
|
+
// src/infrastructure/logging/logger.ts
|
|
108
|
+
import pino from "pino";
|
|
109
|
+
var pinoInstance = pino({
|
|
110
|
+
level: process.env.LOG_LEVEL ?? "error",
|
|
111
|
+
transport: process.env.NODE_ENV !== "production" ? { target: "pino/file", options: { destination: 2 } } : void 0
|
|
112
|
+
});
|
|
113
|
+
var logger = {
|
|
114
|
+
info(obj, msg) {
|
|
115
|
+
if (typeof obj === "string") {
|
|
116
|
+
pinoInstance.info(obj);
|
|
117
|
+
} else {
|
|
118
|
+
pinoInstance.info(obj, msg ?? "");
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
warn(obj, msg) {
|
|
122
|
+
if (typeof obj === "string") {
|
|
123
|
+
pinoInstance.warn(obj);
|
|
124
|
+
} else {
|
|
125
|
+
pinoInstance.warn(obj, msg ?? "");
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
error(obj, msg) {
|
|
129
|
+
if (typeof obj === "string") {
|
|
130
|
+
pinoInstance.error(obj);
|
|
131
|
+
} else {
|
|
132
|
+
pinoInstance.error(obj, msg ?? "");
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
debug(obj, msg) {
|
|
136
|
+
if (typeof obj === "string") {
|
|
137
|
+
pinoInstance.debug(obj);
|
|
138
|
+
} else {
|
|
139
|
+
pinoInstance.debug(obj, msg ?? "");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// src/infrastructure/logging/path-utils.ts
|
|
145
|
+
import { homedir as homedir2 } from "os";
|
|
146
|
+
var HOME_DIR = homedir2();
|
|
147
|
+
function sanitizePath(filePath) {
|
|
148
|
+
if (filePath.startsWith(HOME_DIR)) {
|
|
149
|
+
return filePath.replace(HOME_DIR, "~");
|
|
150
|
+
}
|
|
151
|
+
return filePath;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// src/infrastructure/persistence/migrations/001_initial.ts
|
|
155
|
+
var MIGRATION_001 = `-- Thoth initial schema: sources, profiles, and vector indexes
|
|
156
|
+
|
|
157
|
+
CREATE TABLE IF NOT EXISTS sources (
|
|
158
|
+
id TEXT PRIMARY KEY,
|
|
159
|
+
type TEXT NOT NULL CHECK(type IN ('voice', 'knowledge', 'publication')),
|
|
160
|
+
source_path TEXT NOT NULL,
|
|
161
|
+
content TEXT NOT NULL,
|
|
162
|
+
checksum TEXT NOT NULL,
|
|
163
|
+
chunk_index INTEGER NOT NULL,
|
|
164
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
CREATE INDEX IF NOT EXISTS idx_sources_type ON sources(type);
|
|
168
|
+
CREATE INDEX IF NOT EXISTS idx_sources_checksum ON sources(checksum);
|
|
169
|
+
|
|
170
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vec_sources USING vec0(
|
|
171
|
+
embedding float[1536]
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
CREATE TABLE IF NOT EXISTS source_embeddings (
|
|
175
|
+
id INTEGER PRIMARY KEY,
|
|
176
|
+
source_id TEXT NOT NULL REFERENCES sources(id),
|
|
177
|
+
model TEXT NOT NULL,
|
|
178
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
CREATE INDEX IF NOT EXISTS idx_source_embeddings_source_id ON source_embeddings(source_id);
|
|
182
|
+
|
|
183
|
+
CREATE TABLE IF NOT EXISTS voice_profiles (
|
|
184
|
+
id TEXT PRIMARY KEY,
|
|
185
|
+
name TEXT,
|
|
186
|
+
traits TEXT NOT NULL,
|
|
187
|
+
summary TEXT,
|
|
188
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
CREATE TABLE IF NOT EXISTS knowledge_profiles (
|
|
192
|
+
id TEXT PRIMARY KEY,
|
|
193
|
+
domains TEXT NOT NULL,
|
|
194
|
+
topics TEXT NOT NULL,
|
|
195
|
+
summary TEXT,
|
|
196
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
CREATE TABLE IF NOT EXISTS publication_profiles (
|
|
200
|
+
id TEXT PRIMARY KEY,
|
|
201
|
+
themes TEXT NOT NULL,
|
|
202
|
+
series TEXT,
|
|
203
|
+
summary TEXT,
|
|
204
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vec_profiles USING vec0(
|
|
208
|
+
embedding float[1536]
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
CREATE TABLE IF NOT EXISTS profile_embeddings (
|
|
212
|
+
id INTEGER PRIMARY KEY,
|
|
213
|
+
profile_id TEXT NOT NULL,
|
|
214
|
+
profile_type TEXT NOT NULL CHECK(profile_type IN ('voice', 'knowledge', 'publication')),
|
|
215
|
+
model TEXT NOT NULL,
|
|
216
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
CREATE INDEX IF NOT EXISTS idx_profile_embeddings_profile_id ON profile_embeddings(profile_id);
|
|
220
|
+
`;
|
|
221
|
+
|
|
222
|
+
// src/infrastructure/persistence/migrations/002_research.ts
|
|
223
|
+
var MIGRATION_002 = `-- Research notes and vector index
|
|
224
|
+
|
|
225
|
+
CREATE TABLE IF NOT EXISTS research_notes (
|
|
226
|
+
id TEXT PRIMARY KEY,
|
|
227
|
+
topic TEXT NOT NULL,
|
|
228
|
+
content TEXT NOT NULL,
|
|
229
|
+
citations TEXT NOT NULL,
|
|
230
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
CREATE INDEX IF NOT EXISTS idx_research_notes_topic ON research_notes(topic);
|
|
234
|
+
|
|
235
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vec_research USING vec0(
|
|
236
|
+
embedding float[1536]
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
CREATE TABLE IF NOT EXISTS research_embeddings (
|
|
240
|
+
id INTEGER PRIMARY KEY,
|
|
241
|
+
research_id TEXT NOT NULL REFERENCES research_notes(id),
|
|
242
|
+
model TEXT NOT NULL,
|
|
243
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
244
|
+
);
|
|
245
|
+
`;
|
|
246
|
+
|
|
247
|
+
// src/infrastructure/persistence/migrations/003_articles.ts
|
|
248
|
+
var MIGRATION_003 = `-- Generated articles table
|
|
249
|
+
|
|
250
|
+
CREATE TABLE IF NOT EXISTS articles (
|
|
251
|
+
id TEXT PRIMARY KEY,
|
|
252
|
+
title TEXT NOT NULL,
|
|
253
|
+
content TEXT NOT NULL,
|
|
254
|
+
voice_profile_id TEXT NOT NULL,
|
|
255
|
+
research_id TEXT,
|
|
256
|
+
word_count INTEGER NOT NULL DEFAULT 0,
|
|
257
|
+
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'published', 'archived')),
|
|
258
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
259
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status);
|
|
263
|
+
CREATE INDEX IF NOT EXISTS idx_articles_created_at ON articles(created_at);
|
|
264
|
+
`;
|
|
265
|
+
|
|
266
|
+
// src/infrastructure/persistence/migrations/004_series.ts
|
|
267
|
+
var MIGRATION_004 = `-- Series and series-article relationships
|
|
268
|
+
|
|
269
|
+
CREATE TABLE IF NOT EXISTS series (
|
|
270
|
+
id TEXT PRIMARY KEY,
|
|
271
|
+
name TEXT NOT NULL,
|
|
272
|
+
description TEXT,
|
|
273
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
274
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
CREATE TABLE IF NOT EXISTS series_articles (
|
|
278
|
+
series_id TEXT NOT NULL REFERENCES series(id) ON DELETE CASCADE,
|
|
279
|
+
article_id TEXT NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
|
280
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
281
|
+
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
282
|
+
PRIMARY KEY (series_id, article_id)
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
CREATE INDEX IF NOT EXISTS idx_series_articles_series ON series_articles(series_id);
|
|
286
|
+
CREATE INDEX IF NOT EXISTS idx_series_articles_article ON series_articles(article_id);
|
|
287
|
+
`;
|
|
288
|
+
|
|
289
|
+
// src/infrastructure/persistence/migrations/005_import_log.ts
|
|
290
|
+
var MIGRATION_005 = `-- Import checkpoint log for resume/dedup
|
|
291
|
+
|
|
292
|
+
CREATE TABLE IF NOT EXISTS import_log (
|
|
293
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
294
|
+
source_path TEXT NOT NULL,
|
|
295
|
+
checksum TEXT NOT NULL,
|
|
296
|
+
type TEXT NOT NULL,
|
|
297
|
+
imported_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
CREATE INDEX IF NOT EXISTS idx_import_log_path ON import_log(source_path);
|
|
301
|
+
CREATE INDEX IF NOT EXISTS idx_import_log_checksum ON import_log(checksum);
|
|
302
|
+
`;
|
|
303
|
+
|
|
304
|
+
// src/infrastructure/persistence/migrations/006_vectors_per_provider.ts
|
|
305
|
+
var MIGRATION_006 = `-- Per-provider vector tables
|
|
306
|
+
|
|
307
|
+
DROP TABLE IF EXISTS vec_sources;
|
|
308
|
+
DROP TABLE IF EXISTS vec_profiles;
|
|
309
|
+
DROP TABLE IF EXISTS vec_research;
|
|
310
|
+
|
|
311
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vec_sources_openai USING vec0(
|
|
312
|
+
embedding float[1536]
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vec_sources_ollama USING vec0(
|
|
316
|
+
embedding float[768]
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vec_sources_gemini USING vec0(
|
|
320
|
+
embedding float[768]
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vec_profiles_openai USING vec0(
|
|
324
|
+
embedding float[1536]
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vec_profiles_ollama USING vec0(
|
|
328
|
+
embedding float[768]
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vec_profiles_gemini USING vec0(
|
|
332
|
+
embedding float[768]
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vec_research_openai USING vec0(
|
|
336
|
+
embedding float[1536]
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vec_research_ollama USING vec0(
|
|
340
|
+
embedding float[768]
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vec_research_gemini USING vec0(
|
|
344
|
+
embedding float[768]
|
|
345
|
+
);
|
|
346
|
+
`;
|
|
347
|
+
|
|
348
|
+
// src/infrastructure/persistence/migrations/007_medium.ts
|
|
349
|
+
var MIGRATION_007 = `-- Medium publishing support
|
|
350
|
+
|
|
351
|
+
ALTER TABLE articles ADD COLUMN medium_url TEXT;
|
|
352
|
+
`;
|
|
353
|
+
|
|
354
|
+
// src/infrastructure/persistence/database.ts
|
|
355
|
+
var db = null;
|
|
356
|
+
var cachedDbPath = null;
|
|
357
|
+
function setDbPath(path) {
|
|
358
|
+
cachedDbPath = path;
|
|
359
|
+
}
|
|
360
|
+
function resolveDbPath() {
|
|
361
|
+
if (cachedDbPath) return cachedDbPath;
|
|
362
|
+
return process.env.THOTH_DB_PATH ?? join2(homedir3(), ".thoth", "thoth.db");
|
|
363
|
+
}
|
|
364
|
+
function getDatabase() {
|
|
365
|
+
if (db) return db;
|
|
366
|
+
const dbPath = resolveDbPath();
|
|
367
|
+
const dbDir = dirname(dbPath);
|
|
368
|
+
mkdirSync(dbDir, { recursive: true });
|
|
369
|
+
logger.info({ path: sanitizePath(dbPath) }, "Opening database");
|
|
370
|
+
db = new Database(dbPath);
|
|
371
|
+
db.pragma("journal_mode = WAL");
|
|
372
|
+
db.pragma("foreign_keys = ON");
|
|
373
|
+
try {
|
|
374
|
+
chmodSync(dbPath, 384);
|
|
375
|
+
} catch {
|
|
376
|
+
logger.warn({ path: sanitizePath(dbPath) }, "Could not set database file permissions");
|
|
377
|
+
}
|
|
378
|
+
sqliteVec.load(db);
|
|
379
|
+
runMigrations(db);
|
|
380
|
+
return db;
|
|
381
|
+
}
|
|
382
|
+
function runMigrations(db3) {
|
|
383
|
+
db3.exec(
|
|
384
|
+
"CREATE TABLE IF NOT EXISTS _migrations (id INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL DEFAULT (datetime('now')))"
|
|
385
|
+
);
|
|
386
|
+
const migrations = [
|
|
387
|
+
{ name: "001_initial.sql", sql: MIGRATION_001 },
|
|
388
|
+
{ name: "002_research.sql", sql: MIGRATION_002 },
|
|
389
|
+
{ name: "003_articles.sql", sql: MIGRATION_003 },
|
|
390
|
+
{ name: "004_series.sql", sql: MIGRATION_004 },
|
|
391
|
+
{ name: "005_import_log.sql", sql: MIGRATION_005 },
|
|
392
|
+
{ name: "006_vectors_per_provider.sql", sql: MIGRATION_006 },
|
|
393
|
+
{ name: "007_medium.sql", sql: MIGRATION_007 }
|
|
394
|
+
];
|
|
395
|
+
for (const migration of migrations) {
|
|
396
|
+
const applied = db3.prepare("SELECT 1 FROM _migrations WHERE name = ?").get(migration.name);
|
|
397
|
+
if (applied) continue;
|
|
398
|
+
const applyMigration = db3.transaction(() => {
|
|
399
|
+
db3.exec(migration.sql);
|
|
400
|
+
db3.prepare("INSERT INTO _migrations (name) VALUES (?)").run(migration.name);
|
|
401
|
+
});
|
|
402
|
+
applyMigration();
|
|
403
|
+
logger.info({ migration: migration.name }, "Applied migration");
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
function closeDatabase() {
|
|
407
|
+
if (db) {
|
|
408
|
+
db.close();
|
|
409
|
+
db = null;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// src/infrastructure/ai/ai.service.ts
|
|
414
|
+
import OpenAI from "openai";
|
|
415
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
416
|
+
|
|
417
|
+
// src/infrastructure/ai/provider.ts
|
|
418
|
+
var DEFAULTS2 = {
|
|
419
|
+
ollamaBaseURL: "http://localhost:11434/v1",
|
|
420
|
+
ollamaChat: "qwen2.5:7b",
|
|
421
|
+
ollamaEmbed: "nomic-embed-text",
|
|
422
|
+
geminiChat: "gemini-1.5-flash",
|
|
423
|
+
geminiEmbed: "text-embedding-004",
|
|
424
|
+
groqChat: "mixtral-8x7b-32768",
|
|
425
|
+
openaiChat: "gpt-4o-mini",
|
|
426
|
+
openaiEmbed: "text-embedding-3-small",
|
|
427
|
+
anthropicChat: "claude-sonnet-4-20250514"
|
|
428
|
+
};
|
|
429
|
+
function isSelectedProvider(provider) {
|
|
430
|
+
return process.env.THOTH_PROVIDER === provider || provider === "ollama" && process.env.THOTH_LOCAL === "true";
|
|
431
|
+
}
|
|
432
|
+
function chatModel(provider, providerSpecific, fallback) {
|
|
433
|
+
return providerSpecific ?? (isSelectedProvider(provider) ? process.env.THOTH_CHAT_MODEL : void 0) ?? fallback;
|
|
434
|
+
}
|
|
435
|
+
function embeddingModel(provider, providerSpecific, fallback) {
|
|
436
|
+
return providerSpecific ?? (isSelectedProvider(provider) ? process.env.THOTH_EMBEDDING_MODEL : void 0) ?? fallback;
|
|
437
|
+
}
|
|
438
|
+
function getChatProviders() {
|
|
439
|
+
const providers = [];
|
|
440
|
+
const localOnly = process.env.THOTH_LOCAL === "true";
|
|
441
|
+
const preferredProvider = process.env.THOTH_PROVIDER;
|
|
442
|
+
if (preferredProvider && preferredProvider !== "openai") {
|
|
443
|
+
} else if (!localOnly && process.env.OPENAI_API_KEY) {
|
|
444
|
+
providers.push({
|
|
445
|
+
name: "OpenAI",
|
|
446
|
+
key: "openai",
|
|
447
|
+
kind: "openai-compatible",
|
|
448
|
+
baseURL: "https://api.openai.com/v1",
|
|
449
|
+
apiKey: process.env.OPENAI_API_KEY,
|
|
450
|
+
chatModel: chatModel("openai", process.env.OPENAI_CHAT_MODEL, DEFAULTS2.openaiChat),
|
|
451
|
+
supportsEmbeddings: true,
|
|
452
|
+
embeddingModel: embeddingModel("openai", process.env.OPENAI_EMBEDDING_MODEL, DEFAULTS2.openaiEmbed),
|
|
453
|
+
embeddingDimensions: 1536
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
if (preferredProvider && preferredProvider !== "groq") {
|
|
457
|
+
} else if (!localOnly && process.env.GROQ_API_KEY) {
|
|
458
|
+
providers.push({
|
|
459
|
+
name: "Groq",
|
|
460
|
+
key: "groq",
|
|
461
|
+
kind: "openai-compatible",
|
|
462
|
+
baseURL: "https://api.groq.com/openai/v1",
|
|
463
|
+
apiKey: process.env.GROQ_API_KEY,
|
|
464
|
+
chatModel: chatModel("groq", process.env.GROQ_CHAT_MODEL, DEFAULTS2.groqChat),
|
|
465
|
+
supportsEmbeddings: false
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
if (preferredProvider && preferredProvider !== "gemini") {
|
|
469
|
+
} else if (!localOnly && process.env.GEMINI_API_KEY) {
|
|
470
|
+
providers.push({
|
|
471
|
+
name: "Gemini",
|
|
472
|
+
key: "gemini",
|
|
473
|
+
kind: "openai-compatible",
|
|
474
|
+
baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
475
|
+
apiKey: process.env.GEMINI_API_KEY,
|
|
476
|
+
chatModel: chatModel("gemini", process.env.GEMINI_CHAT_MODEL, DEFAULTS2.geminiChat),
|
|
477
|
+
supportsEmbeddings: true,
|
|
478
|
+
embeddingModel: embeddingModel("gemini", process.env.GEMINI_EMBEDDING_MODEL, DEFAULTS2.geminiEmbed),
|
|
479
|
+
embeddingDimensions: 768
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
if (preferredProvider && preferredProvider !== "anthropic") {
|
|
483
|
+
} else if (!localOnly && process.env.ANTHROPIC_API_KEY) {
|
|
484
|
+
providers.push({
|
|
485
|
+
name: "Anthropic",
|
|
486
|
+
key: "anthropic",
|
|
487
|
+
kind: "anthropic",
|
|
488
|
+
baseURL: "https://api.anthropic.com/v1",
|
|
489
|
+
apiKey: process.env.ANTHROPIC_API_KEY,
|
|
490
|
+
chatModel: chatModel("anthropic", process.env.ANTHROPIC_CHAT_MODEL, DEFAULTS2.anthropicChat),
|
|
491
|
+
supportsEmbeddings: false
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
if (preferredProvider && preferredProvider !== "ollama") {
|
|
495
|
+
} else {
|
|
496
|
+
providers.push({
|
|
497
|
+
name: "Ollama",
|
|
498
|
+
key: "ollama",
|
|
499
|
+
kind: "openai-compatible",
|
|
500
|
+
baseURL: process.env.OLLAMA_BASE_URL ?? DEFAULTS2.ollamaBaseURL,
|
|
501
|
+
chatModel: chatModel("ollama", process.env.OLLAMA_CHAT_MODEL, DEFAULTS2.ollamaChat),
|
|
502
|
+
supportsEmbeddings: true,
|
|
503
|
+
embeddingModel: embeddingModel("ollama", process.env.OLLAMA_EMBEDDING_MODEL, DEFAULTS2.ollamaEmbed),
|
|
504
|
+
embeddingDimensions: 768
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
return providers;
|
|
508
|
+
}
|
|
509
|
+
function getEmbeddingProviders() {
|
|
510
|
+
return getChatProviders().filter((p) => p.supportsEmbeddings);
|
|
511
|
+
}
|
|
512
|
+
function getActiveEmbeddingProviderKey() {
|
|
513
|
+
const providers = getEmbeddingProviders();
|
|
514
|
+
return providers[0]?.key ?? "openai";
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// src/infrastructure/ai/ai.service.ts
|
|
518
|
+
function createClient(config) {
|
|
519
|
+
return new OpenAI({
|
|
520
|
+
baseURL: config.baseURL,
|
|
521
|
+
apiKey: config.apiKey ?? "no-key-required"
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
async function withTimeout(fn, ms) {
|
|
525
|
+
const controller = new AbortController();
|
|
526
|
+
const timer2 = setTimeout(() => controller.abort(), ms);
|
|
527
|
+
try {
|
|
528
|
+
return await fn(controller.signal);
|
|
529
|
+
} finally {
|
|
530
|
+
clearTimeout(timer2);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
function buildProviderListError(operation) {
|
|
534
|
+
return `No AI provider available for ${operation}. Set OPENAI_API_KEY, GROQ_API_KEY, GEMINI_API_KEY, ANTHROPIC_API_KEY, or start Ollama on localhost:11434.`;
|
|
535
|
+
}
|
|
536
|
+
function parseTimeoutMs(value, fallback) {
|
|
537
|
+
if (value === void 0) return fallback;
|
|
538
|
+
const parsed = Number.parseInt(value, 10);
|
|
539
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
540
|
+
return parsed;
|
|
541
|
+
}
|
|
542
|
+
function defaultChatTimeoutMs(provider) {
|
|
543
|
+
return provider.name === "Ollama" ? 18e4 : 3e4;
|
|
544
|
+
}
|
|
545
|
+
function defaultEmbeddingTimeoutMs(provider) {
|
|
546
|
+
return provider.name === "Ollama" ? 6e4 : 15e3;
|
|
547
|
+
}
|
|
548
|
+
async function chatWithOpenAiCompatible(config, params, signal) {
|
|
549
|
+
const client = createClient(config);
|
|
550
|
+
const response = await client.chat.completions.create(
|
|
551
|
+
{
|
|
552
|
+
model: config.chatModel,
|
|
553
|
+
messages: [
|
|
554
|
+
{ role: "system", content: params.systemPrompt },
|
|
555
|
+
{ role: "user", content: params.userPrompt }
|
|
556
|
+
],
|
|
557
|
+
temperature: params.temperature ?? 0.7,
|
|
558
|
+
...params.responseFormat === "json" ? { response_format: { type: "json_object" } } : {}
|
|
559
|
+
},
|
|
560
|
+
{ signal }
|
|
561
|
+
);
|
|
562
|
+
return response.choices[0]?.message?.content ?? null;
|
|
563
|
+
}
|
|
564
|
+
async function chatWithAnthropic(config, params, signal) {
|
|
565
|
+
const anthropic = new Anthropic({
|
|
566
|
+
apiKey: config.apiKey
|
|
567
|
+
});
|
|
568
|
+
const response = await anthropic.messages.create(
|
|
569
|
+
{
|
|
570
|
+
model: config.chatModel,
|
|
571
|
+
system: params.systemPrompt,
|
|
572
|
+
messages: [{ role: "user", content: params.userPrompt }],
|
|
573
|
+
max_tokens: 4096,
|
|
574
|
+
temperature: params.temperature ?? 0.7
|
|
575
|
+
},
|
|
576
|
+
{ signal }
|
|
577
|
+
);
|
|
578
|
+
const contentBlock = response.content.find((block) => block.type === "text");
|
|
579
|
+
const content = contentBlock?.text ?? null;
|
|
580
|
+
return content || null;
|
|
581
|
+
}
|
|
582
|
+
var OpenAiAiService = class {
|
|
583
|
+
async chat(params) {
|
|
584
|
+
const providers = getChatProviders();
|
|
585
|
+
if (providers.length === 0) {
|
|
586
|
+
return { ok: false, error: buildProviderListError("chat") };
|
|
587
|
+
}
|
|
588
|
+
const MAX_RETRIES = 3;
|
|
589
|
+
for (const provider of providers) {
|
|
590
|
+
const timeoutMs = parseTimeoutMs(process.env.THOTH_CHAT_TIMEOUT_MS, defaultChatTimeoutMs(provider));
|
|
591
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
592
|
+
try {
|
|
593
|
+
logger.debug({ provider: provider.name, attempt, timeoutMs }, "Attempting chat completion");
|
|
594
|
+
const content = await withTimeout(async (signal) => {
|
|
595
|
+
if (provider.kind === "anthropic") {
|
|
596
|
+
return chatWithAnthropic(provider, params, signal);
|
|
597
|
+
}
|
|
598
|
+
return chatWithOpenAiCompatible(provider, params, signal);
|
|
599
|
+
}, timeoutMs);
|
|
600
|
+
if (content) {
|
|
601
|
+
logger.info({ provider: provider.name }, "Chat completed");
|
|
602
|
+
return { ok: true, value: content };
|
|
603
|
+
}
|
|
604
|
+
} catch (error2) {
|
|
605
|
+
const isLastAttempt = attempt === MAX_RETRIES;
|
|
606
|
+
logger.warn(
|
|
607
|
+
{
|
|
608
|
+
provider: provider.name,
|
|
609
|
+
attempt,
|
|
610
|
+
error: error2 instanceof Error ? error2.message : error2
|
|
611
|
+
},
|
|
612
|
+
isLastAttempt ? "Provider failed, trying next" : "Retrying..."
|
|
613
|
+
);
|
|
614
|
+
if (!isLastAttempt) {
|
|
615
|
+
const delay = Math.min(1e3 * Math.pow(2, attempt - 1), 8e3);
|
|
616
|
+
await new Promise((resolve3) => setTimeout(resolve3, delay));
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return { ok: false, error: "All AI providers failed to complete the chat request." };
|
|
622
|
+
}
|
|
623
|
+
getActiveEmbeddingModel() {
|
|
624
|
+
const providers = getEmbeddingProviders();
|
|
625
|
+
return providers[0]?.embeddingModel ?? "text-embedding-3-small";
|
|
626
|
+
}
|
|
627
|
+
async generateEmbedding(text) {
|
|
628
|
+
const providers = getEmbeddingProviders();
|
|
629
|
+
if (providers.length === 0) {
|
|
630
|
+
return { ok: false, error: buildProviderListError("embeddings") };
|
|
631
|
+
}
|
|
632
|
+
const MAX_RETRIES = 3;
|
|
633
|
+
for (const provider of providers) {
|
|
634
|
+
if (!provider.embeddingModel) continue;
|
|
635
|
+
const model = provider.embeddingModel;
|
|
636
|
+
const timeoutMs = parseTimeoutMs(process.env.THOTH_EMBEDDING_TIMEOUT_MS, defaultEmbeddingTimeoutMs(provider));
|
|
637
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
638
|
+
try {
|
|
639
|
+
logger.debug({ provider: provider.name, attempt, textLength: text.length, timeoutMs }, "Attempting embedding");
|
|
640
|
+
const client = createClient(provider);
|
|
641
|
+
const response = await withTimeout(
|
|
642
|
+
(signal) => client.embeddings.create(
|
|
643
|
+
{
|
|
644
|
+
model,
|
|
645
|
+
input: text
|
|
646
|
+
},
|
|
647
|
+
{ signal }
|
|
648
|
+
),
|
|
649
|
+
timeoutMs
|
|
650
|
+
);
|
|
651
|
+
const embedding = response.data[0]?.embedding;
|
|
652
|
+
if (embedding) {
|
|
653
|
+
logger.info({ provider: provider.name }, "Embedding generated");
|
|
654
|
+
return { ok: true, value: embedding };
|
|
655
|
+
}
|
|
656
|
+
} catch (error2) {
|
|
657
|
+
const isLastAttempt = attempt === MAX_RETRIES;
|
|
658
|
+
logger.warn(
|
|
659
|
+
{
|
|
660
|
+
provider: provider.name,
|
|
661
|
+
attempt,
|
|
662
|
+
error: error2 instanceof Error ? error2.message : error2
|
|
663
|
+
},
|
|
664
|
+
isLastAttempt ? "Provider failed, trying next" : "Retrying..."
|
|
665
|
+
);
|
|
666
|
+
if (!isLastAttempt) {
|
|
667
|
+
const delay = Math.min(1e3 * Math.pow(2, attempt - 1), 8e3);
|
|
668
|
+
await new Promise((resolve3) => setTimeout(resolve3, delay));
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return { ok: false, error: "All embedding providers failed." };
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
// src/infrastructure/adapters/file-source.adapter.ts
|
|
678
|
+
import { readFileSync as readFileSync2, readdirSync, statSync, realpathSync } from "fs";
|
|
679
|
+
import { join as join3, extname } from "path";
|
|
680
|
+
import { randomUUID, createHash } from "crypto";
|
|
681
|
+
|
|
682
|
+
// src/application/services/embedding.service.ts
|
|
683
|
+
import { get_encoding } from "tiktoken";
|
|
684
|
+
var enc = get_encoding("cl100k_base");
|
|
685
|
+
function chunkText(text, maxTokens = 512, overlap = 50) {
|
|
686
|
+
if (overlap >= maxTokens) overlap = maxTokens - 1;
|
|
687
|
+
const tokens = enc.encode(text);
|
|
688
|
+
const chunks = [];
|
|
689
|
+
if (tokens.length <= maxTokens) {
|
|
690
|
+
chunks.push({ content: text, index: 0, tokenCount: tokens.length });
|
|
691
|
+
return chunks;
|
|
692
|
+
}
|
|
693
|
+
let start = 0;
|
|
694
|
+
let chunkIndex = 0;
|
|
695
|
+
while (start < tokens.length) {
|
|
696
|
+
const end = Math.min(start + maxTokens, tokens.length);
|
|
697
|
+
const chunkTokens = tokens.slice(start, end);
|
|
698
|
+
const content = new TextDecoder().decode(enc.decode(chunkTokens));
|
|
699
|
+
chunks.push({
|
|
700
|
+
content,
|
|
701
|
+
index: chunkIndex,
|
|
702
|
+
tokenCount: chunkTokens.length
|
|
703
|
+
});
|
|
704
|
+
chunkIndex++;
|
|
705
|
+
start += maxTokens - overlap;
|
|
706
|
+
if (start >= tokens.length) break;
|
|
707
|
+
}
|
|
708
|
+
return chunks;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// src/infrastructure/adapters/file-source.adapter.ts
|
|
712
|
+
var MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
713
|
+
var FileSourceAdapter = class {
|
|
714
|
+
importFromPath(sourcePath, type) {
|
|
715
|
+
const resolvedPath = realpathSync(sourcePath);
|
|
716
|
+
const stat = statSync(resolvedPath);
|
|
717
|
+
if (stat.isDirectory()) {
|
|
718
|
+
return Promise.resolve(this.importFromDirectory(resolvedPath, type));
|
|
719
|
+
}
|
|
720
|
+
return Promise.resolve(this.importFile(resolvedPath, type));
|
|
721
|
+
}
|
|
722
|
+
importFromDirectory(dirPath, type, visited) {
|
|
723
|
+
const resolvedDir = realpathSync(dirPath);
|
|
724
|
+
if (!visited) visited = /* @__PURE__ */ new Set();
|
|
725
|
+
if (visited.has(resolvedDir)) return [];
|
|
726
|
+
visited.add(resolvedDir);
|
|
727
|
+
const entries = readdirSync(resolvedDir);
|
|
728
|
+
const results = [];
|
|
729
|
+
for (const entry of entries) {
|
|
730
|
+
const entryPath = join3(resolvedDir, entry);
|
|
731
|
+
let resolvedPath;
|
|
732
|
+
try {
|
|
733
|
+
resolvedPath = realpathSync(entryPath);
|
|
734
|
+
} catch {
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
if (!resolvedPath.startsWith(resolvedDir + "/")) continue;
|
|
738
|
+
const stat = statSync(resolvedPath);
|
|
739
|
+
if (stat.isDirectory()) {
|
|
740
|
+
const nested = this.importFromDirectory(resolvedPath, type, visited);
|
|
741
|
+
results.push(...nested);
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
const ext = extname(resolvedPath).toLowerCase();
|
|
745
|
+
if (ext !== ".md" && ext !== ".txt") continue;
|
|
746
|
+
const sources = this.importFile(resolvedPath, type);
|
|
747
|
+
results.push(...sources);
|
|
748
|
+
}
|
|
749
|
+
return results;
|
|
750
|
+
}
|
|
751
|
+
importFile(filePath, type) {
|
|
752
|
+
const stat = statSync(filePath);
|
|
753
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
754
|
+
logger.warn({ file: sanitizePath(filePath), size: stat.size }, "File exceeds maximum size, skipping");
|
|
755
|
+
return [];
|
|
756
|
+
}
|
|
757
|
+
const content = readFileSync2(filePath, "utf-8");
|
|
758
|
+
const checksum = createHash("sha256").update(content).digest("hex");
|
|
759
|
+
const chunks = chunkText(content);
|
|
760
|
+
logger.info(
|
|
761
|
+
{
|
|
762
|
+
file: sanitizePath(filePath),
|
|
763
|
+
chunks: chunks.length,
|
|
764
|
+
totalTokens: chunks.reduce((s, c) => s + c.tokenCount, 0)
|
|
765
|
+
},
|
|
766
|
+
"Importing file"
|
|
767
|
+
);
|
|
768
|
+
return chunks.map((chunk) => ({
|
|
769
|
+
id: randomUUID(),
|
|
770
|
+
type,
|
|
771
|
+
sourcePath: filePath,
|
|
772
|
+
content: chunk.content,
|
|
773
|
+
checksum: `${checksum}:${chunk.index}`,
|
|
774
|
+
chunkIndex: chunk.index,
|
|
775
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
776
|
+
}));
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
// src/application/use-cases/research.usecase.ts
|
|
781
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
782
|
+
|
|
783
|
+
// src/application/use-cases/parse-ai-json.ts
|
|
784
|
+
function isJsonRecord(value) {
|
|
785
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
786
|
+
}
|
|
787
|
+
function extractJsonObject(value) {
|
|
788
|
+
const fencedMatch = /```(?:json)?\s*([\s\S]*?)```/i.exec(value);
|
|
789
|
+
const candidate = fencedMatch?.[1]?.trim() ?? value.trim();
|
|
790
|
+
const start = candidate.indexOf("{");
|
|
791
|
+
if (start === -1) return null;
|
|
792
|
+
let depth = 0;
|
|
793
|
+
let inString = false;
|
|
794
|
+
let escaped = false;
|
|
795
|
+
for (let index = start; index < candidate.length; index += 1) {
|
|
796
|
+
const char = candidate[index];
|
|
797
|
+
if (escaped) {
|
|
798
|
+
escaped = false;
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
if (char === "\\") {
|
|
802
|
+
escaped = true;
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
if (char === '"') {
|
|
806
|
+
inString = !inString;
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
if (inString) continue;
|
|
810
|
+
if (char === "{") depth += 1;
|
|
811
|
+
if (char === "}") depth -= 1;
|
|
812
|
+
if (depth === 0) {
|
|
813
|
+
return candidate.slice(start, index + 1);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
function parseJsonRecord(value) {
|
|
819
|
+
const json = extractJsonObject(value);
|
|
820
|
+
if (json === null) return { ok: false, error: "No JSON object found in AI response" };
|
|
821
|
+
try {
|
|
822
|
+
const parsed = JSON.parse(json);
|
|
823
|
+
if (!isJsonRecord(parsed)) {
|
|
824
|
+
return { ok: false, error: "AI response JSON was not an object" };
|
|
825
|
+
}
|
|
826
|
+
return { ok: true, value: parsed };
|
|
827
|
+
} catch (error2) {
|
|
828
|
+
return {
|
|
829
|
+
ok: false,
|
|
830
|
+
error: error2 instanceof Error ? error2.message : "AI response JSON could not be parsed"
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// src/application/use-cases/research.usecase.ts
|
|
836
|
+
var RESEARCH_PROMPT = `You are a research assistant synthesizing information from a person's own writing.
|
|
837
|
+
|
|
838
|
+
Given the topic and the provided source excerpts from their knowledge base, produce a well-structured research note that:
|
|
839
|
+
|
|
840
|
+
1. Summarizes the key findings relevant to the topic
|
|
841
|
+
2. Identifies patterns, contradictions, or gaps in the source material
|
|
842
|
+
3. Cites specific excerpts that support each claim
|
|
843
|
+
4. Suggests angles or directions for further exploration
|
|
844
|
+
|
|
845
|
+
Return ONLY valid JSON with this exact structure:
|
|
846
|
+
{
|
|
847
|
+
"content": "The full research note text with inline citations referencing source IDs",
|
|
848
|
+
"citations": [
|
|
849
|
+
{
|
|
850
|
+
"sourceId": "uuid-of-the-source",
|
|
851
|
+
"sourcePath": "path-to-source",
|
|
852
|
+
"excerpt": "the relevant excerpt",
|
|
853
|
+
"relevanceScore": 0.0 to 1.0
|
|
854
|
+
}
|
|
855
|
+
]
|
|
856
|
+
}`;
|
|
857
|
+
var ResearchUseCase = class {
|
|
858
|
+
constructor(ai2, researchRepo, sourceRepo, logger2) {
|
|
859
|
+
this.ai = ai2;
|
|
860
|
+
this.researchRepo = researchRepo;
|
|
861
|
+
this.sourceRepo = sourceRepo;
|
|
862
|
+
this.logger = logger2;
|
|
863
|
+
}
|
|
864
|
+
ai;
|
|
865
|
+
researchRepo;
|
|
866
|
+
sourceRepo;
|
|
867
|
+
logger;
|
|
868
|
+
async execute(topic) {
|
|
869
|
+
this.logger.info({ topic }, "Starting research");
|
|
870
|
+
const embedResult = await this.ai.generateEmbedding(topic);
|
|
871
|
+
if (!embedResult.ok) return { ok: false, error: `Embedding failed: ${embedResult.error}` };
|
|
872
|
+
const nearChunks = await this.sourceRepo.searchByVector(embedResult.value, 15);
|
|
873
|
+
if (!nearChunks.ok) return { ok: false, error: nearChunks.error };
|
|
874
|
+
if (nearChunks.value.length === 0) {
|
|
875
|
+
return {
|
|
876
|
+
ok: false,
|
|
877
|
+
error: "No relevant sources found. Import knowledge sources with `thoth import knowledge <path>` first."
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
const excerpts = nearChunks.value.map((chunk) => `[${chunk.id}] ${chunk.content.slice(0, 500)}`).join("\n\n---\n\n");
|
|
881
|
+
const chatResult = await this.ai.chat({
|
|
882
|
+
systemPrompt: RESEARCH_PROMPT,
|
|
883
|
+
userPrompt: `Topic: ${topic}
|
|
884
|
+
|
|
885
|
+
Relevant source excerpts:
|
|
886
|
+
|
|
887
|
+
${excerpts}`,
|
|
888
|
+
temperature: 0.4
|
|
889
|
+
});
|
|
890
|
+
if (!chatResult.ok) return { ok: false, error: chatResult.error };
|
|
891
|
+
const parsed = parseJsonRecord(chatResult.value);
|
|
892
|
+
if (!parsed.ok) return { ok: false, error: `Invalid JSON response from research AI: ${parsed.error}` };
|
|
893
|
+
const content = parsed.value.content;
|
|
894
|
+
const rawCitations = parsed.value.citations;
|
|
895
|
+
if (typeof content !== "string" || !Array.isArray(rawCitations)) {
|
|
896
|
+
return { ok: false, error: "Research response missing content or citations" };
|
|
897
|
+
}
|
|
898
|
+
const citations = rawCitations.map((c) => ({
|
|
899
|
+
sourceId: String(c.sourceId),
|
|
900
|
+
sourcePath: String(c.sourcePath),
|
|
901
|
+
excerpt: String(c.excerpt),
|
|
902
|
+
relevanceScore: Number(c.relevanceScore)
|
|
903
|
+
}));
|
|
904
|
+
const note = {
|
|
905
|
+
id: randomUUID2(),
|
|
906
|
+
topic,
|
|
907
|
+
content,
|
|
908
|
+
citations,
|
|
909
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
910
|
+
};
|
|
911
|
+
const saveResult = await this.researchRepo.save(note);
|
|
912
|
+
if (!saveResult.ok) return saveResult;
|
|
913
|
+
const noteEmbedResult = await this.ai.generateEmbedding(note.content);
|
|
914
|
+
if (noteEmbedResult.ok) {
|
|
915
|
+
const embResult = await this.researchRepo.saveResearchEmbedding(
|
|
916
|
+
note.id,
|
|
917
|
+
noteEmbedResult.value,
|
|
918
|
+
this.ai.getActiveEmbeddingModel()
|
|
919
|
+
);
|
|
920
|
+
if (!embResult.ok) {
|
|
921
|
+
this.logger.warn({ error: embResult.error }, "Failed to save research embedding");
|
|
922
|
+
}
|
|
923
|
+
} else {
|
|
924
|
+
this.logger.warn({ error: noteEmbedResult.error }, "Research note embedding failed");
|
|
925
|
+
}
|
|
926
|
+
this.logger.info({ topic, citationCount: citations.length, id: note.id }, "Research complete");
|
|
927
|
+
return { ok: true, value: note };
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
// src/application/use-cases/generate-article.usecase.ts
|
|
932
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
933
|
+
var ARTICLE_PROMPT = `You are writing an article for a specific author. Your goal is to produce text that sounds exactly like them.
|
|
934
|
+
|
|
935
|
+
Below is the author's voice profile \u2014 their stylistic traits extracted from their writing. You MUST follow these traits precisely.
|
|
936
|
+
|
|
937
|
+
Voice Profile:
|
|
938
|
+
- Tone: {{tone}}
|
|
939
|
+
- Pacing: {{pacing}}
|
|
940
|
+
- Storytelling: {{storytelling}}
|
|
941
|
+
- Vocabulary: {{vocabulary}}
|
|
942
|
+
- Sentence Structure: {{sentenceStructure}}
|
|
943
|
+
- Transitions: {{transitions}}
|
|
944
|
+
- Humor: {{humor}}
|
|
945
|
+
- Reader Engagement: {{readerEngagement}}
|
|
946
|
+
- Summary: {{summary}}
|
|
947
|
+
|
|
948
|
+
Write a complete article on the given topic using the provided research material. The article should:
|
|
949
|
+
|
|
950
|
+
1. Match the author's voice exactly \u2014 follow every trait above
|
|
951
|
+
2. Be informative and well-structured with a clear introduction, body, and conclusion
|
|
952
|
+
3. Incorporate insights from the research material naturally
|
|
953
|
+
4. Be between 1600-2500 words \u2014 aim for an 8-10 minute read. Go deeper if the topic demands it.
|
|
954
|
+
|
|
955
|
+
Return ONLY valid JSON with this exact structure:
|
|
956
|
+
{
|
|
957
|
+
"title": "Article title that matches the author's style",
|
|
958
|
+
"content": "Full article content in markdown format"
|
|
959
|
+
}`;
|
|
960
|
+
var GenerateArticleUseCase = class {
|
|
961
|
+
constructor(ai2, articleRepo, profileRepo, researchRepo, logger2) {
|
|
962
|
+
this.ai = ai2;
|
|
963
|
+
this.articleRepo = articleRepo;
|
|
964
|
+
this.profileRepo = profileRepo;
|
|
965
|
+
this.researchRepo = researchRepo;
|
|
966
|
+
this.logger = logger2;
|
|
967
|
+
}
|
|
968
|
+
ai;
|
|
969
|
+
articleRepo;
|
|
970
|
+
profileRepo;
|
|
971
|
+
researchRepo;
|
|
972
|
+
logger;
|
|
973
|
+
async execute(params) {
|
|
974
|
+
const voiceResult = await this.profileRepo.getLatestVoiceProfile();
|
|
975
|
+
if (!voiceResult.ok) return voiceResult;
|
|
976
|
+
const voiceProfile = voiceResult.value;
|
|
977
|
+
if (!voiceProfile) {
|
|
978
|
+
return { ok: false, error: "No voice profile found. Run `thoth profile generate` first." };
|
|
979
|
+
}
|
|
980
|
+
let researchContent = "";
|
|
981
|
+
let researchId = null;
|
|
982
|
+
if (params.researchId) {
|
|
983
|
+
const researchResult = await this.researchRepo.get(params.researchId);
|
|
984
|
+
if (!researchResult.ok) return researchResult;
|
|
985
|
+
if (researchResult.value) {
|
|
986
|
+
researchContent = researchResult.value.content;
|
|
987
|
+
researchId = researchResult.value.id;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
const topicSection = params.topic ? `
|
|
991
|
+
|
|
992
|
+
Topic: ${params.topic}` : "";
|
|
993
|
+
const researchSection = researchContent ? `
|
|
994
|
+
|
|
995
|
+
Research Material:
|
|
996
|
+
${researchContent}` : "";
|
|
997
|
+
const prompt = ARTICLE_PROMPT.replace("{{tone}}", voiceProfile.traits.tone.join(", ")).replace("{{pacing}}", voiceProfile.traits.pacing.join(", ")).replace("{{storytelling}}", voiceProfile.traits.storytelling.join(", ")).replace("{{vocabulary}}", voiceProfile.traits.vocabulary.join(", ")).replace("{{sentenceStructure}}", voiceProfile.traits.sentenceStructure.join(", ")).replace("{{transitions}}", voiceProfile.traits.transitions.join(", ")).replace("{{humor}}", voiceProfile.traits.humor.join(", ")).replace("{{readerEngagement}}", voiceProfile.traits.readerEngagement.join(", ")).replace("{{summary}}", voiceProfile.summary ?? "");
|
|
998
|
+
const userMessage = `Write an article${topicSection}${researchSection}`;
|
|
999
|
+
const chatResult = await this.ai.chat({
|
|
1000
|
+
systemPrompt: `${prompt}
|
|
1001
|
+
|
|
1002
|
+
Do not include markdown fences, commentary, or any text outside the JSON object.`,
|
|
1003
|
+
userPrompt: userMessage,
|
|
1004
|
+
responseFormat: "json",
|
|
1005
|
+
temperature: 0.7
|
|
1006
|
+
});
|
|
1007
|
+
if (!chatResult.ok) return { ok: false, error: chatResult.error };
|
|
1008
|
+
const parsed = parseJsonRecord(chatResult.value);
|
|
1009
|
+
if (!parsed.ok) return { ok: false, error: `Invalid JSON response from article generation AI: ${parsed.error}` };
|
|
1010
|
+
if (typeof parsed.value.title !== "string" || typeof parsed.value.content !== "string") {
|
|
1011
|
+
return { ok: false, error: "Article response missing title or content" };
|
|
1012
|
+
}
|
|
1013
|
+
const title = parsed.value.title;
|
|
1014
|
+
const content = parsed.value.content;
|
|
1015
|
+
const wordCount = content.split(/\s+/).length;
|
|
1016
|
+
const article2 = {
|
|
1017
|
+
id: randomUUID3(),
|
|
1018
|
+
title,
|
|
1019
|
+
content,
|
|
1020
|
+
voiceProfileId: voiceProfile.id,
|
|
1021
|
+
researchId,
|
|
1022
|
+
wordCount,
|
|
1023
|
+
status: "draft",
|
|
1024
|
+
mediumUrl: null,
|
|
1025
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1026
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1027
|
+
};
|
|
1028
|
+
const saveResult = await this.articleRepo.save(article2);
|
|
1029
|
+
if (!saveResult.ok) return saveResult;
|
|
1030
|
+
this.logger.info({ articleId: article2.id, title: article2.title, wordCount }, "Article generated");
|
|
1031
|
+
return { ok: true, value: article2 };
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
// src/application/use-cases/generate-profiles.usecase.ts
|
|
1036
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
1037
|
+
|
|
1038
|
+
// src/application/use-cases/profile-schemas.ts
|
|
1039
|
+
import { z as z2 } from "zod";
|
|
1040
|
+
var voiceTraitsSchema = z2.object({
|
|
1041
|
+
tone: z2.array(z2.string()),
|
|
1042
|
+
pacing: z2.array(z2.string()),
|
|
1043
|
+
storytelling: z2.array(z2.string()),
|
|
1044
|
+
vocabulary: z2.array(z2.string()),
|
|
1045
|
+
sentenceStructure: z2.array(z2.string()),
|
|
1046
|
+
transitions: z2.array(z2.string()),
|
|
1047
|
+
humor: z2.array(z2.string()),
|
|
1048
|
+
readerEngagement: z2.array(z2.string())
|
|
1049
|
+
});
|
|
1050
|
+
var voiceProfileResponseSchema = z2.object({
|
|
1051
|
+
traits: voiceTraitsSchema,
|
|
1052
|
+
summary: z2.string().min(1)
|
|
1053
|
+
});
|
|
1054
|
+
var knowledgeProfileResponseSchema = z2.object({
|
|
1055
|
+
domains: z2.array(z2.string()),
|
|
1056
|
+
topics: z2.array(z2.string()),
|
|
1057
|
+
summary: z2.string().min(1)
|
|
1058
|
+
});
|
|
1059
|
+
var publicationProfileResponseSchema = z2.object({
|
|
1060
|
+
themes: z2.array(z2.string()),
|
|
1061
|
+
summary: z2.string().min(1)
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
// src/application/use-cases/generate-profiles.usecase.ts
|
|
1065
|
+
var VOICE_PROMPT = `You are analyzing a person's writing to build a Voice Profile.
|
|
1066
|
+
|
|
1067
|
+
Extract the following traits from the provided text samples. Be specific and evidence-based.
|
|
1068
|
+
|
|
1069
|
+
Return ONLY valid JSON with this exact structure:
|
|
1070
|
+
{
|
|
1071
|
+
"traits": {
|
|
1072
|
+
"tone": ["descriptive adjectives"],
|
|
1073
|
+
"pacing": ["sentence-level observations"],
|
|
1074
|
+
"storytelling": ["narrative patterns"],
|
|
1075
|
+
"vocabulary": ["vocabulary characteristics"],
|
|
1076
|
+
"sentenceStructure": ["structural patterns"],
|
|
1077
|
+
"transitions": ["transition patterns"],
|
|
1078
|
+
"humor": ["humor characteristics or empty array"],
|
|
1079
|
+
"readerEngagement": ["engagement techniques"]
|
|
1080
|
+
},
|
|
1081
|
+
"summary": "A 2-3 sentence summary of their voice"
|
|
1082
|
+
}`;
|
|
1083
|
+
var KNOWLEDGE_PROMPT = `You are analyzing technical writing to build a Knowledge Profile.
|
|
1084
|
+
|
|
1085
|
+
Extract the domains, topics, and depth of knowledge from the provided text samples.
|
|
1086
|
+
|
|
1087
|
+
Return ONLY valid JSON with this exact structure:
|
|
1088
|
+
{
|
|
1089
|
+
"domains": ["broad technical domains"],
|
|
1090
|
+
"topics": ["specific topics covered"],
|
|
1091
|
+
"summary": "A 2-3 sentence summary of their knowledge areas and depth"
|
|
1092
|
+
}`;
|
|
1093
|
+
var PUBLICATION_PROMPT = `You are analyzing a person's published work to build a Publication Profile.
|
|
1094
|
+
|
|
1095
|
+
Extract recurring themes, series, and writing evolution from the provided text samples.
|
|
1096
|
+
|
|
1097
|
+
Return ONLY valid JSON with this exact structure:
|
|
1098
|
+
{
|
|
1099
|
+
"themes": ["recurring themes"],
|
|
1100
|
+
"summary": "A 2-3 sentence summary of their publication patterns"
|
|
1101
|
+
}`;
|
|
1102
|
+
var GenerateProfilesUseCase = class {
|
|
1103
|
+
constructor(ai2, profileRepo, sourceRepo, logger2) {
|
|
1104
|
+
this.ai = ai2;
|
|
1105
|
+
this.profileRepo = profileRepo;
|
|
1106
|
+
this.sourceRepo = sourceRepo;
|
|
1107
|
+
this.logger = logger2;
|
|
1108
|
+
}
|
|
1109
|
+
ai;
|
|
1110
|
+
profileRepo;
|
|
1111
|
+
sourceRepo;
|
|
1112
|
+
logger;
|
|
1113
|
+
generateVoiceProfile() {
|
|
1114
|
+
return this.generateProfile("voice", VOICE_PROMPT, "voice profile");
|
|
1115
|
+
}
|
|
1116
|
+
generateKnowledgeProfile() {
|
|
1117
|
+
return this.generateProfile("knowledge", KNOWLEDGE_PROMPT, "knowledge profile");
|
|
1118
|
+
}
|
|
1119
|
+
generatePublicationProfile() {
|
|
1120
|
+
return this.generateProfile("publication", PUBLICATION_PROMPT, "publication profile");
|
|
1121
|
+
}
|
|
1122
|
+
async generateProfile(type, systemPrompt, label) {
|
|
1123
|
+
const typeLabel = type === "publication" ? "publication" : type;
|
|
1124
|
+
const sourcesResult = await this.sourceRepo.getSourcesByType(type);
|
|
1125
|
+
if (!sourcesResult.ok) return { ok: false, error: sourcesResult.error };
|
|
1126
|
+
const sources = sourcesResult.value;
|
|
1127
|
+
if (sources.length === 0) {
|
|
1128
|
+
return {
|
|
1129
|
+
ok: false,
|
|
1130
|
+
error: `No ${typeLabel} sources imported. Run \`thoth import ${typeLabel} <path>\` first.`
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
const combined = sources.map((s) => s.content).join("\n\n---\n\n");
|
|
1134
|
+
const truncated = combined.length > 32e3 ? combined.slice(0, 32e3) : combined;
|
|
1135
|
+
this.logger.info(
|
|
1136
|
+
{ sampleCount: sources.length, totalChars: combined.length },
|
|
1137
|
+
`Generating ${typeLabel} profile`
|
|
1138
|
+
);
|
|
1139
|
+
const chatResult = await this.ai.chat({
|
|
1140
|
+
systemPrompt: `${systemPrompt}
|
|
1141
|
+
|
|
1142
|
+
Do not include markdown fences, commentary, or any text outside the JSON object.`,
|
|
1143
|
+
userPrompt: `Here are the ${typeLabel} samples:
|
|
1144
|
+
|
|
1145
|
+
${truncated}`,
|
|
1146
|
+
responseFormat: "json",
|
|
1147
|
+
temperature: 0.3
|
|
1148
|
+
});
|
|
1149
|
+
if (!chatResult.ok) return { ok: false, error: chatResult.error };
|
|
1150
|
+
const parsed = parseJsonRecord(chatResult.value);
|
|
1151
|
+
if (!parsed.ok) return { ok: false, error: `Invalid JSON response for ${label}: ${parsed.error}` };
|
|
1152
|
+
const schema = this.getSchema(type);
|
|
1153
|
+
const validated = schema.safeParse(parsed.value);
|
|
1154
|
+
if (!validated.success) {
|
|
1155
|
+
this.logger.error({ error: validated.error.message }, `${label} AI response validation failed`);
|
|
1156
|
+
return { ok: false, error: `Invalid AI response for ${label}: ${validated.error.message}` };
|
|
1157
|
+
}
|
|
1158
|
+
const profile = this.buildProfile(type, validated.data);
|
|
1159
|
+
const saveResult = await this.saveProfile(type, profile);
|
|
1160
|
+
if (!saveResult.ok) {
|
|
1161
|
+
return { ok: false, error: saveResult.error };
|
|
1162
|
+
}
|
|
1163
|
+
const embedResult = await this.ai.generateEmbedding(JSON.stringify(validated.data));
|
|
1164
|
+
if (embedResult.ok) {
|
|
1165
|
+
const embResult = await this.profileRepo.saveProfileEmbedding(
|
|
1166
|
+
profile.id,
|
|
1167
|
+
type,
|
|
1168
|
+
embedResult.value,
|
|
1169
|
+
this.ai.getActiveEmbeddingModel()
|
|
1170
|
+
);
|
|
1171
|
+
if (!embResult.ok) {
|
|
1172
|
+
this.logger.warn({ error: embResult.error }, "Embedding save failed for profile");
|
|
1173
|
+
}
|
|
1174
|
+
} else {
|
|
1175
|
+
this.logger.warn({ error: embedResult.error }, "Embedding generation failed for profile");
|
|
1176
|
+
}
|
|
1177
|
+
this.logger.info({ profileId: profile.id }, `${label} generated`);
|
|
1178
|
+
return { ok: true, value: profile };
|
|
1179
|
+
}
|
|
1180
|
+
getSchema(type) {
|
|
1181
|
+
switch (type) {
|
|
1182
|
+
case "voice":
|
|
1183
|
+
return voiceProfileResponseSchema;
|
|
1184
|
+
case "knowledge":
|
|
1185
|
+
return knowledgeProfileResponseSchema;
|
|
1186
|
+
case "publication":
|
|
1187
|
+
return publicationProfileResponseSchema;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
buildProfile(type, data) {
|
|
1191
|
+
const id = randomUUID4();
|
|
1192
|
+
const createdAt = /* @__PURE__ */ new Date();
|
|
1193
|
+
switch (type) {
|
|
1194
|
+
case "voice": {
|
|
1195
|
+
const { traits, summary: summary2 } = voiceProfileResponseSchema.parse(data);
|
|
1196
|
+
return { id, name: null, traits, summary: summary2, createdAt };
|
|
1197
|
+
}
|
|
1198
|
+
case "knowledge": {
|
|
1199
|
+
const { domains, topics, summary: summary2 } = knowledgeProfileResponseSchema.parse(data);
|
|
1200
|
+
return { id, domains, topics, summary: summary2, createdAt };
|
|
1201
|
+
}
|
|
1202
|
+
case "publication": {
|
|
1203
|
+
const { themes, summary: summary2 } = publicationProfileResponseSchema.parse(data);
|
|
1204
|
+
return { id, themes, series: null, summary: summary2, createdAt };
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
async saveProfile(type, profile) {
|
|
1209
|
+
switch (type) {
|
|
1210
|
+
case "voice":
|
|
1211
|
+
return this.profileRepo.saveVoiceProfile(profile);
|
|
1212
|
+
case "knowledge":
|
|
1213
|
+
return this.profileRepo.saveKnowledgeProfile(profile);
|
|
1214
|
+
case "publication":
|
|
1215
|
+
return this.profileRepo.savePublicationProfile(profile);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1220
|
+
// src/application/use-cases/import-sources.usecase.ts
|
|
1221
|
+
var DEFAULT_CONCURRENCY = 5;
|
|
1222
|
+
var ImportSourcesUseCase = class {
|
|
1223
|
+
constructor(sourceRepo, fileSource, ai2, logger2) {
|
|
1224
|
+
this.sourceRepo = sourceRepo;
|
|
1225
|
+
this.fileSource = fileSource;
|
|
1226
|
+
this.ai = ai2;
|
|
1227
|
+
this.logger = logger2;
|
|
1228
|
+
}
|
|
1229
|
+
sourceRepo;
|
|
1230
|
+
fileSource;
|
|
1231
|
+
ai;
|
|
1232
|
+
logger;
|
|
1233
|
+
async execute(sourcePath, type) {
|
|
1234
|
+
this.logger.info({ sourcePath, type }, "Starting import");
|
|
1235
|
+
const sources = await this.fileSource.importFromPath(sourcePath, type);
|
|
1236
|
+
const alreadyImportedResults = await Promise.all(
|
|
1237
|
+
sources.map((s) => this.sourceRepo.isAlreadyImported(s.sourcePath, s.checksum))
|
|
1238
|
+
);
|
|
1239
|
+
const newSources = sources.filter((_, i) => {
|
|
1240
|
+
const result = alreadyImportedResults[i];
|
|
1241
|
+
return !result.ok || !result.value;
|
|
1242
|
+
});
|
|
1243
|
+
const skipped = sources.length - newSources.length;
|
|
1244
|
+
if (skipped > 0) {
|
|
1245
|
+
this.logger.info({ skipped }, "Skipped previously imported chunks");
|
|
1246
|
+
}
|
|
1247
|
+
if (newSources.length === 0) {
|
|
1248
|
+
this.logger.info({ type }, "All sources already imported");
|
|
1249
|
+
return { ok: true, value: 0 };
|
|
1250
|
+
}
|
|
1251
|
+
const saveResult = await this.sourceRepo.saveSources(newSources);
|
|
1252
|
+
if (!saveResult.ok) {
|
|
1253
|
+
this.logger.error({ error: saveResult.error }, "Failed to save sources");
|
|
1254
|
+
return { ok: false, error: saveResult.error };
|
|
1255
|
+
}
|
|
1256
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1257
|
+
for (const s of newSources) {
|
|
1258
|
+
const key = `${s.sourcePath}:${s.checksum}`;
|
|
1259
|
+
if (seen.has(key)) continue;
|
|
1260
|
+
seen.add(key);
|
|
1261
|
+
await this.sourceRepo.logImport(s.sourcePath, s.checksum, type);
|
|
1262
|
+
}
|
|
1263
|
+
this.logger.info({ type, count: newSources.length, skipped }, "Import complete");
|
|
1264
|
+
return { ok: true, value: newSources.length };
|
|
1265
|
+
}
|
|
1266
|
+
async generateEmbeddingsForType(type, onProgress) {
|
|
1267
|
+
const sourcesResult = await this.sourceRepo.getSourcesByType(type);
|
|
1268
|
+
if (!sourcesResult.ok) {
|
|
1269
|
+
this.logger.error({ error: sourcesResult.error }, "Failed to load sources for embedding");
|
|
1270
|
+
return { ok: false, error: sourcesResult.error };
|
|
1271
|
+
}
|
|
1272
|
+
const sources = sourcesResult.value;
|
|
1273
|
+
const total = sources.length;
|
|
1274
|
+
if (total === 0) {
|
|
1275
|
+
onProgress?.(0, 0, "No sources to embed");
|
|
1276
|
+
return { ok: true, value: { total: 0, failed: 0 } };
|
|
1277
|
+
}
|
|
1278
|
+
let completed = 0;
|
|
1279
|
+
let failed = 0;
|
|
1280
|
+
const model = this.ai.getActiveEmbeddingModel();
|
|
1281
|
+
const processChunk = async (chunk) => {
|
|
1282
|
+
const results = await Promise.allSettled(
|
|
1283
|
+
chunk.map((source) => this.ai.generateEmbedding(source.content))
|
|
1284
|
+
);
|
|
1285
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
1286
|
+
const result = results[j];
|
|
1287
|
+
if (result.status === "rejected") {
|
|
1288
|
+
this.logger.warn({ sourceId: chunk[j].id }, "Embedding rejected");
|
|
1289
|
+
failed++;
|
|
1290
|
+
completed++;
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
if (!result.value.ok) {
|
|
1294
|
+
this.logger.warn({ sourceId: chunk[j].id, error: result.value.error }, "Embedding failed");
|
|
1295
|
+
failed++;
|
|
1296
|
+
completed++;
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
const storeResult = await this.sourceRepo.saveSourceEmbedding(
|
|
1300
|
+
chunk[j].id,
|
|
1301
|
+
result.value.value,
|
|
1302
|
+
model
|
|
1303
|
+
);
|
|
1304
|
+
if (!storeResult.ok) {
|
|
1305
|
+
this.logger.warn({ sourceId: chunk[j].id, error: storeResult.error }, "Failed to store embedding");
|
|
1306
|
+
failed++;
|
|
1307
|
+
}
|
|
1308
|
+
completed++;
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
onProgress?.(0, total, "Generating embeddings...");
|
|
1312
|
+
for (let i = 0; i < sources.length; i += DEFAULT_CONCURRENCY) {
|
|
1313
|
+
const chunk = sources.slice(i, i + DEFAULT_CONCURRENCY);
|
|
1314
|
+
await processChunk(chunk);
|
|
1315
|
+
onProgress?.(completed, total, `Embedded ${completed}/${total}`);
|
|
1316
|
+
}
|
|
1317
|
+
this.logger.info({ type, total: sources.length }, "Embeddings complete");
|
|
1318
|
+
return { ok: true, value: { total, failed } };
|
|
1319
|
+
}
|
|
1320
|
+
async getImportStatus(type) {
|
|
1321
|
+
const count = await this.sourceRepo.getSourceCountByType(type);
|
|
1322
|
+
return count.ok ? count.value : 0;
|
|
1323
|
+
}
|
|
1324
|
+
};
|
|
1325
|
+
|
|
1326
|
+
// src/application/use-cases/export-article.usecase.ts
|
|
1327
|
+
import { marked } from "marked";
|
|
1328
|
+
var HTML_TEMPLATE = ({
|
|
1329
|
+
title,
|
|
1330
|
+
body,
|
|
1331
|
+
createdAt,
|
|
1332
|
+
wordCount
|
|
1333
|
+
}) => `<!DOCTYPE html>
|
|
1334
|
+
<html lang="en">
|
|
1335
|
+
<head>
|
|
1336
|
+
<meta charset="UTF-8">
|
|
1337
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1338
|
+
<title>${escapeHtml(title)}</title>
|
|
1339
|
+
<style>
|
|
1340
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1341
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 18px; line-height: 1.8; color: #1d1d1f; max-width: 720px; margin: 0 auto; padding: 2rem 1.5rem; }
|
|
1342
|
+
header { margin-bottom: 2.5rem; }
|
|
1343
|
+
h1 { font-size: 2rem; line-height: 1.3; margin-bottom: .5rem; }
|
|
1344
|
+
.meta { color: #6e6e73; font-size: .875rem; }
|
|
1345
|
+
h2 { font-size: 1.5rem; margin-top: 2rem; margin-bottom: .75rem; }
|
|
1346
|
+
h3 { font-size: 1.25rem; margin-top: 1.5rem; margin-bottom: .5rem; }
|
|
1347
|
+
p { margin-bottom: 1.25rem; }
|
|
1348
|
+
a { color: #0066cc; }
|
|
1349
|
+
code { background: #f5f5f7; padding: .15em .4em; border-radius: 3px; font-size: .9em; }
|
|
1350
|
+
pre { background: #f5f5f7; padding: 1rem; border-radius: 8px; overflow-x: auto; margin-bottom: 1.25rem; }
|
|
1351
|
+
pre code { background: none; padding: 0; }
|
|
1352
|
+
blockquote { border-left: 3px solid #d2d2d7; padding-left: 1rem; color: #6e6e73; margin-bottom: 1.25rem; }
|
|
1353
|
+
ul, ol { margin-bottom: 1.25rem; padding-left: 1.5rem; }
|
|
1354
|
+
img { max-width: 100%; height: auto; border-radius: 8px; }
|
|
1355
|
+
hr { border: none; border-top: 1px solid #d2d2d7; margin: 2rem 0; }
|
|
1356
|
+
footer { margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid #d2d2d7; font-size: .8125rem; color: #6e6e73; }
|
|
1357
|
+
</style>
|
|
1358
|
+
</head>
|
|
1359
|
+
<body>
|
|
1360
|
+
<header>
|
|
1361
|
+
<h1>${escapeHtml(title)}</h1>
|
|
1362
|
+
<p class="meta">${createdAt} · ${wordCount} words</p>
|
|
1363
|
+
</header>
|
|
1364
|
+
<main>${body}</main>
|
|
1365
|
+
<footer>Generated by Thoth</footer>
|
|
1366
|
+
</body>
|
|
1367
|
+
</html>`;
|
|
1368
|
+
function escapeHtml(text) {
|
|
1369
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1370
|
+
}
|
|
1371
|
+
function stripMarkdown(md) {
|
|
1372
|
+
return md.replace(/#{1,6}\s+/g, "").replace(/\*\*(.+?)\*\*/g, "$1").replace(/\*(.+?)\*/g, "$1").replace(/`(.+?)`/g, "$1").replace(/\[(.+?)\]\(.+?\)/g, "$1").replace(/!\[.*?\]\(.+?\)/g, "").replace(/>\s+/g, "").replace(/[-*+]\s+/g, "").replace(/\d+\.\s+/g, "").replace(/---/g, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
1373
|
+
}
|
|
1374
|
+
function formatArticleAsMarkdown(article2) {
|
|
1375
|
+
const date = article2.createdAt.toISOString().split("T")[0];
|
|
1376
|
+
return [
|
|
1377
|
+
"---",
|
|
1378
|
+
`title: "${article2.title}"`,
|
|
1379
|
+
`date: ${date}`,
|
|
1380
|
+
`word_count: ${article2.wordCount}`,
|
|
1381
|
+
`status: ${article2.status}`,
|
|
1382
|
+
`id: ${article2.id}`,
|
|
1383
|
+
"---",
|
|
1384
|
+
"",
|
|
1385
|
+
article2.content
|
|
1386
|
+
].join("\n");
|
|
1387
|
+
}
|
|
1388
|
+
function formatArticleAsHtml(article2) {
|
|
1389
|
+
const date = article2.createdAt.toISOString().split("T")[0];
|
|
1390
|
+
const body = marked.parse(article2.content);
|
|
1391
|
+
return HTML_TEMPLATE({ title: article2.title, body, createdAt: date, wordCount: article2.wordCount });
|
|
1392
|
+
}
|
|
1393
|
+
function formatArticleAsTxt(article2) {
|
|
1394
|
+
const date = article2.createdAt.toISOString().split("T")[0];
|
|
1395
|
+
const plain = stripMarkdown(article2.content);
|
|
1396
|
+
return [
|
|
1397
|
+
`Title: ${article2.title}`,
|
|
1398
|
+
`Date: ${date}`,
|
|
1399
|
+
`Words: ${article2.wordCount}`,
|
|
1400
|
+
`Status: ${article2.status}`,
|
|
1401
|
+
"",
|
|
1402
|
+
plain
|
|
1403
|
+
].join("\n");
|
|
1404
|
+
}
|
|
1405
|
+
var ExportArticleUseCase = class {
|
|
1406
|
+
constructor(articleRepo) {
|
|
1407
|
+
this.articleRepo = articleRepo;
|
|
1408
|
+
}
|
|
1409
|
+
articleRepo;
|
|
1410
|
+
async execute(input) {
|
|
1411
|
+
const articleResult = await this.articleRepo.get(input.articleId);
|
|
1412
|
+
if (!articleResult.ok) return articleResult;
|
|
1413
|
+
if (!articleResult.value) return { ok: false, error: `Article not found: ${input.articleId}` };
|
|
1414
|
+
const article2 = articleResult.value;
|
|
1415
|
+
const slug = article2.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || "article";
|
|
1416
|
+
let content;
|
|
1417
|
+
let ext;
|
|
1418
|
+
switch (input.format) {
|
|
1419
|
+
case "md":
|
|
1420
|
+
content = formatArticleAsMarkdown(article2);
|
|
1421
|
+
ext = "md";
|
|
1422
|
+
break;
|
|
1423
|
+
case "html":
|
|
1424
|
+
content = formatArticleAsHtml(article2);
|
|
1425
|
+
ext = "html";
|
|
1426
|
+
break;
|
|
1427
|
+
case "txt":
|
|
1428
|
+
content = formatArticleAsTxt(article2);
|
|
1429
|
+
ext = "txt";
|
|
1430
|
+
break;
|
|
1431
|
+
}
|
|
1432
|
+
return {
|
|
1433
|
+
ok: true,
|
|
1434
|
+
value: { content, filename: `${slug}.${ext}` }
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
};
|
|
1438
|
+
|
|
1439
|
+
// src/application/use-cases/export-series.usecase.ts
|
|
1440
|
+
import { marked as marked2 } from "marked";
|
|
1441
|
+
function escapeXml(text) {
|
|
1442
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1443
|
+
}
|
|
1444
|
+
function generateRssFeed(series, articles) {
|
|
1445
|
+
const now = (/* @__PURE__ */ new Date()).toUTCString();
|
|
1446
|
+
const items = articles.map(
|
|
1447
|
+
(a) => ` <item>
|
|
1448
|
+
<title>${escapeXml(a.title)}</title>
|
|
1449
|
+
<guid isPermaLink="false">${escapeXml(a.id)}</guid>
|
|
1450
|
+
<pubDate>${a.createdAt.toUTCString()}</pubDate>
|
|
1451
|
+
<description>${escapeXml(stripMarkdown(a.content).slice(0, 500))}</description>
|
|
1452
|
+
<content:encoded><![CDATA[${a.content}]]></content:encoded>
|
|
1453
|
+
</item>`
|
|
1454
|
+
).join("\n");
|
|
1455
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1456
|
+
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
1457
|
+
<channel>
|
|
1458
|
+
<title>${escapeXml(series.name)}</title>
|
|
1459
|
+
${series.description ? `<description>${escapeXml(series.description)}</description>` : ""}
|
|
1460
|
+
<link>https://thoth.sh</link>
|
|
1461
|
+
<lastBuildDate>${now}</lastBuildDate>
|
|
1462
|
+
<atom:link href="https://thoth.sh/rss.xml" rel="self" type="application/rss+xml"/>
|
|
1463
|
+
${items}
|
|
1464
|
+
</channel>
|
|
1465
|
+
</rss>`;
|
|
1466
|
+
}
|
|
1467
|
+
function generateSeriesMarkdown(series, articles) {
|
|
1468
|
+
const date = series.createdAt.toISOString().split("T")[0];
|
|
1469
|
+
const parts = [
|
|
1470
|
+
"---",
|
|
1471
|
+
`title: "${series.name}"`,
|
|
1472
|
+
`date: ${date}`,
|
|
1473
|
+
`articles: ${articles.length}`,
|
|
1474
|
+
`id: ${series.id}`,
|
|
1475
|
+
"---",
|
|
1476
|
+
"",
|
|
1477
|
+
series.description ? `${series.description}
|
|
1478
|
+
` : ""
|
|
1479
|
+
];
|
|
1480
|
+
for (let i = 0; i < articles.length; i++) {
|
|
1481
|
+
const a = articles[i];
|
|
1482
|
+
parts.push(`## ${i + 1}. ${a.title}`);
|
|
1483
|
+
parts.push("");
|
|
1484
|
+
parts.push(formatArticleAsMarkdown(a));
|
|
1485
|
+
parts.push("");
|
|
1486
|
+
parts.push("---");
|
|
1487
|
+
parts.push("");
|
|
1488
|
+
}
|
|
1489
|
+
return parts.join("\n");
|
|
1490
|
+
}
|
|
1491
|
+
function generateSeriesHtml(series, articles) {
|
|
1492
|
+
const date = series.createdAt.toISOString().split("T")[0];
|
|
1493
|
+
const articleHtml = articles.map((a, i) => {
|
|
1494
|
+
const body = marked2.parse(a.content);
|
|
1495
|
+
return `<article>
|
|
1496
|
+
<h2>${escapeXml(`${i + 1}. ${a.title}`)}</h2>
|
|
1497
|
+
<div>${body}</div>
|
|
1498
|
+
</article>`;
|
|
1499
|
+
}).join("\n");
|
|
1500
|
+
return `<!DOCTYPE html>
|
|
1501
|
+
<html lang="en">
|
|
1502
|
+
<head>
|
|
1503
|
+
<meta charset="UTF-8">
|
|
1504
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1505
|
+
<title>${escapeXml(series.name)}</title>
|
|
1506
|
+
<style>
|
|
1507
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1508
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 18px; line-height: 1.8; color: #1d1d1f; max-width: 720px; margin: 0 auto; padding: 2rem 1.5rem; }
|
|
1509
|
+
h1 { font-size: 2rem; margin-bottom: .25rem; }
|
|
1510
|
+
.meta { color: #6e6e73; font-size: .875rem; margin-bottom: 2rem; }
|
|
1511
|
+
.toc { background: #f5f5f7; padding: 1.25rem 1.5rem; border-radius: 8px; margin-bottom: 2rem; }
|
|
1512
|
+
.toc h2 { font-size: 1rem; margin-bottom: .5rem; }
|
|
1513
|
+
.toc ol { padding-left: 1.25rem; }
|
|
1514
|
+
.toc li { margin-bottom: .25rem; }
|
|
1515
|
+
.toc a { color: #0066cc; text-decoration: none; }
|
|
1516
|
+
.toc a:hover { text-decoration: underline; }
|
|
1517
|
+
article { margin-bottom: 3rem; padding-top: 1rem; border-top: 1px solid #d2d2d7; }
|
|
1518
|
+
article h2 { font-size: 1.5rem; margin-bottom: 1rem; }
|
|
1519
|
+
article p { margin-bottom: 1.25rem; }
|
|
1520
|
+
article a { color: #0066cc; }
|
|
1521
|
+
article code { background: #f5f5f7; padding: .15em .4em; border-radius: 3px; font-size: .9em; }
|
|
1522
|
+
article pre { background: #f5f5f7; padding: 1rem; border-radius: 8px; overflow-x: auto; margin-bottom: 1.25rem; }
|
|
1523
|
+
article pre code { background: none; padding: 0; }
|
|
1524
|
+
article blockquote { border-left: 3px solid #d2d2d7; padding-left: 1rem; color: #6e6e73; margin-bottom: 1.25rem; }
|
|
1525
|
+
article ul, article ol { margin-bottom: 1.25rem; padding-left: 1.5rem; }
|
|
1526
|
+
article img { max-width: 100%; height: auto; border-radius: 8px; }
|
|
1527
|
+
footer { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid #d2d2d7; font-size: .8125rem; color: #6e6e73; }
|
|
1528
|
+
</style>
|
|
1529
|
+
</head>
|
|
1530
|
+
<body>
|
|
1531
|
+
<header>
|
|
1532
|
+
<h1>${escapeXml(series.name)}</h1>
|
|
1533
|
+
${series.description ? `<p class="meta">${escapeXml(series.description)}</p>` : ""}
|
|
1534
|
+
<p class="meta">${articles.length} articles · ${date}</p>
|
|
1535
|
+
</header>
|
|
1536
|
+
<nav class="toc">
|
|
1537
|
+
<h2>Table of Contents</h2>
|
|
1538
|
+
<ol>
|
|
1539
|
+
${articles.map((a, i) => `<li><a href="#article-${i + 1}">${escapeXml(a.title)}</a></li>`).join("\n ")}
|
|
1540
|
+
</ol>
|
|
1541
|
+
</nav>
|
|
1542
|
+
<main>
|
|
1543
|
+
${articleHtml}
|
|
1544
|
+
</main>
|
|
1545
|
+
<footer>Generated by Thoth</footer>
|
|
1546
|
+
</body>
|
|
1547
|
+
</html>`;
|
|
1548
|
+
}
|
|
1549
|
+
var ExportSeriesUseCase = class {
|
|
1550
|
+
constructor(seriesRepo, articleRepo) {
|
|
1551
|
+
this.seriesRepo = seriesRepo;
|
|
1552
|
+
this.articleRepo = articleRepo;
|
|
1553
|
+
}
|
|
1554
|
+
seriesRepo;
|
|
1555
|
+
articleRepo;
|
|
1556
|
+
async execute(input) {
|
|
1557
|
+
const seriesResult = await this.seriesRepo.get(input.seriesId);
|
|
1558
|
+
if (!seriesResult.ok) return seriesResult;
|
|
1559
|
+
if (!seriesResult.value) return { ok: false, error: `Series not found: ${input.seriesId}` };
|
|
1560
|
+
const series = seriesResult.value;
|
|
1561
|
+
const articles = [];
|
|
1562
|
+
for (const articleId of series.articleIds) {
|
|
1563
|
+
const articleResult = await this.articleRepo.get(articleId);
|
|
1564
|
+
if (articleResult.ok && articleResult.value) {
|
|
1565
|
+
articles.push(articleResult.value);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
const slug = series.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || "series";
|
|
1569
|
+
let content;
|
|
1570
|
+
let ext;
|
|
1571
|
+
switch (input.format) {
|
|
1572
|
+
case "md":
|
|
1573
|
+
content = generateSeriesMarkdown(series, articles);
|
|
1574
|
+
ext = "md";
|
|
1575
|
+
break;
|
|
1576
|
+
case "html":
|
|
1577
|
+
content = generateSeriesHtml(series, articles);
|
|
1578
|
+
ext = "html";
|
|
1579
|
+
break;
|
|
1580
|
+
case "rss":
|
|
1581
|
+
content = generateRssFeed(series, articles);
|
|
1582
|
+
ext = "xml";
|
|
1583
|
+
break;
|
|
1584
|
+
}
|
|
1585
|
+
return {
|
|
1586
|
+
ok: true,
|
|
1587
|
+
value: { content, filename: `${slug}.${ext}` }
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
};
|
|
1591
|
+
|
|
1592
|
+
// src/application/use-cases/series.usecase.ts
|
|
1593
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
1594
|
+
var SeriesUseCase = class {
|
|
1595
|
+
constructor(seriesRepo, logger2) {
|
|
1596
|
+
this.seriesRepo = seriesRepo;
|
|
1597
|
+
this.logger = logger2;
|
|
1598
|
+
}
|
|
1599
|
+
seriesRepo;
|
|
1600
|
+
logger;
|
|
1601
|
+
async create(name, description) {
|
|
1602
|
+
const series = {
|
|
1603
|
+
id: randomUUID5(),
|
|
1604
|
+
name,
|
|
1605
|
+
description: description ?? null,
|
|
1606
|
+
articleIds: [],
|
|
1607
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1608
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1609
|
+
};
|
|
1610
|
+
const result = await this.seriesRepo.save(series);
|
|
1611
|
+
if (!result.ok) return result;
|
|
1612
|
+
this.logger.info({ seriesId: series.id, name }, "Series created");
|
|
1613
|
+
return { ok: true, value: series };
|
|
1614
|
+
}
|
|
1615
|
+
async list() {
|
|
1616
|
+
return this.seriesRepo.list();
|
|
1617
|
+
}
|
|
1618
|
+
async get(id) {
|
|
1619
|
+
return this.seriesRepo.get(id);
|
|
1620
|
+
}
|
|
1621
|
+
async addArticle(seriesId, articleId) {
|
|
1622
|
+
const seriesResult = await this.seriesRepo.get(seriesId);
|
|
1623
|
+
if (!seriesResult.ok) return { ok: false, error: seriesResult.error };
|
|
1624
|
+
if (!seriesResult.value) return { ok: false, error: `Series not found: ${seriesId}` };
|
|
1625
|
+
const result = await this.seriesRepo.addArticle(seriesId, articleId);
|
|
1626
|
+
if (!result.ok) return result;
|
|
1627
|
+
this.logger.info({ seriesId, articleId }, "Article added to series");
|
|
1628
|
+
return { ok: true, value: void 0 };
|
|
1629
|
+
}
|
|
1630
|
+
};
|
|
1631
|
+
|
|
1632
|
+
// src/application/use-cases/publish-article.usecase.ts
|
|
1633
|
+
var PublishArticleUseCase = class {
|
|
1634
|
+
constructor(articleRepository, mediumAdapter, logger2) {
|
|
1635
|
+
this.articleRepository = articleRepository;
|
|
1636
|
+
this.mediumAdapter = mediumAdapter;
|
|
1637
|
+
this.logger = logger2;
|
|
1638
|
+
}
|
|
1639
|
+
articleRepository;
|
|
1640
|
+
mediumAdapter;
|
|
1641
|
+
logger;
|
|
1642
|
+
async execute(articleId) {
|
|
1643
|
+
const articleResult = await this.articleRepository.get(articleId);
|
|
1644
|
+
if (!articleResult.ok) return articleResult;
|
|
1645
|
+
if (!articleResult.value) {
|
|
1646
|
+
return { ok: false, error: `Article not found: ${articleId}` };
|
|
1647
|
+
}
|
|
1648
|
+
const article2 = articleResult.value;
|
|
1649
|
+
this.logger.info(
|
|
1650
|
+
{ articleId, title: article2.title },
|
|
1651
|
+
"Publishing article to Medium..."
|
|
1652
|
+
);
|
|
1653
|
+
const publishResult = await this.mediumAdapter.publish({
|
|
1654
|
+
title: article2.title,
|
|
1655
|
+
content: article2.content
|
|
1656
|
+
});
|
|
1657
|
+
if (!publishResult.ok) return publishResult;
|
|
1658
|
+
const { mediumUrl } = publishResult.value;
|
|
1659
|
+
const updatedArticle = {
|
|
1660
|
+
...article2,
|
|
1661
|
+
mediumUrl,
|
|
1662
|
+
status: "published",
|
|
1663
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1664
|
+
};
|
|
1665
|
+
this.logger.info({ articleId, mediumUrl }, "Article published to Medium.");
|
|
1666
|
+
return this.articleRepository.save(updatedArticle);
|
|
1667
|
+
}
|
|
1668
|
+
};
|
|
1669
|
+
|
|
1670
|
+
// src/infrastructure/publishing/medium-http.adapter.ts
|
|
1671
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
|
|
1672
|
+
import { join as join4 } from "path";
|
|
1673
|
+
import { homedir as homedir4 } from "os";
|
|
1674
|
+
var SESSION_DIR = join4(homedir4(), ".thoth");
|
|
1675
|
+
var CREDENTIALS_FILE = join4(SESSION_DIR, "medium-credentials.json");
|
|
1676
|
+
var MediumHttpAdapter = class {
|
|
1677
|
+
constructor(logger2) {
|
|
1678
|
+
this.logger = logger2;
|
|
1679
|
+
}
|
|
1680
|
+
logger;
|
|
1681
|
+
connect(params) {
|
|
1682
|
+
if (!existsSync2(SESSION_DIR)) {
|
|
1683
|
+
mkdirSync2(SESSION_DIR, { recursive: true });
|
|
1684
|
+
}
|
|
1685
|
+
const credentials = {
|
|
1686
|
+
sid: params.sid.trim(),
|
|
1687
|
+
uid: params.uid.trim()
|
|
1688
|
+
};
|
|
1689
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials), { mode: 384 });
|
|
1690
|
+
this.logger.info({ action: "connect_medium_saved" }, "Medium credentials saved.");
|
|
1691
|
+
return Promise.resolve({ ok: true, value: void 0 });
|
|
1692
|
+
}
|
|
1693
|
+
isAuthenticated() {
|
|
1694
|
+
if (!existsSync2(CREDENTIALS_FILE)) return Promise.resolve(false);
|
|
1695
|
+
try {
|
|
1696
|
+
const raw = readFileSync3(CREDENTIALS_FILE, "utf-8");
|
|
1697
|
+
const creds = JSON.parse(raw);
|
|
1698
|
+
return Promise.resolve(typeof creds.sid === "string" && typeof creds.uid === "string");
|
|
1699
|
+
} catch {
|
|
1700
|
+
return Promise.resolve(false);
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
async publish(params) {
|
|
1704
|
+
if (!existsSync2(CREDENTIALS_FILE)) {
|
|
1705
|
+
return {
|
|
1706
|
+
ok: false,
|
|
1707
|
+
error: "Not authenticated. Run `thoth connect medium` first."
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
let credentials;
|
|
1711
|
+
try {
|
|
1712
|
+
credentials = JSON.parse(readFileSync3(CREDENTIALS_FILE, "utf-8"));
|
|
1713
|
+
} catch {
|
|
1714
|
+
return { ok: false, error: "Credentials file is corrupted. Run `thoth connect medium` again." };
|
|
1715
|
+
}
|
|
1716
|
+
const cookie = `uid=${credentials.uid}; sid=${credentials.sid}`;
|
|
1717
|
+
try {
|
|
1718
|
+
this.logger.info({ action: "publish" }, "Creating Medium draft via API...");
|
|
1719
|
+
const response = await fetch("https://medium.com/_/api/posts", {
|
|
1720
|
+
method: "POST",
|
|
1721
|
+
headers: {
|
|
1722
|
+
"Content-Type": "application/json",
|
|
1723
|
+
Cookie: cookie,
|
|
1724
|
+
Accept: "application/json"
|
|
1725
|
+
},
|
|
1726
|
+
body: JSON.stringify({
|
|
1727
|
+
title: params.title,
|
|
1728
|
+
content: params.content,
|
|
1729
|
+
contentFormat: "markdown",
|
|
1730
|
+
publishStatus: "draft",
|
|
1731
|
+
tags: []
|
|
1732
|
+
})
|
|
1733
|
+
});
|
|
1734
|
+
if (!response.ok) {
|
|
1735
|
+
const text = await response.text().catch(() => "");
|
|
1736
|
+
this.logger.error(
|
|
1737
|
+
{ status: response.status, body: text.slice(0, 500) },
|
|
1738
|
+
"Medium API returned error"
|
|
1739
|
+
);
|
|
1740
|
+
return {
|
|
1741
|
+
ok: false,
|
|
1742
|
+
error: response.status === 401 || response.status === 403 ? "Medium rejected the credentials. Your session may have expired. Run `thoth connect medium` again." : `Medium API error (${response.status}). The API may have changed.`
|
|
1743
|
+
};
|
|
1744
|
+
}
|
|
1745
|
+
const body = await response.json();
|
|
1746
|
+
const payload = body.payload;
|
|
1747
|
+
const mediumPostId = payload?.id ?? "";
|
|
1748
|
+
const mediumUrl = mediumPostId ? `https://medium.com/p/${mediumPostId}` : "https://medium.com/me/stories";
|
|
1749
|
+
const result = { mediumPostId, mediumUrl };
|
|
1750
|
+
this.logger.info({ action: "publish_success", mediumUrl }, "Draft created on Medium.");
|
|
1751
|
+
return { ok: true, value: result };
|
|
1752
|
+
} catch (err) {
|
|
1753
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1754
|
+
return { ok: false, error: `Publish failed: ${message}` };
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
};
|
|
1758
|
+
|
|
1759
|
+
// src/infrastructure/persistence/repositories/sqlite-research-repository.ts
|
|
1760
|
+
import { z as z3 } from "zod";
|
|
1761
|
+
var researchNoteRowSchema = z3.object({
|
|
1762
|
+
id: z3.string(),
|
|
1763
|
+
topic: z3.string(),
|
|
1764
|
+
content: z3.string(),
|
|
1765
|
+
citations: z3.string(),
|
|
1766
|
+
created_at: z3.string()
|
|
1767
|
+
}).transform((row) => ({
|
|
1768
|
+
id: row.id,
|
|
1769
|
+
topic: row.topic,
|
|
1770
|
+
content: row.content,
|
|
1771
|
+
citations: JSON.parse(row.citations),
|
|
1772
|
+
createdAt: new Date(row.created_at)
|
|
1773
|
+
}));
|
|
1774
|
+
var SqliteResearchRepository = class {
|
|
1775
|
+
constructor(db3, embeddingTableSuffix = "openai") {
|
|
1776
|
+
this.db = db3;
|
|
1777
|
+
this.vecTable = `vec_research_${embeddingTableSuffix}`;
|
|
1778
|
+
}
|
|
1779
|
+
db;
|
|
1780
|
+
vecTable;
|
|
1781
|
+
save(note) {
|
|
1782
|
+
this.db.prepare(
|
|
1783
|
+
`INSERT INTO research_notes (id, topic, content, citations, created_at)
|
|
1784
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1785
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1786
|
+
topic = excluded.topic,
|
|
1787
|
+
content = excluded.content,
|
|
1788
|
+
citations = excluded.citations`
|
|
1789
|
+
).run(
|
|
1790
|
+
note.id,
|
|
1791
|
+
note.topic,
|
|
1792
|
+
note.content,
|
|
1793
|
+
JSON.stringify(note.citations),
|
|
1794
|
+
note.createdAt.toISOString()
|
|
1795
|
+
);
|
|
1796
|
+
return Promise.resolve({ ok: true, value: note });
|
|
1797
|
+
}
|
|
1798
|
+
get(id) {
|
|
1799
|
+
const row = this.db.prepare("SELECT * FROM research_notes WHERE id = ?").get(id);
|
|
1800
|
+
if (!row) return Promise.resolve({ ok: true, value: null });
|
|
1801
|
+
const parsed = researchNoteRowSchema.safeParse(row);
|
|
1802
|
+
if (!parsed.success) return Promise.resolve({ ok: false, error: `Invalid research note row: ${parsed.error.message}` });
|
|
1803
|
+
return Promise.resolve({ ok: true, value: parsed.data });
|
|
1804
|
+
}
|
|
1805
|
+
searchByTopic(topic) {
|
|
1806
|
+
const rows = this.db.prepare("SELECT * FROM research_notes WHERE topic LIKE ? ORDER BY created_at DESC").all(`%${topic}%`);
|
|
1807
|
+
const notes = [];
|
|
1808
|
+
for (const row of rows) {
|
|
1809
|
+
const parsed = researchNoteRowSchema.safeParse(row);
|
|
1810
|
+
if (!parsed.success) continue;
|
|
1811
|
+
notes.push(parsed.data);
|
|
1812
|
+
}
|
|
1813
|
+
return Promise.resolve({ ok: true, value: notes });
|
|
1814
|
+
}
|
|
1815
|
+
saveResearchEmbedding(researchId, embedding, model) {
|
|
1816
|
+
try {
|
|
1817
|
+
const insertVec = this.db.prepare(`INSERT INTO ${this.vecTable} (embedding) VALUES (?)`);
|
|
1818
|
+
const insertEmb = this.db.prepare(
|
|
1819
|
+
"INSERT INTO research_embeddings (research_id, model) VALUES (?, ?)"
|
|
1820
|
+
);
|
|
1821
|
+
const doInsert = this.db.transaction(() => {
|
|
1822
|
+
insertVec.run(new Float32Array(embedding));
|
|
1823
|
+
insertEmb.run(researchId, model);
|
|
1824
|
+
});
|
|
1825
|
+
doInsert();
|
|
1826
|
+
return Promise.resolve({ ok: true, value: void 0 });
|
|
1827
|
+
} catch (error2) {
|
|
1828
|
+
return Promise.resolve({
|
|
1829
|
+
ok: false,
|
|
1830
|
+
error: error2 instanceof Error ? error2.message : String(error2)
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
};
|
|
1835
|
+
|
|
1836
|
+
// src/infrastructure/persistence/repositories/sqlite-article-repository.ts
|
|
1837
|
+
import { z as z4 } from "zod";
|
|
1838
|
+
var articleRowSchema = z4.object({
|
|
1839
|
+
id: z4.string(),
|
|
1840
|
+
title: z4.string(),
|
|
1841
|
+
content: z4.string(),
|
|
1842
|
+
voice_profile_id: z4.string(),
|
|
1843
|
+
research_id: z4.string().nullable(),
|
|
1844
|
+
word_count: z4.number(),
|
|
1845
|
+
status: z4.enum(["draft", "published", "archived"]),
|
|
1846
|
+
medium_url: z4.string().nullable(),
|
|
1847
|
+
created_at: z4.string(),
|
|
1848
|
+
updated_at: z4.string()
|
|
1849
|
+
}).transform((row) => ({
|
|
1850
|
+
id: row.id,
|
|
1851
|
+
title: row.title,
|
|
1852
|
+
content: row.content,
|
|
1853
|
+
voiceProfileId: row.voice_profile_id,
|
|
1854
|
+
researchId: row.research_id,
|
|
1855
|
+
wordCount: row.word_count,
|
|
1856
|
+
status: row.status,
|
|
1857
|
+
mediumUrl: row.medium_url,
|
|
1858
|
+
createdAt: new Date(row.created_at),
|
|
1859
|
+
updatedAt: new Date(row.updated_at)
|
|
1860
|
+
}));
|
|
1861
|
+
var SqliteArticleRepository = class {
|
|
1862
|
+
constructor(db3) {
|
|
1863
|
+
this.db = db3;
|
|
1864
|
+
}
|
|
1865
|
+
db;
|
|
1866
|
+
save(article2) {
|
|
1867
|
+
this.db.prepare(
|
|
1868
|
+
`INSERT INTO articles (id, title, content, voice_profile_id, research_id, word_count, status, medium_url, created_at, updated_at)
|
|
1869
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1870
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1871
|
+
title = excluded.title,
|
|
1872
|
+
content = excluded.content,
|
|
1873
|
+
word_count = excluded.word_count,
|
|
1874
|
+
status = excluded.status,
|
|
1875
|
+
medium_url = excluded.medium_url,
|
|
1876
|
+
updated_at = excluded.updated_at`
|
|
1877
|
+
).run(
|
|
1878
|
+
article2.id,
|
|
1879
|
+
article2.title,
|
|
1880
|
+
article2.content,
|
|
1881
|
+
article2.voiceProfileId,
|
|
1882
|
+
article2.researchId,
|
|
1883
|
+
article2.wordCount,
|
|
1884
|
+
article2.status,
|
|
1885
|
+
article2.mediumUrl,
|
|
1886
|
+
article2.createdAt.toISOString(),
|
|
1887
|
+
article2.updatedAt.toISOString()
|
|
1888
|
+
);
|
|
1889
|
+
return Promise.resolve({ ok: true, value: article2 });
|
|
1890
|
+
}
|
|
1891
|
+
get(id) {
|
|
1892
|
+
const row = this.db.prepare("SELECT * FROM articles WHERE id = ?").get(id);
|
|
1893
|
+
if (!row) return Promise.resolve({ ok: true, value: null });
|
|
1894
|
+
const parsed = articleRowSchema.safeParse(row);
|
|
1895
|
+
if (!parsed.success) return Promise.resolve({ ok: false, error: `Invalid article row: ${parsed.error.message}` });
|
|
1896
|
+
return Promise.resolve({ ok: true, value: parsed.data });
|
|
1897
|
+
}
|
|
1898
|
+
list() {
|
|
1899
|
+
const rows = this.db.prepare("SELECT * FROM articles ORDER BY created_at DESC").all();
|
|
1900
|
+
const articles = [];
|
|
1901
|
+
for (const row of rows) {
|
|
1902
|
+
const parsed = articleRowSchema.safeParse(row);
|
|
1903
|
+
if (!parsed.success) continue;
|
|
1904
|
+
articles.push(parsed.data);
|
|
1905
|
+
}
|
|
1906
|
+
return Promise.resolve({ ok: true, value: articles });
|
|
1907
|
+
}
|
|
1908
|
+
};
|
|
1909
|
+
|
|
1910
|
+
// src/infrastructure/persistence/repositories/profile-schemas.ts
|
|
1911
|
+
import { z as z5 } from "zod";
|
|
1912
|
+
var voiceProfileRowSchema = z5.object({
|
|
1913
|
+
id: z5.string(),
|
|
1914
|
+
name: z5.string().nullable(),
|
|
1915
|
+
traits: z5.string(),
|
|
1916
|
+
summary: z5.string().nullable(),
|
|
1917
|
+
created_at: z5.string()
|
|
1918
|
+
}).transform((row) => ({
|
|
1919
|
+
id: row.id,
|
|
1920
|
+
name: row.name,
|
|
1921
|
+
traits: JSON.parse(row.traits),
|
|
1922
|
+
summary: row.summary,
|
|
1923
|
+
createdAt: new Date(row.created_at)
|
|
1924
|
+
}));
|
|
1925
|
+
var knowledgeProfileRowSchema = z5.object({
|
|
1926
|
+
id: z5.string(),
|
|
1927
|
+
domains: z5.string(),
|
|
1928
|
+
topics: z5.string(),
|
|
1929
|
+
summary: z5.string().nullable(),
|
|
1930
|
+
created_at: z5.string()
|
|
1931
|
+
}).transform((row) => ({
|
|
1932
|
+
id: row.id,
|
|
1933
|
+
domains: JSON.parse(row.domains),
|
|
1934
|
+
topics: JSON.parse(row.topics),
|
|
1935
|
+
summary: row.summary,
|
|
1936
|
+
createdAt: new Date(row.created_at)
|
|
1937
|
+
}));
|
|
1938
|
+
var publicationProfileRowSchema = z5.object({
|
|
1939
|
+
id: z5.string(),
|
|
1940
|
+
themes: z5.string(),
|
|
1941
|
+
series: z5.string().nullable(),
|
|
1942
|
+
summary: z5.string().nullable(),
|
|
1943
|
+
created_at: z5.string()
|
|
1944
|
+
}).transform((row) => ({
|
|
1945
|
+
id: row.id,
|
|
1946
|
+
themes: JSON.parse(row.themes),
|
|
1947
|
+
series: row.series ? JSON.parse(row.series) : null,
|
|
1948
|
+
summary: row.summary,
|
|
1949
|
+
createdAt: new Date(row.created_at)
|
|
1950
|
+
}));
|
|
1951
|
+
|
|
1952
|
+
// src/infrastructure/persistence/repositories/sqlite-profile-repository.ts
|
|
1953
|
+
function parseRow(schema, row, label) {
|
|
1954
|
+
try {
|
|
1955
|
+
return { ok: true, value: schema.parse(row) };
|
|
1956
|
+
} catch (error2) {
|
|
1957
|
+
return { ok: false, error: `Invalid ${label} row: ${error2 instanceof Error ? error2.message : String(error2)}` };
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
var SqliteProfileRepository = class {
|
|
1961
|
+
constructor(db3, embeddingTableSuffix = "openai") {
|
|
1962
|
+
this.db = db3;
|
|
1963
|
+
this.vecTable = `vec_profiles_${embeddingTableSuffix}`;
|
|
1964
|
+
}
|
|
1965
|
+
db;
|
|
1966
|
+
vecTable;
|
|
1967
|
+
saveVoiceProfile(profile) {
|
|
1968
|
+
return Promise.resolve(this.saveVoiceProfileSync(profile));
|
|
1969
|
+
}
|
|
1970
|
+
getVoiceProfile(id) {
|
|
1971
|
+
return Promise.resolve(this.getProfileByTypeAndId("voice", id));
|
|
1972
|
+
}
|
|
1973
|
+
getLatestVoiceProfile() {
|
|
1974
|
+
return Promise.resolve(this.getLatestProfileByType("voice"));
|
|
1975
|
+
}
|
|
1976
|
+
saveKnowledgeProfile(profile) {
|
|
1977
|
+
return Promise.resolve(this.saveKnowledgeProfileSync(profile));
|
|
1978
|
+
}
|
|
1979
|
+
getKnowledgeProfile(id) {
|
|
1980
|
+
return Promise.resolve(this.getProfileByTypeAndId("knowledge", id));
|
|
1981
|
+
}
|
|
1982
|
+
getLatestKnowledgeProfile() {
|
|
1983
|
+
return Promise.resolve(this.getLatestProfileByType("knowledge"));
|
|
1984
|
+
}
|
|
1985
|
+
savePublicationProfile(profile) {
|
|
1986
|
+
return Promise.resolve(this.savePublicationProfileSync(profile));
|
|
1987
|
+
}
|
|
1988
|
+
getPublicationProfile(id) {
|
|
1989
|
+
return Promise.resolve(this.getProfileByTypeAndId("publication", id));
|
|
1990
|
+
}
|
|
1991
|
+
getLatestPublicationProfile() {
|
|
1992
|
+
return Promise.resolve(this.getLatestProfileByType("publication"));
|
|
1993
|
+
}
|
|
1994
|
+
saveProfileEmbedding(profileId, type, embedding, model) {
|
|
1995
|
+
try {
|
|
1996
|
+
const insertEmb = this.db.prepare(
|
|
1997
|
+
"INSERT INTO profile_embeddings (profile_id, profile_type, model) VALUES (?, ?, ?)"
|
|
1998
|
+
);
|
|
1999
|
+
const insertVec = this.db.prepare(`INSERT INTO ${this.vecTable} (embedding) VALUES (?)`);
|
|
2000
|
+
const doInsert = this.db.transaction(() => {
|
|
2001
|
+
insertVec.run(new Float32Array(embedding));
|
|
2002
|
+
insertEmb.run(profileId, type, model);
|
|
2003
|
+
});
|
|
2004
|
+
doInsert();
|
|
2005
|
+
return Promise.resolve({ ok: true, value: void 0 });
|
|
2006
|
+
} catch (error2) {
|
|
2007
|
+
return Promise.resolve({
|
|
2008
|
+
ok: false,
|
|
2009
|
+
error: error2 instanceof Error ? error2.message : String(error2)
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
saveVoiceProfileSync(profile) {
|
|
2014
|
+
this.db.prepare(
|
|
2015
|
+
`INSERT INTO voice_profiles (id, name, traits, summary, created_at)
|
|
2016
|
+
VALUES (?, ?, ?, ?, ?)
|
|
2017
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
2018
|
+
name = excluded.name,
|
|
2019
|
+
traits = excluded.traits,
|
|
2020
|
+
summary = excluded.summary`
|
|
2021
|
+
).run(
|
|
2022
|
+
profile.id,
|
|
2023
|
+
profile.name,
|
|
2024
|
+
JSON.stringify(profile.traits),
|
|
2025
|
+
profile.summary,
|
|
2026
|
+
profile.createdAt.toISOString()
|
|
2027
|
+
);
|
|
2028
|
+
return { ok: true, value: profile };
|
|
2029
|
+
}
|
|
2030
|
+
saveKnowledgeProfileSync(profile) {
|
|
2031
|
+
this.db.prepare(
|
|
2032
|
+
`INSERT INTO knowledge_profiles (id, domains, topics, summary, created_at)
|
|
2033
|
+
VALUES (?, ?, ?, ?, ?)
|
|
2034
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
2035
|
+
domains = excluded.domains,
|
|
2036
|
+
topics = excluded.topics,
|
|
2037
|
+
summary = excluded.summary`
|
|
2038
|
+
).run(
|
|
2039
|
+
profile.id,
|
|
2040
|
+
JSON.stringify(profile.domains),
|
|
2041
|
+
JSON.stringify(profile.topics),
|
|
2042
|
+
profile.summary,
|
|
2043
|
+
profile.createdAt.toISOString()
|
|
2044
|
+
);
|
|
2045
|
+
return { ok: true, value: profile };
|
|
2046
|
+
}
|
|
2047
|
+
savePublicationProfileSync(profile) {
|
|
2048
|
+
this.db.prepare(
|
|
2049
|
+
`INSERT INTO publication_profiles (id, themes, series, summary, created_at)
|
|
2050
|
+
VALUES (?, ?, ?, ?, ?)
|
|
2051
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
2052
|
+
themes = excluded.themes,
|
|
2053
|
+
series = excluded.series,
|
|
2054
|
+
summary = excluded.summary`
|
|
2055
|
+
).run(
|
|
2056
|
+
profile.id,
|
|
2057
|
+
JSON.stringify(profile.themes),
|
|
2058
|
+
profile.series ? JSON.stringify(profile.series) : null,
|
|
2059
|
+
profile.summary,
|
|
2060
|
+
profile.createdAt.toISOString()
|
|
2061
|
+
);
|
|
2062
|
+
return { ok: true, value: profile };
|
|
2063
|
+
}
|
|
2064
|
+
getProfileByTypeAndId(type, id) {
|
|
2065
|
+
const table = this.tableForType(type);
|
|
2066
|
+
const row = this.db.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(id);
|
|
2067
|
+
if (!row) return { ok: true, value: null };
|
|
2068
|
+
const schema = this.schemaForType(type);
|
|
2069
|
+
return parseRow(schema, row, `${type} profile`);
|
|
2070
|
+
}
|
|
2071
|
+
getLatestProfileByType(type) {
|
|
2072
|
+
const table = this.tableForType(type);
|
|
2073
|
+
const row = this.db.prepare(`SELECT * FROM ${table} ORDER BY created_at DESC LIMIT 1`).get();
|
|
2074
|
+
if (!row) return { ok: true, value: null };
|
|
2075
|
+
const schema = this.schemaForType(type);
|
|
2076
|
+
return parseRow(schema, row, `${type} profile`);
|
|
2077
|
+
}
|
|
2078
|
+
getProfileStatus() {
|
|
2079
|
+
try {
|
|
2080
|
+
const voiceRow = this.db.prepare("SELECT id, summary FROM voice_profiles ORDER BY created_at DESC LIMIT 1").get();
|
|
2081
|
+
const knowledgeRow = this.db.prepare("SELECT id, domains FROM knowledge_profiles ORDER BY created_at DESC LIMIT 1").get();
|
|
2082
|
+
const pubRow = this.db.prepare("SELECT id, themes FROM publication_profiles ORDER BY created_at DESC LIMIT 1").get();
|
|
2083
|
+
return Promise.resolve({
|
|
2084
|
+
ok: true,
|
|
2085
|
+
value: {
|
|
2086
|
+
voice: voiceRow ? { exists: true, id: voiceRow.id, summary: voiceRow.summary } : { exists: false },
|
|
2087
|
+
knowledge: knowledgeRow ? { exists: true, id: knowledgeRow.id, domains: knowledgeRow.domains } : { exists: false },
|
|
2088
|
+
publication: pubRow ? { exists: true, id: pubRow.id, themes: pubRow.themes } : { exists: false }
|
|
2089
|
+
}
|
|
2090
|
+
});
|
|
2091
|
+
} catch (error2) {
|
|
2092
|
+
return Promise.resolve({
|
|
2093
|
+
ok: false,
|
|
2094
|
+
error: error2 instanceof Error ? error2.message : String(error2)
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
tableForType(type) {
|
|
2099
|
+
switch (type) {
|
|
2100
|
+
case "voice":
|
|
2101
|
+
return "voice_profiles";
|
|
2102
|
+
case "knowledge":
|
|
2103
|
+
return "knowledge_profiles";
|
|
2104
|
+
case "publication":
|
|
2105
|
+
return "publication_profiles";
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
schemaForType(type) {
|
|
2109
|
+
switch (type) {
|
|
2110
|
+
case "voice":
|
|
2111
|
+
return voiceProfileRowSchema;
|
|
2112
|
+
case "knowledge":
|
|
2113
|
+
return knowledgeProfileRowSchema;
|
|
2114
|
+
case "publication":
|
|
2115
|
+
return publicationProfileRowSchema;
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
};
|
|
2119
|
+
|
|
2120
|
+
// src/infrastructure/persistence/repositories/source-schemas.ts
|
|
2121
|
+
import { z as z6 } from "zod";
|
|
2122
|
+
var sourceReferenceSchema = z6.object({
|
|
2123
|
+
id: z6.string(),
|
|
2124
|
+
type: z6.enum(["voice", "knowledge", "publication"]),
|
|
2125
|
+
source_path: z6.string(),
|
|
2126
|
+
content: z6.string(),
|
|
2127
|
+
checksum: z6.string(),
|
|
2128
|
+
chunk_index: z6.number(),
|
|
2129
|
+
created_at: z6.string()
|
|
2130
|
+
}).transform((row) => ({
|
|
2131
|
+
id: row.id,
|
|
2132
|
+
type: row.type,
|
|
2133
|
+
sourcePath: row.source_path,
|
|
2134
|
+
content: row.content,
|
|
2135
|
+
checksum: row.checksum,
|
|
2136
|
+
chunkIndex: row.chunk_index,
|
|
2137
|
+
createdAt: new Date(row.created_at)
|
|
2138
|
+
}));
|
|
2139
|
+
|
|
2140
|
+
// src/infrastructure/persistence/repositories/source-repository.ts
|
|
2141
|
+
function saveSources(db3, sources) {
|
|
2142
|
+
const insert = db3.prepare(
|
|
2143
|
+
`INSERT OR IGNORE INTO sources (id, type, source_path, content, checksum, chunk_index, created_at)
|
|
2144
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
2145
|
+
);
|
|
2146
|
+
const insertMany = db3.transaction((items) => {
|
|
2147
|
+
for (const s of items) {
|
|
2148
|
+
insert.run(
|
|
2149
|
+
s.id,
|
|
2150
|
+
s.type,
|
|
2151
|
+
s.sourcePath,
|
|
2152
|
+
s.content,
|
|
2153
|
+
s.checksum,
|
|
2154
|
+
s.chunkIndex,
|
|
2155
|
+
s.createdAt.toISOString()
|
|
2156
|
+
);
|
|
2157
|
+
}
|
|
2158
|
+
});
|
|
2159
|
+
insertMany(sources);
|
|
2160
|
+
}
|
|
2161
|
+
function getSourcesByType(db3, type) {
|
|
2162
|
+
const rows = db3.prepare("SELECT * FROM sources WHERE type = ? ORDER BY created_at").all(type);
|
|
2163
|
+
const mapped = [];
|
|
2164
|
+
for (const row of rows) {
|
|
2165
|
+
const parsed = sourceReferenceSchema.safeParse(row);
|
|
2166
|
+
if (!parsed.success) {
|
|
2167
|
+
return { ok: false, error: `Invalid source row: ${parsed.error.message}` };
|
|
2168
|
+
}
|
|
2169
|
+
mapped.push(parsed.data);
|
|
2170
|
+
}
|
|
2171
|
+
return { ok: true, value: mapped };
|
|
2172
|
+
}
|
|
2173
|
+
function getSourceCountByType(db3, type) {
|
|
2174
|
+
const row = db3.prepare("SELECT COUNT(*) as count FROM sources WHERE type = ?").get(type);
|
|
2175
|
+
return row.count;
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
// src/infrastructure/persistence/repositories/sqlite-source-repository.ts
|
|
2179
|
+
var SqliteSourceRepository = class {
|
|
2180
|
+
constructor(db3, embeddingTableSuffix = "openai") {
|
|
2181
|
+
this.db = db3;
|
|
2182
|
+
this.vecTable = `vec_sources_${embeddingTableSuffix}`;
|
|
2183
|
+
}
|
|
2184
|
+
db;
|
|
2185
|
+
vecTable;
|
|
2186
|
+
saveSources(sources) {
|
|
2187
|
+
saveSources(this.db, sources);
|
|
2188
|
+
return Promise.resolve({ ok: true, value: void 0 });
|
|
2189
|
+
}
|
|
2190
|
+
getSourcesByType(type) {
|
|
2191
|
+
return Promise.resolve(getSourcesByType(this.db, type));
|
|
2192
|
+
}
|
|
2193
|
+
getSourceCountByType(type) {
|
|
2194
|
+
return Promise.resolve({ ok: true, value: getSourceCountByType(this.db, type) });
|
|
2195
|
+
}
|
|
2196
|
+
searchByVector(embedding, k) {
|
|
2197
|
+
try {
|
|
2198
|
+
const rows = this.db.prepare(
|
|
2199
|
+
`SELECT v.rowid, s.id, s.source_path, s.content, v.distance
|
|
2200
|
+
FROM ${this.vecTable} v
|
|
2201
|
+
JOIN sources s ON v.rowid = s.rowid
|
|
2202
|
+
WHERE v.embedding MATCH ? AND k = ?
|
|
2203
|
+
ORDER BY v.distance`
|
|
2204
|
+
).all(new Float32Array(embedding), k);
|
|
2205
|
+
const results = rows.map((r) => ({
|
|
2206
|
+
id: r.id,
|
|
2207
|
+
sourcePath: r.source_path,
|
|
2208
|
+
content: r.content,
|
|
2209
|
+
distance: r.distance
|
|
2210
|
+
}));
|
|
2211
|
+
return Promise.resolve({ ok: true, value: results });
|
|
2212
|
+
} catch (error2) {
|
|
2213
|
+
return Promise.resolve({ ok: false, error: error2 instanceof Error ? error2.message : String(error2) });
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
isAlreadyImported(sourcePath, checksum) {
|
|
2217
|
+
try {
|
|
2218
|
+
const row = this.db.prepare("SELECT 1 FROM import_log WHERE source_path = ? AND checksum = ?").get(sourcePath, checksum);
|
|
2219
|
+
return Promise.resolve({ ok: true, value: !!row });
|
|
2220
|
+
} catch {
|
|
2221
|
+
return Promise.resolve({ ok: true, value: false });
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
logImport(sourcePath, checksum, type) {
|
|
2225
|
+
try {
|
|
2226
|
+
this.db.prepare("INSERT OR IGNORE INTO import_log (source_path, checksum, type) VALUES (?, ?, ?)").run(sourcePath, checksum, type);
|
|
2227
|
+
return Promise.resolve({ ok: true, value: void 0 });
|
|
2228
|
+
} catch (error2) {
|
|
2229
|
+
return Promise.resolve({
|
|
2230
|
+
ok: false,
|
|
2231
|
+
error: `Failed to log import: ${error2 instanceof Error ? error2.message : String(error2)}`
|
|
2232
|
+
});
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
saveSourceEmbedding(sourceId, embedding, model) {
|
|
2236
|
+
try {
|
|
2237
|
+
const insertVec = this.db.prepare(`INSERT INTO ${this.vecTable} (embedding) VALUES (?)`);
|
|
2238
|
+
const insertEmb = this.db.prepare(
|
|
2239
|
+
"INSERT INTO source_embeddings (source_id, model) VALUES (?, ?)"
|
|
2240
|
+
);
|
|
2241
|
+
const doInsert = this.db.transaction(() => {
|
|
2242
|
+
insertVec.run(new Float32Array(embedding));
|
|
2243
|
+
insertEmb.run(sourceId, model);
|
|
2244
|
+
});
|
|
2245
|
+
doInsert();
|
|
2246
|
+
return Promise.resolve({ ok: true, value: void 0 });
|
|
2247
|
+
} catch (error2) {
|
|
2248
|
+
return Promise.resolve({ ok: false, error: error2 instanceof Error ? error2.message : String(error2) });
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
};
|
|
2252
|
+
|
|
2253
|
+
// src/infrastructure/persistence/repositories/sqlite-series-repository.ts
|
|
2254
|
+
import { z as z7 } from "zod";
|
|
2255
|
+
var seriesRowSchema = z7.object({
|
|
2256
|
+
id: z7.string(),
|
|
2257
|
+
name: z7.string(),
|
|
2258
|
+
description: z7.string().nullable(),
|
|
2259
|
+
created_at: z7.string(),
|
|
2260
|
+
updated_at: z7.string()
|
|
2261
|
+
}).transform((row) => ({
|
|
2262
|
+
id: row.id,
|
|
2263
|
+
name: row.name,
|
|
2264
|
+
description: row.description,
|
|
2265
|
+
articleIds: [],
|
|
2266
|
+
createdAt: new Date(row.created_at),
|
|
2267
|
+
updatedAt: new Date(row.updated_at)
|
|
2268
|
+
}));
|
|
2269
|
+
var SqliteSeriesRepository = class {
|
|
2270
|
+
constructor(db3) {
|
|
2271
|
+
this.db = db3;
|
|
2272
|
+
}
|
|
2273
|
+
db;
|
|
2274
|
+
save(series) {
|
|
2275
|
+
this.db.prepare(
|
|
2276
|
+
`INSERT INTO series (id, name, description, created_at, updated_at)
|
|
2277
|
+
VALUES (?, ?, ?, ?, ?)
|
|
2278
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
2279
|
+
name = excluded.name,
|
|
2280
|
+
description = excluded.description,
|
|
2281
|
+
updated_at = excluded.updated_at`
|
|
2282
|
+
).run(
|
|
2283
|
+
series.id,
|
|
2284
|
+
series.name,
|
|
2285
|
+
series.description,
|
|
2286
|
+
series.createdAt.toISOString(),
|
|
2287
|
+
series.updatedAt.toISOString()
|
|
2288
|
+
);
|
|
2289
|
+
return Promise.resolve({ ok: true, value: series });
|
|
2290
|
+
}
|
|
2291
|
+
get(id) {
|
|
2292
|
+
const row = this.db.prepare("SELECT * FROM series WHERE id = ?").get(id);
|
|
2293
|
+
if (!row) return Promise.resolve({ ok: true, value: null });
|
|
2294
|
+
const parsed = seriesRowSchema.safeParse(row);
|
|
2295
|
+
if (!parsed.success) return Promise.resolve({ ok: false, error: `Invalid series row: ${parsed.error.message}` });
|
|
2296
|
+
const articleRows = this.db.prepare("SELECT article_id FROM series_articles WHERE series_id = ? ORDER BY position").all(id);
|
|
2297
|
+
return Promise.resolve({
|
|
2298
|
+
ok: true,
|
|
2299
|
+
value: { ...parsed.data, articleIds: articleRows.map((r) => r.article_id) }
|
|
2300
|
+
});
|
|
2301
|
+
}
|
|
2302
|
+
list() {
|
|
2303
|
+
const rows = this.db.prepare("SELECT * FROM series ORDER BY created_at DESC").all();
|
|
2304
|
+
const seriesList = [];
|
|
2305
|
+
for (const row of rows) {
|
|
2306
|
+
const parsed = seriesRowSchema.safeParse(row);
|
|
2307
|
+
if (!parsed.success) continue;
|
|
2308
|
+
const articleRows = this.db.prepare("SELECT article_id FROM series_articles WHERE series_id = ? ORDER BY position").all(parsed.data.id);
|
|
2309
|
+
seriesList.push({ ...parsed.data, articleIds: articleRows.map((r) => r.article_id) });
|
|
2310
|
+
}
|
|
2311
|
+
return Promise.resolve({ ok: true, value: seriesList });
|
|
2312
|
+
}
|
|
2313
|
+
addArticle(seriesId, articleId) {
|
|
2314
|
+
const maxPos = this.db.prepare("SELECT COALESCE(MAX(position), -1) + 1 as next_pos FROM series_articles WHERE series_id = ?").get(seriesId);
|
|
2315
|
+
this.db.prepare(
|
|
2316
|
+
"INSERT OR IGNORE INTO series_articles (series_id, article_id, position) VALUES (?, ?, ?)"
|
|
2317
|
+
).run(seriesId, articleId, maxPos.next_pos);
|
|
2318
|
+
this.db.prepare(
|
|
2319
|
+
"UPDATE series SET updated_at = ? WHERE id = ?"
|
|
2320
|
+
).run((/* @__PURE__ */ new Date()).toISOString(), seriesId);
|
|
2321
|
+
return Promise.resolve({ ok: true, value: void 0 });
|
|
2322
|
+
}
|
|
2323
|
+
removeArticle(seriesId, articleId) {
|
|
2324
|
+
this.db.prepare("DELETE FROM series_articles WHERE series_id = ? AND article_id = ?").run(seriesId, articleId);
|
|
2325
|
+
this.db.prepare(
|
|
2326
|
+
"UPDATE series SET updated_at = ? WHERE id = ?"
|
|
2327
|
+
).run((/* @__PURE__ */ new Date()).toISOString(), seriesId);
|
|
2328
|
+
return Promise.resolve({ ok: true, value: void 0 });
|
|
2329
|
+
}
|
|
2330
|
+
};
|
|
2331
|
+
|
|
2332
|
+
// src/infrastructure/composition-root.ts
|
|
2333
|
+
function providerKey() {
|
|
2334
|
+
return getActiveEmbeddingProviderKey();
|
|
2335
|
+
}
|
|
2336
|
+
function db2() {
|
|
2337
|
+
return getDatabase();
|
|
2338
|
+
}
|
|
2339
|
+
function ai() {
|
|
2340
|
+
return new OpenAiAiService();
|
|
2341
|
+
}
|
|
2342
|
+
function createResearchUseCase() {
|
|
2343
|
+
const key = providerKey();
|
|
2344
|
+
return new ResearchUseCase(
|
|
2345
|
+
ai(),
|
|
2346
|
+
new SqliteResearchRepository(db2(), key),
|
|
2347
|
+
new SqliteSourceRepository(db2(), key),
|
|
2348
|
+
logger
|
|
2349
|
+
);
|
|
2350
|
+
}
|
|
2351
|
+
function createGenerateArticleUseCase() {
|
|
2352
|
+
return new GenerateArticleUseCase(
|
|
2353
|
+
ai(),
|
|
2354
|
+
new SqliteArticleRepository(db2()),
|
|
2355
|
+
new SqliteProfileRepository(db2()),
|
|
2356
|
+
new SqliteResearchRepository(db2()),
|
|
2357
|
+
logger
|
|
2358
|
+
);
|
|
2359
|
+
}
|
|
2360
|
+
function createGenerateProfilesUseCase() {
|
|
2361
|
+
const key = providerKey();
|
|
2362
|
+
return new GenerateProfilesUseCase(
|
|
2363
|
+
ai(),
|
|
2364
|
+
new SqliteProfileRepository(db2(), key),
|
|
2365
|
+
new SqliteSourceRepository(db2(), key),
|
|
2366
|
+
logger
|
|
2367
|
+
);
|
|
2368
|
+
}
|
|
2369
|
+
function createImportSourcesUseCase() {
|
|
2370
|
+
const key = providerKey();
|
|
2371
|
+
return new ImportSourcesUseCase(
|
|
2372
|
+
new SqliteSourceRepository(db2(), key),
|
|
2373
|
+
new FileSourceAdapter(),
|
|
2374
|
+
ai(),
|
|
2375
|
+
logger
|
|
2376
|
+
);
|
|
2377
|
+
}
|
|
2378
|
+
function createExportArticleUseCase() {
|
|
2379
|
+
return new ExportArticleUseCase(new SqliteArticleRepository(db2()));
|
|
2380
|
+
}
|
|
2381
|
+
function createExportSeriesUseCase() {
|
|
2382
|
+
return new ExportSeriesUseCase(
|
|
2383
|
+
new SqliteSeriesRepository(db2()),
|
|
2384
|
+
new SqliteArticleRepository(db2())
|
|
2385
|
+
);
|
|
2386
|
+
}
|
|
2387
|
+
function createSeriesUseCase() {
|
|
2388
|
+
return new SeriesUseCase(new SqliteSeriesRepository(db2()), logger);
|
|
2389
|
+
}
|
|
2390
|
+
function createArticleRepository() {
|
|
2391
|
+
return new SqliteArticleRepository(db2());
|
|
2392
|
+
}
|
|
2393
|
+
function createProfileRepository() {
|
|
2394
|
+
const key = providerKey();
|
|
2395
|
+
return new SqliteProfileRepository(db2(), key);
|
|
2396
|
+
}
|
|
2397
|
+
function createSourceRepository() {
|
|
2398
|
+
const key = providerKey();
|
|
2399
|
+
return new SqliteSourceRepository(db2(), key);
|
|
2400
|
+
}
|
|
2401
|
+
function createPublishArticleUseCase() {
|
|
2402
|
+
return new PublishArticleUseCase(
|
|
2403
|
+
new SqliteArticleRepository(db2()),
|
|
2404
|
+
new MediumHttpAdapter(logger),
|
|
2405
|
+
logger
|
|
2406
|
+
);
|
|
2407
|
+
}
|
|
2408
|
+
function createMediumAdapter() {
|
|
2409
|
+
return new MediumHttpAdapter(logger);
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
// src/cli/ui.ts
|
|
2413
|
+
import ora from "ora";
|
|
2414
|
+
var useColor = process.stderr.isTTY && process.env.NO_COLOR === void 0;
|
|
2415
|
+
var codes = {
|
|
2416
|
+
reset: "\x1B[0m",
|
|
2417
|
+
bold: "\x1B[1m",
|
|
2418
|
+
dim: "\x1B[2m",
|
|
2419
|
+
red: "\x1B[31m",
|
|
2420
|
+
green: "\x1B[32m",
|
|
2421
|
+
yellow: "\x1B[33m",
|
|
2422
|
+
blue: "\x1B[34m",
|
|
2423
|
+
magenta: "\x1B[35m",
|
|
2424
|
+
cyan: "\x1B[36m",
|
|
2425
|
+
gray: "\x1B[90m"
|
|
2426
|
+
};
|
|
2427
|
+
function paint(value, color2) {
|
|
2428
|
+
if (!useColor) return value;
|
|
2429
|
+
return `${codes[color2]}${value}${codes.reset}`;
|
|
2430
|
+
}
|
|
2431
|
+
function bold(value) {
|
|
2432
|
+
if (!useColor) return value;
|
|
2433
|
+
return `${codes.bold}${value}${codes.reset}`;
|
|
2434
|
+
}
|
|
2435
|
+
var color = {
|
|
2436
|
+
cyan: (value) => paint(value, "cyan"),
|
|
2437
|
+
green: (value) => paint(value, "green"),
|
|
2438
|
+
yellow: (value) => paint(value, "yellow"),
|
|
2439
|
+
red: (value) => paint(value, "red"),
|
|
2440
|
+
blue: (value) => paint(value, "blue"),
|
|
2441
|
+
magenta: (value) => paint(value, "magenta"),
|
|
2442
|
+
gray: (value) => paint(value, "gray"),
|
|
2443
|
+
dim: (value) => paint(value, "dim"),
|
|
2444
|
+
bold
|
|
2445
|
+
};
|
|
2446
|
+
function writeStderr(value) {
|
|
2447
|
+
process.stderr.write(value + "\n");
|
|
2448
|
+
}
|
|
2449
|
+
function blank() {
|
|
2450
|
+
writeStderr("");
|
|
2451
|
+
}
|
|
2452
|
+
function heading(value) {
|
|
2453
|
+
writeStderr(color.cyan(color.bold(value)));
|
|
2454
|
+
}
|
|
2455
|
+
function section(value) {
|
|
2456
|
+
writeStderr(color.blue(color.bold(value)));
|
|
2457
|
+
}
|
|
2458
|
+
function step(value) {
|
|
2459
|
+
writeStderr(`${color.cyan(">")} ${value}`);
|
|
2460
|
+
}
|
|
2461
|
+
function success(value) {
|
|
2462
|
+
writeStderr(`${color.green("[ok]")} ${value}`);
|
|
2463
|
+
}
|
|
2464
|
+
function warn(value) {
|
|
2465
|
+
writeStderr(`${color.yellow("[warn]")} ${value}`);
|
|
2466
|
+
}
|
|
2467
|
+
function error(value) {
|
|
2468
|
+
writeStderr(`${color.red("[error]")} ${value}`);
|
|
2469
|
+
}
|
|
2470
|
+
function info(value) {
|
|
2471
|
+
writeStderr(`${color.blue("[info]")} ${value}`);
|
|
2472
|
+
}
|
|
2473
|
+
function empty(value) {
|
|
2474
|
+
writeStderr(color.dim(value));
|
|
2475
|
+
}
|
|
2476
|
+
function meta(label, value) {
|
|
2477
|
+
writeStderr(` ${color.gray(label.padEnd(16))} ${value}`);
|
|
2478
|
+
}
|
|
2479
|
+
function item(id, value) {
|
|
2480
|
+
writeStderr(` ${color.gray(id)} ${value}`);
|
|
2481
|
+
}
|
|
2482
|
+
function divider() {
|
|
2483
|
+
writeStderr(color.dim("\u2500".repeat(48)));
|
|
2484
|
+
}
|
|
2485
|
+
function output(value) {
|
|
2486
|
+
process.stdout.write(value + "\n");
|
|
2487
|
+
}
|
|
2488
|
+
function article(value) {
|
|
2489
|
+
writeStderr(value);
|
|
2490
|
+
}
|
|
2491
|
+
function timer(start) {
|
|
2492
|
+
const elapsed = process.hrtime(start);
|
|
2493
|
+
const ms = elapsed[0] * 1e3 + elapsed[1] / 1e6;
|
|
2494
|
+
if (ms < 1e3) return `${ms.toFixed(1)}ms`;
|
|
2495
|
+
if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
|
|
2496
|
+
const min = Math.floor(ms / 6e4);
|
|
2497
|
+
const sec = (ms % 6e4 / 1e3).toFixed(0);
|
|
2498
|
+
return `${min}m ${sec}s`;
|
|
2499
|
+
}
|
|
2500
|
+
function spinner(initialText) {
|
|
2501
|
+
let instance = null;
|
|
2502
|
+
return {
|
|
2503
|
+
start(text) {
|
|
2504
|
+
if (!instance) {
|
|
2505
|
+
instance = ora({ text: text ?? initialText, color: "cyan" }).start();
|
|
2506
|
+
}
|
|
2507
|
+
},
|
|
2508
|
+
stop(finalText) {
|
|
2509
|
+
if (instance) {
|
|
2510
|
+
instance.stop();
|
|
2511
|
+
if (finalText) writeStderr(` ${color.green(finalText)}`);
|
|
2512
|
+
instance = null;
|
|
2513
|
+
}
|
|
2514
|
+
},
|
|
2515
|
+
setText(text) {
|
|
2516
|
+
if (instance) instance.text = text;
|
|
2517
|
+
},
|
|
2518
|
+
get isSpinning() {
|
|
2519
|
+
return instance !== null;
|
|
2520
|
+
}
|
|
2521
|
+
};
|
|
2522
|
+
}
|
|
2523
|
+
function summary(entries) {
|
|
2524
|
+
const keys = Object.keys(entries);
|
|
2525
|
+
const labelWidth = Math.max(...keys.map((k) => k.length));
|
|
2526
|
+
writeStderr(color.bold(color.cyan("Summary")));
|
|
2527
|
+
for (const [label, value] of Object.entries(entries)) {
|
|
2528
|
+
writeStderr(` ${color.gray(label.padEnd(labelWidth + 2))} ${value}`);
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
function nextSteps(steps) {
|
|
2532
|
+
section("Next steps");
|
|
2533
|
+
for (const command of steps) {
|
|
2534
|
+
writeStderr(` ${color.magenta(command)}`);
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
// src/cli/error-handler.ts
|
|
2539
|
+
async function withCliError(logger2, label, fn) {
|
|
2540
|
+
try {
|
|
2541
|
+
return await fn();
|
|
2542
|
+
} catch (error2) {
|
|
2543
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
2544
|
+
logger2.error({ error: message }, `${label} failed`);
|
|
2545
|
+
error(`${label} failed: ${message}`);
|
|
2546
|
+
process.exit(1);
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
// src/cli/commands/article.command.ts
|
|
2551
|
+
function registerArticleCommand(program2) {
|
|
2552
|
+
program2.command("generate_article").description("Generate an article using your voice profile (--topic, --research)").option("-t, --topic <topic>", "Topic to write about").option("-r, --research <id>", "Research note ID to use as source").action(async (options) => {
|
|
2553
|
+
await withCliError(logger, "Article generation", async () => {
|
|
2554
|
+
if (!options.topic && !options.research) {
|
|
2555
|
+
error("Provide a topic (--topic) or research ID (--research).");
|
|
2556
|
+
process.exit(1);
|
|
2557
|
+
}
|
|
2558
|
+
if (options.topic && options.topic.length > 500) {
|
|
2559
|
+
error("Topic must be under 500 characters.");
|
|
2560
|
+
process.exit(1);
|
|
2561
|
+
}
|
|
2562
|
+
const useCase = createGenerateArticleUseCase();
|
|
2563
|
+
heading("Generating article");
|
|
2564
|
+
if (options.topic) meta("Topic", options.topic);
|
|
2565
|
+
if (options.research) meta("Research ID", options.research);
|
|
2566
|
+
blank();
|
|
2567
|
+
const spin = spinner("Drafting article...");
|
|
2568
|
+
const startTime = process.hrtime();
|
|
2569
|
+
spin.start();
|
|
2570
|
+
const result = await useCase.execute({
|
|
2571
|
+
topic: options.topic,
|
|
2572
|
+
researchId: options.research
|
|
2573
|
+
});
|
|
2574
|
+
spin.stop();
|
|
2575
|
+
const duration = timer(startTime);
|
|
2576
|
+
if (!result.ok) {
|
|
2577
|
+
error(`Generation failed: ${result.error}`);
|
|
2578
|
+
process.exit(1);
|
|
2579
|
+
}
|
|
2580
|
+
success("Article generated.");
|
|
2581
|
+
meta("Title", result.value.title);
|
|
2582
|
+
meta("Words", result.value.wordCount);
|
|
2583
|
+
meta("ID", result.value.id);
|
|
2584
|
+
meta("Duration", duration);
|
|
2585
|
+
blank();
|
|
2586
|
+
divider();
|
|
2587
|
+
output(result.value.content);
|
|
2588
|
+
divider();
|
|
2589
|
+
closeDatabase();
|
|
2590
|
+
});
|
|
2591
|
+
});
|
|
2592
|
+
program2.command("list_articles").description("List generated articles").action(async () => {
|
|
2593
|
+
await withCliError(logger, "Article list", async () => {
|
|
2594
|
+
const repo = createArticleRepository();
|
|
2595
|
+
const result = await repo.list();
|
|
2596
|
+
if (!result.ok) {
|
|
2597
|
+
error(`Failed to list articles: ${result.error}`);
|
|
2598
|
+
process.exit(1);
|
|
2599
|
+
}
|
|
2600
|
+
if (result.value.length === 0) {
|
|
2601
|
+
empty("No articles generated yet.");
|
|
2602
|
+
nextSteps(['thoth generate_article --topic "<topic>"']);
|
|
2603
|
+
closeDatabase();
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2606
|
+
heading("Articles");
|
|
2607
|
+
for (const article2 of result.value) {
|
|
2608
|
+
item(article2.id, `${article2.title} (${article2.wordCount} words, ${article2.status})`);
|
|
2609
|
+
}
|
|
2610
|
+
closeDatabase();
|
|
2611
|
+
});
|
|
2612
|
+
});
|
|
2613
|
+
program2.command("get_article").description("Show an article by ID").argument("<id>", "Article ID").action(async (id) => {
|
|
2614
|
+
await withCliError(logger, "Article get", async () => {
|
|
2615
|
+
const repo = createArticleRepository();
|
|
2616
|
+
const result = await repo.get(id);
|
|
2617
|
+
if (!result.ok) {
|
|
2618
|
+
error(`Failed to get article: ${result.error}`);
|
|
2619
|
+
process.exit(1);
|
|
2620
|
+
}
|
|
2621
|
+
if (!result.value) {
|
|
2622
|
+
error(`Article not found: ${id}`);
|
|
2623
|
+
process.exit(1);
|
|
2624
|
+
}
|
|
2625
|
+
heading(result.value.title);
|
|
2626
|
+
meta("Words", result.value.wordCount);
|
|
2627
|
+
meta("Status", result.value.status);
|
|
2628
|
+
meta("Created", result.value.createdAt.toLocaleString());
|
|
2629
|
+
blank();
|
|
2630
|
+
divider();
|
|
2631
|
+
article(result.value.content);
|
|
2632
|
+
divider();
|
|
2633
|
+
closeDatabase();
|
|
2634
|
+
});
|
|
2635
|
+
});
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
// src/cli/commands/export.command.ts
|
|
2639
|
+
import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync3, existsSync as existsSync3 } from "fs";
|
|
2640
|
+
import { join as join5, resolve as resolve2 } from "path";
|
|
2641
|
+
import { homedir as homedir5 } from "os";
|
|
2642
|
+
var VALID_ARTICLE_FORMATS = ["md", "html", "txt"];
|
|
2643
|
+
var VALID_SERIES_FORMATS = ["md", "html", "rss"];
|
|
2644
|
+
function defaultOutputDir(type) {
|
|
2645
|
+
const config = loadConfig();
|
|
2646
|
+
if (config.export?.outputDir) return join5(config.export.outputDir, type);
|
|
2647
|
+
return join5(homedir5(), "Documents", "Thoth", type);
|
|
2648
|
+
}
|
|
2649
|
+
function registerExportCommand(program2) {
|
|
2650
|
+
program2.command("export_article").description("Export an article (--format md|html|txt, --output <path>)").argument("<id>", "Article ID").option("-f, --format <format>", "Output format (md, html, txt)", "md").option("-o, --output <path>", "Output directory", defaultOutputDir("singles")).action(async (id, options) => {
|
|
2651
|
+
await withCliError(logger, "Export article", async () => {
|
|
2652
|
+
if (!VALID_ARTICLE_FORMATS.includes(options.format)) {
|
|
2653
|
+
error(`Invalid format. Choose one of: ${VALID_ARTICLE_FORMATS.join(", ")}`);
|
|
2654
|
+
process.exit(1);
|
|
2655
|
+
}
|
|
2656
|
+
const useCase = createExportArticleUseCase();
|
|
2657
|
+
const result = await useCase.execute({
|
|
2658
|
+
articleId: id,
|
|
2659
|
+
format: options.format
|
|
2660
|
+
});
|
|
2661
|
+
if (!result.ok) {
|
|
2662
|
+
error(`Export failed: ${result.error}`);
|
|
2663
|
+
process.exit(1);
|
|
2664
|
+
}
|
|
2665
|
+
const outDir = resolve2(options.output);
|
|
2666
|
+
if (!existsSync3(outDir)) {
|
|
2667
|
+
mkdirSync3(outDir, { recursive: true });
|
|
2668
|
+
}
|
|
2669
|
+
const outPath = join5(outDir, result.value.filename);
|
|
2670
|
+
writeFileSync2(outPath, result.value.content, "utf-8");
|
|
2671
|
+
blank();
|
|
2672
|
+
success("Article exported.");
|
|
2673
|
+
summary({
|
|
2674
|
+
Title: result.value.filename.replace(/\.[^.]+$/, ""),
|
|
2675
|
+
Format: options.format,
|
|
2676
|
+
Path: outPath
|
|
2677
|
+
});
|
|
2678
|
+
closeDatabase();
|
|
2679
|
+
});
|
|
2680
|
+
});
|
|
2681
|
+
program2.command("export_series").description("Export a series (--format md|html|rss, --output <path>)").argument("<id>", "Series ID").option("-f, --format <format>", "Output format (md, html, rss)", "md").option("-o, --output <path>", "Output directory", defaultOutputDir("series")).action(async (id, options) => {
|
|
2682
|
+
await withCliError(logger, "Export series", async () => {
|
|
2683
|
+
if (!VALID_SERIES_FORMATS.includes(options.format)) {
|
|
2684
|
+
error(`Invalid format. Choose one of: ${VALID_SERIES_FORMATS.join(", ")}`);
|
|
2685
|
+
process.exit(1);
|
|
2686
|
+
}
|
|
2687
|
+
const useCase = createExportSeriesUseCase();
|
|
2688
|
+
const result = await useCase.execute({
|
|
2689
|
+
seriesId: id,
|
|
2690
|
+
format: options.format
|
|
2691
|
+
});
|
|
2692
|
+
if (!result.ok) {
|
|
2693
|
+
error(`Export failed: ${result.error}`);
|
|
2694
|
+
process.exit(1);
|
|
2695
|
+
}
|
|
2696
|
+
const outDir = resolve2(options.output);
|
|
2697
|
+
if (!existsSync3(outDir)) {
|
|
2698
|
+
mkdirSync3(outDir, { recursive: true });
|
|
2699
|
+
}
|
|
2700
|
+
const outPath = join5(outDir, result.value.filename);
|
|
2701
|
+
writeFileSync2(outPath, result.value.content, "utf-8");
|
|
2702
|
+
blank();
|
|
2703
|
+
success("Series exported.");
|
|
2704
|
+
summary({
|
|
2705
|
+
Title: result.value.filename.replace(/\.[^.]+$/, ""),
|
|
2706
|
+
Format: options.format,
|
|
2707
|
+
Path: outPath
|
|
2708
|
+
});
|
|
2709
|
+
closeDatabase();
|
|
2710
|
+
});
|
|
2711
|
+
});
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
// src/cli/commands/import.command.ts
|
|
2715
|
+
import cliProgress from "cli-progress";
|
|
2716
|
+
function registerImportCommand(program2) {
|
|
2717
|
+
program2.command("import_voice").description("Import voice sources (journals, essays, blog posts)").argument("<path>", "File or directory path").action(async (sourcePath) => {
|
|
2718
|
+
await runImport(sourcePath, "voice");
|
|
2719
|
+
});
|
|
2720
|
+
program2.command("import_knowledge").description("Import knowledge sources (technical notes, docs, repos)").argument("<path>", "File or directory path").action(async (sourcePath) => {
|
|
2721
|
+
await runImport(sourcePath, "knowledge");
|
|
2722
|
+
});
|
|
2723
|
+
program2.command("import_publications").description("Import publication sources (articles, series)").argument("<path>", "File or directory path").action(async (sourcePath) => {
|
|
2724
|
+
await runImport(sourcePath, "publication");
|
|
2725
|
+
});
|
|
2726
|
+
}
|
|
2727
|
+
async function runImport(sourcePath, type) {
|
|
2728
|
+
await withCliError(logger, "Import", async () => {
|
|
2729
|
+
const useCase = createImportSourcesUseCase();
|
|
2730
|
+
const startTime = process.hrtime();
|
|
2731
|
+
heading(`Importing ${type} sources`);
|
|
2732
|
+
meta("Path", sourcePath);
|
|
2733
|
+
const importResult = await useCase.execute(sourcePath, type);
|
|
2734
|
+
if (!importResult.ok) {
|
|
2735
|
+
error(`Import failed: ${importResult.error}`);
|
|
2736
|
+
closeDatabase();
|
|
2737
|
+
return;
|
|
2738
|
+
}
|
|
2739
|
+
const count = importResult.value;
|
|
2740
|
+
success(`Imported ${count} chunks.`);
|
|
2741
|
+
const embedBar = new cliProgress.SingleBar({
|
|
2742
|
+
format: ` ${color.cyan("Embedding")} [{bar}] {percentage}% | {value}/{total} chunks | {duration_formatted}`,
|
|
2743
|
+
barCompleteChar: "\u2588",
|
|
2744
|
+
barIncompleteChar: "\u2591",
|
|
2745
|
+
hideCursor: true
|
|
2746
|
+
});
|
|
2747
|
+
let embedOk = true;
|
|
2748
|
+
let embedFailed = 0;
|
|
2749
|
+
let embedTotal = 0;
|
|
2750
|
+
if (count > 0) {
|
|
2751
|
+
step("Generating embeddings...");
|
|
2752
|
+
try {
|
|
2753
|
+
embedBar.start(count, 0);
|
|
2754
|
+
const embedResult = await useCase.generateEmbeddingsForType(type, (current) => {
|
|
2755
|
+
embedBar.update(current);
|
|
2756
|
+
});
|
|
2757
|
+
embedBar.stop();
|
|
2758
|
+
if (!embedResult.ok) {
|
|
2759
|
+
warn(`Embedding failed: ${embedResult.error}`);
|
|
2760
|
+
embedOk = false;
|
|
2761
|
+
} else {
|
|
2762
|
+
embedFailed = embedResult.value.failed;
|
|
2763
|
+
embedTotal = embedResult.value.total;
|
|
2764
|
+
if (embedFailed > 0) {
|
|
2765
|
+
warn(`${embedFailed} of ${embedTotal} embeddings failed.`);
|
|
2766
|
+
if (embedFailed === embedTotal) {
|
|
2767
|
+
warn("Check that your embedding provider is configured correctly.");
|
|
2768
|
+
}
|
|
2769
|
+
} else {
|
|
2770
|
+
success("Embeddings complete.");
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
} catch (error2) {
|
|
2774
|
+
embedBar.stop();
|
|
2775
|
+
warn("Embedding generation failed. You can retry by running import again.");
|
|
2776
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
2777
|
+
warn(message);
|
|
2778
|
+
embedOk = false;
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
closeDatabase();
|
|
2782
|
+
const duration = timer(startTime);
|
|
2783
|
+
blank();
|
|
2784
|
+
success("Import complete.");
|
|
2785
|
+
divider();
|
|
2786
|
+
summary({
|
|
2787
|
+
Type: type,
|
|
2788
|
+
"Chunks imported": String(count),
|
|
2789
|
+
Embeddings: count === 0 ? "none" : !embedOk ? "failed" : embedFailed === 0 ? "complete" : `${embedFailed}/${embedTotal} failed`,
|
|
2790
|
+
Duration: duration
|
|
2791
|
+
});
|
|
2792
|
+
blank();
|
|
2793
|
+
nextSteps(["thoth generate_profile Create identity profiles from imported sources"]);
|
|
2794
|
+
});
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
// src/cli/commands/init.command.ts
|
|
2798
|
+
function registerInitCommand(program2) {
|
|
2799
|
+
program2.command("init").description("Initialize Thoth \u2014 create database and run migrations").action(async () => {
|
|
2800
|
+
await withCliError(logger, "Init", async () => {
|
|
2801
|
+
logger.info("Initializing Thoth");
|
|
2802
|
+
const sourceRepo = createSourceRepository();
|
|
2803
|
+
const [voiceCount, knowledgeCount, pubCount] = await Promise.all([
|
|
2804
|
+
sourceRepo.getSourceCountByType("voice"),
|
|
2805
|
+
sourceRepo.getSourceCountByType("knowledge"),
|
|
2806
|
+
sourceRepo.getSourceCountByType("publication")
|
|
2807
|
+
]);
|
|
2808
|
+
const counts = {
|
|
2809
|
+
voice: voiceCount.ok ? voiceCount.value : 0,
|
|
2810
|
+
knowledge: knowledgeCount.ok ? knowledgeCount.value : 0,
|
|
2811
|
+
publication: pubCount.ok ? pubCount.value : 0
|
|
2812
|
+
};
|
|
2813
|
+
logger.info(
|
|
2814
|
+
{ dbPath: process.env.THOTH_DB_PATH ?? "~/.thoth/thoth.db", ...counts },
|
|
2815
|
+
"Thoth initialized successfully"
|
|
2816
|
+
);
|
|
2817
|
+
success("Thoth initialized.");
|
|
2818
|
+
meta("Database", "~/.thoth/thoth.db");
|
|
2819
|
+
meta(
|
|
2820
|
+
"Sources",
|
|
2821
|
+
`${counts.voice} voice, ${counts.knowledge} knowledge, ${counts.publication} publication`
|
|
2822
|
+
);
|
|
2823
|
+
blank();
|
|
2824
|
+
const isLocal = process.env.THOTH_LOCAL === "true";
|
|
2825
|
+
if (!isLocal) {
|
|
2826
|
+
section("Privacy notice");
|
|
2827
|
+
warn(" Thoth sends source content to external AI providers (OpenAI, Groq, Gemini)");
|
|
2828
|
+
warn(" for profile generation and embedding.");
|
|
2829
|
+
info("Run with --local to use only local AI (Ollama) and keep data on device.");
|
|
2830
|
+
blank();
|
|
2831
|
+
}
|
|
2832
|
+
nextSteps([
|
|
2833
|
+
"thoth import_voice <path> Import voice sources",
|
|
2834
|
+
"thoth import_knowledge <path> Import knowledge sources",
|
|
2835
|
+
"thoth import_publications <path> Import publication sources",
|
|
2836
|
+
"thoth generate_profile Generate identity profiles",
|
|
2837
|
+
'thoth research "<topic>" Research a topic using your knowledge',
|
|
2838
|
+
"thoth generate_article --topic Generate an article in your voice",
|
|
2839
|
+
"thoth create_series <name> Group articles into series"
|
|
2840
|
+
]);
|
|
2841
|
+
closeDatabase();
|
|
2842
|
+
});
|
|
2843
|
+
});
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
// src/cli/commands/profile.command.ts
|
|
2847
|
+
function preview(value) {
|
|
2848
|
+
if (typeof value !== "string") return "";
|
|
2849
|
+
return `${value.slice(0, 60)}...`;
|
|
2850
|
+
}
|
|
2851
|
+
function registerProfileCommand(program2) {
|
|
2852
|
+
program2.command("generate_profile").description("Generate identity profiles from imported sources").action(async () => {
|
|
2853
|
+
await withCliError(logger, "Profile generation", async () => {
|
|
2854
|
+
const useCase = createGenerateProfilesUseCase();
|
|
2855
|
+
const sourceRepo = createSourceRepository();
|
|
2856
|
+
const [voiceCount, knowledgeCount, pubCount] = await Promise.all([
|
|
2857
|
+
sourceRepo.getSourceCountByType("voice"),
|
|
2858
|
+
sourceRepo.getSourceCountByType("knowledge"),
|
|
2859
|
+
sourceRepo.getSourceCountByType("publication")
|
|
2860
|
+
]);
|
|
2861
|
+
const counts = {
|
|
2862
|
+
voice: voiceCount.ok ? voiceCount.value : 0,
|
|
2863
|
+
knowledge: knowledgeCount.ok ? knowledgeCount.value : 0,
|
|
2864
|
+
publication: pubCount.ok ? pubCount.value : 0
|
|
2865
|
+
};
|
|
2866
|
+
if (counts.voice === 0 && counts.knowledge === 0 && counts.publication === 0) {
|
|
2867
|
+
error("No sources imported.");
|
|
2868
|
+
nextSteps(["thoth import_voice <path> Import writing samples"]);
|
|
2869
|
+
process.exit(1);
|
|
2870
|
+
}
|
|
2871
|
+
heading("Generating identity profiles");
|
|
2872
|
+
meta("Voice chunks", counts.voice);
|
|
2873
|
+
meta("Knowledge chunks", counts.knowledge);
|
|
2874
|
+
meta("Publication chunks", counts.publication);
|
|
2875
|
+
blank();
|
|
2876
|
+
let generatedCount = 0;
|
|
2877
|
+
const failures = [];
|
|
2878
|
+
const startTime = process.hrtime();
|
|
2879
|
+
if (counts.voice > 0) {
|
|
2880
|
+
const spin = spinner("Generating voice profile...");
|
|
2881
|
+
spin.start();
|
|
2882
|
+
const result = await useCase.generateVoiceProfile();
|
|
2883
|
+
spin.stop();
|
|
2884
|
+
if (!result.ok) {
|
|
2885
|
+
error(`Voice profile failed: ${result.error}`);
|
|
2886
|
+
failures.push(`voice: ${result.error}`);
|
|
2887
|
+
} else {
|
|
2888
|
+
generatedCount += 1;
|
|
2889
|
+
success("Voice profile generated");
|
|
2890
|
+
meta("Summary", result.value.summary ?? "No summary");
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
if (counts.knowledge > 0) {
|
|
2894
|
+
const spin = spinner("Generating knowledge profile...");
|
|
2895
|
+
spin.start();
|
|
2896
|
+
const result = await useCase.generateKnowledgeProfile();
|
|
2897
|
+
spin.stop();
|
|
2898
|
+
if (!result.ok) {
|
|
2899
|
+
error(`Knowledge profile failed: ${result.error}`);
|
|
2900
|
+
failures.push(`knowledge: ${result.error}`);
|
|
2901
|
+
} else {
|
|
2902
|
+
generatedCount += 1;
|
|
2903
|
+
success("Knowledge profile generated");
|
|
2904
|
+
meta("Domains", result.value.domains.join(", "));
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
if (counts.publication > 0) {
|
|
2908
|
+
const spin = spinner("Generating publication profile...");
|
|
2909
|
+
spin.start();
|
|
2910
|
+
const result = await useCase.generatePublicationProfile();
|
|
2911
|
+
spin.stop();
|
|
2912
|
+
if (!result.ok) {
|
|
2913
|
+
error(`Publication profile failed: ${result.error}`);
|
|
2914
|
+
failures.push(`publication: ${result.error}`);
|
|
2915
|
+
} else {
|
|
2916
|
+
generatedCount += 1;
|
|
2917
|
+
success("Publication profile generated");
|
|
2918
|
+
meta("Themes", result.value.themes.join(", "));
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
const duration = timer(startTime);
|
|
2922
|
+
blank();
|
|
2923
|
+
if (failures.length > 0) {
|
|
2924
|
+
error(
|
|
2925
|
+
generatedCount > 0 ? `Profile generation completed with ${failures.length} failure(s).` : "Profile generation failed."
|
|
2926
|
+
);
|
|
2927
|
+
closeDatabase();
|
|
2928
|
+
process.exit(1);
|
|
2929
|
+
}
|
|
2930
|
+
divider();
|
|
2931
|
+
summary({
|
|
2932
|
+
Generated: `${generatedCount} of 3`,
|
|
2933
|
+
Duration: duration
|
|
2934
|
+
});
|
|
2935
|
+
blank();
|
|
2936
|
+
nextSteps([
|
|
2937
|
+
"thoth profile_status Review profile readiness",
|
|
2938
|
+
'thoth research "<topic>" Research from imported knowledge',
|
|
2939
|
+
"thoth generate_article --topic Draft in your voice"
|
|
2940
|
+
]);
|
|
2941
|
+
closeDatabase();
|
|
2942
|
+
});
|
|
2943
|
+
});
|
|
2944
|
+
program2.command("profile_status").description("Show profile generation status").action(async () => {
|
|
2945
|
+
await withCliError(logger, "Status check", async () => {
|
|
2946
|
+
const profileRepo = createProfileRepository();
|
|
2947
|
+
const sourceRepo = createSourceRepository();
|
|
2948
|
+
const [voiceCount, knowledgeCount, pubCount, statusResult] = await Promise.all([
|
|
2949
|
+
sourceRepo.getSourceCountByType("voice"),
|
|
2950
|
+
sourceRepo.getSourceCountByType("knowledge"),
|
|
2951
|
+
sourceRepo.getSourceCountByType("publication"),
|
|
2952
|
+
profileRepo.getProfileStatus()
|
|
2953
|
+
]);
|
|
2954
|
+
if (!statusResult.ok) {
|
|
2955
|
+
error(`Status check failed: ${statusResult.error}`);
|
|
2956
|
+
process.exit(1);
|
|
2957
|
+
}
|
|
2958
|
+
const status = statusResult.value;
|
|
2959
|
+
heading("Thoth status");
|
|
2960
|
+
section("Sources");
|
|
2961
|
+
meta("Voice", `${voiceCount.ok ? voiceCount.value : 0} chunks`);
|
|
2962
|
+
meta("Knowledge", `${knowledgeCount.ok ? knowledgeCount.value : 0} chunks`);
|
|
2963
|
+
meta("Publications", `${pubCount.ok ? pubCount.value : 0} chunks`);
|
|
2964
|
+
blank();
|
|
2965
|
+
section("Profiles");
|
|
2966
|
+
meta(
|
|
2967
|
+
"Voice",
|
|
2968
|
+
status.voice.exists ? `${color.green("[ready]")} ${preview(status.voice.summary)}` : `${color.yellow("[missing]")} Not generated`
|
|
2969
|
+
);
|
|
2970
|
+
meta(
|
|
2971
|
+
"Knowledge",
|
|
2972
|
+
status.knowledge.exists ? `${color.green("[ready]")} ${preview(status.knowledge.domains)}` : `${color.yellow("[missing]")} Not generated`
|
|
2973
|
+
);
|
|
2974
|
+
meta(
|
|
2975
|
+
"Publication",
|
|
2976
|
+
status.publication.exists ? `${color.green("[ready]")} ${preview(status.publication.themes)}` : `${color.yellow("[missing]")} Not generated`
|
|
2977
|
+
);
|
|
2978
|
+
closeDatabase();
|
|
2979
|
+
});
|
|
2980
|
+
});
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
// src/cli/commands/research.command.ts
|
|
2984
|
+
function registerResearchCommand(program2) {
|
|
2985
|
+
program2.command("research").description("Research a topic using imported knowledge sources").argument("<topic>", "Topic to research").action(async (topic) => {
|
|
2986
|
+
await withCliError(logger, "Research", async () => {
|
|
2987
|
+
if (topic.length > 500) {
|
|
2988
|
+
error("Topic must be under 500 characters.");
|
|
2989
|
+
process.exit(1);
|
|
2990
|
+
}
|
|
2991
|
+
const useCase = createResearchUseCase();
|
|
2992
|
+
heading("Researching");
|
|
2993
|
+
meta("Topic", topic);
|
|
2994
|
+
blank();
|
|
2995
|
+
const spin = spinner("Researching...");
|
|
2996
|
+
const startTime = process.hrtime();
|
|
2997
|
+
spin.start();
|
|
2998
|
+
const result = await useCase.execute(topic);
|
|
2999
|
+
spin.stop();
|
|
3000
|
+
const duration = timer(startTime);
|
|
3001
|
+
if (!result.ok) {
|
|
3002
|
+
error(`Research failed: ${result.error}`);
|
|
3003
|
+
process.exit(1);
|
|
3004
|
+
}
|
|
3005
|
+
divider();
|
|
3006
|
+
output(result.value.content);
|
|
3007
|
+
divider();
|
|
3008
|
+
blank();
|
|
3009
|
+
success("Research complete.");
|
|
3010
|
+
meta("Duration", duration);
|
|
3011
|
+
meta("Sources cited", result.value.citations.length);
|
|
3012
|
+
meta("Research ID", result.value.id);
|
|
3013
|
+
blank();
|
|
3014
|
+
section("Citations");
|
|
3015
|
+
for (const citation of result.value.citations) {
|
|
3016
|
+
item(`[${citation.sourceId.slice(0, 8)}]`, citation.sourcePath);
|
|
3017
|
+
meta("Relevance", `${(citation.relevanceScore * 100).toFixed(0)}%`);
|
|
3018
|
+
empty(` "${citation.excerpt.slice(0, 120)}..."`);
|
|
3019
|
+
blank();
|
|
3020
|
+
}
|
|
3021
|
+
closeDatabase();
|
|
3022
|
+
});
|
|
3023
|
+
});
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
// src/cli/commands/series.command.ts
|
|
3027
|
+
function registerSeriesCommand(program2) {
|
|
3028
|
+
program2.command("create_series").description("Create a new series (--description <text>)").argument("<name>", "Series name").option("-d, --description <text>", "Series description").action(async (name, options) => {
|
|
3029
|
+
await withCliError(logger, "Series create", async () => {
|
|
3030
|
+
if (name.length > 200) {
|
|
3031
|
+
error("Series name must be under 200 characters.");
|
|
3032
|
+
process.exit(1);
|
|
3033
|
+
}
|
|
3034
|
+
if (options.description && options.description.length > 1e3) {
|
|
3035
|
+
error("Description must be under 1000 characters.");
|
|
3036
|
+
process.exit(1);
|
|
3037
|
+
}
|
|
3038
|
+
const useCase = createSeriesUseCase();
|
|
3039
|
+
const result = await useCase.create(name, options.description);
|
|
3040
|
+
if (!result.ok) {
|
|
3041
|
+
error(`Failed to create series: ${result.error}`);
|
|
3042
|
+
process.exit(1);
|
|
3043
|
+
}
|
|
3044
|
+
success(`Series created: ${result.value.name}`);
|
|
3045
|
+
meta("ID", result.value.id);
|
|
3046
|
+
closeDatabase();
|
|
3047
|
+
});
|
|
3048
|
+
});
|
|
3049
|
+
program2.command("list_series").description("List all series").action(async () => {
|
|
3050
|
+
await withCliError(logger, "Series list", async () => {
|
|
3051
|
+
const useCase = createSeriesUseCase();
|
|
3052
|
+
const result = await useCase.list();
|
|
3053
|
+
if (!result.ok) {
|
|
3054
|
+
error(`Failed to list series: ${result.error}`);
|
|
3055
|
+
process.exit(1);
|
|
3056
|
+
}
|
|
3057
|
+
if (result.value.length === 0) {
|
|
3058
|
+
empty("No series created yet.");
|
|
3059
|
+
nextSteps(["thoth create_series <name>"]);
|
|
3060
|
+
closeDatabase();
|
|
3061
|
+
return;
|
|
3062
|
+
}
|
|
3063
|
+
heading("Series");
|
|
3064
|
+
for (const series of result.value) {
|
|
3065
|
+
item(series.id.slice(0, 8), `${series.name} (${series.articleIds.length} articles)`);
|
|
3066
|
+
if (series.description) {
|
|
3067
|
+
empty(` ${series.description}`);
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
closeDatabase();
|
|
3071
|
+
});
|
|
3072
|
+
});
|
|
3073
|
+
program2.command("add_to_series").description("Add an article to a series").argument("<series-id>", "Series ID").argument("<article-id>", "Article ID").action(async (seriesId, articleId) => {
|
|
3074
|
+
await withCliError(logger, "Series add", async () => {
|
|
3075
|
+
const useCase = createSeriesUseCase();
|
|
3076
|
+
const result = await useCase.addArticle(seriesId, articleId);
|
|
3077
|
+
if (!result.ok) {
|
|
3078
|
+
error(`Failed to add article: ${result.error}`);
|
|
3079
|
+
process.exit(1);
|
|
3080
|
+
}
|
|
3081
|
+
success(`Article ${articleId.slice(0, 8)} added to series ${seriesId.slice(0, 8)}`);
|
|
3082
|
+
closeDatabase();
|
|
3083
|
+
});
|
|
3084
|
+
});
|
|
3085
|
+
program2.command("get_series").description("Show series details").argument("<id>", "Series ID").action(async (id) => {
|
|
3086
|
+
await withCliError(logger, "Series get", async () => {
|
|
3087
|
+
const useCase = createSeriesUseCase();
|
|
3088
|
+
const result = await useCase.get(id);
|
|
3089
|
+
if (!result.ok) {
|
|
3090
|
+
error(`Failed to get series: ${result.error}`);
|
|
3091
|
+
process.exit(1);
|
|
3092
|
+
}
|
|
3093
|
+
if (!result.value) {
|
|
3094
|
+
error(`Series not found: ${id}`);
|
|
3095
|
+
process.exit(1);
|
|
3096
|
+
}
|
|
3097
|
+
heading(result.value.name);
|
|
3098
|
+
if (result.value.description) meta("Description", result.value.description);
|
|
3099
|
+
meta("Created", result.value.createdAt.toLocaleString());
|
|
3100
|
+
meta("Articles", result.value.articleIds.length);
|
|
3101
|
+
blank();
|
|
3102
|
+
for (const articleId of result.value.articleIds) {
|
|
3103
|
+
item(articleId.slice(0, 8), articleId);
|
|
3104
|
+
}
|
|
3105
|
+
closeDatabase();
|
|
3106
|
+
});
|
|
3107
|
+
});
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
// src/cli/commands/publish.command.ts
|
|
3111
|
+
async function promptCookie(label) {
|
|
3112
|
+
const { stdin, stdout } = process;
|
|
3113
|
+
return new Promise((resolve3) => {
|
|
3114
|
+
stdout.write(` ${label}: `);
|
|
3115
|
+
stdin.once("data", (data) => {
|
|
3116
|
+
resolve3(data.toString().trim());
|
|
3117
|
+
});
|
|
3118
|
+
});
|
|
3119
|
+
}
|
|
3120
|
+
function registerPublishCommand(program2) {
|
|
3121
|
+
program2.command("connect_medium").description("Save Medium session cookies for publishing (no password handling)").action(async () => {
|
|
3122
|
+
await withCliError(logger, "Connect Medium", async () => {
|
|
3123
|
+
heading("Connect to Medium");
|
|
3124
|
+
empty("");
|
|
3125
|
+
info("Open medium.com in your browser and log in.");
|
|
3126
|
+
info("Then open DevTools:");
|
|
3127
|
+
info(" Chrome: View \u2192 Developer \u2192 Developer Tools \u2192 Application \u2192 Cookies");
|
|
3128
|
+
info(" Firefox: Tools \u2192 Web Developer \u2192 Storage Inspector \u2192 Cookies");
|
|
3129
|
+
info(" Safari: Develop \u2192 Show Web Inspector \u2192 Storage \u2192 Cookies");
|
|
3130
|
+
empty("");
|
|
3131
|
+
info("Copy the values for these two cookies:");
|
|
3132
|
+
empty("");
|
|
3133
|
+
const sid = await promptCookie("sid");
|
|
3134
|
+
const uid = await promptCookie("uid");
|
|
3135
|
+
if (!sid || !uid) {
|
|
3136
|
+
error("Both sid and uid cookies are required.");
|
|
3137
|
+
process.exit(1);
|
|
3138
|
+
}
|
|
3139
|
+
const adapter = createMediumAdapter();
|
|
3140
|
+
const result = await adapter.connect({ sid, uid });
|
|
3141
|
+
if (!result.ok) {
|
|
3142
|
+
error(`Failed to save credentials: ${result.error}`);
|
|
3143
|
+
process.exit(1);
|
|
3144
|
+
}
|
|
3145
|
+
blank();
|
|
3146
|
+
success("Credentials saved to ~/.thoth/medium-credentials.json");
|
|
3147
|
+
info("You can now run `thoth publish <article-id>` to publish to Medium.");
|
|
3148
|
+
closeDatabase();
|
|
3149
|
+
});
|
|
3150
|
+
});
|
|
3151
|
+
program2.command("publish").description("Publish an article to Medium as a draft").argument("<id>", "Article ID to publish").action(async (id) => {
|
|
3152
|
+
await withCliError(logger, "Publish to Medium", async () => {
|
|
3153
|
+
const adapter = createMediumAdapter();
|
|
3154
|
+
const authenticated = await adapter.isAuthenticated();
|
|
3155
|
+
if (!authenticated) {
|
|
3156
|
+
error("Not authenticated. Run `thoth connect_medium` first.");
|
|
3157
|
+
process.exit(1);
|
|
3158
|
+
}
|
|
3159
|
+
const spin = spinner("Publishing to Medium...");
|
|
3160
|
+
spin.start();
|
|
3161
|
+
const useCase = createPublishArticleUseCase();
|
|
3162
|
+
const result = await useCase.execute(id);
|
|
3163
|
+
spin.stop();
|
|
3164
|
+
if (!result.ok) {
|
|
3165
|
+
error(`Publish failed: ${result.error}`);
|
|
3166
|
+
process.exit(1);
|
|
3167
|
+
}
|
|
3168
|
+
blank();
|
|
3169
|
+
success("Article published to Medium.");
|
|
3170
|
+
summary({
|
|
3171
|
+
Article: result.value.title,
|
|
3172
|
+
URL: result.value.mediumUrl ?? "(not set)",
|
|
3173
|
+
Status: result.value.status
|
|
3174
|
+
});
|
|
3175
|
+
closeDatabase();
|
|
3176
|
+
});
|
|
3177
|
+
});
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
// src/cli/index.ts
|
|
3181
|
+
var activeConfig = null;
|
|
3182
|
+
function createCli() {
|
|
3183
|
+
const program2 = new Command();
|
|
3184
|
+
program2.name("thoth").description("Identity Preserving Publishing Engine").version("1.0.0").option("--local", "Use only local AI (Ollama). No data sent to external providers").option("--provider <name>", "AI provider to use (openai, groq, gemini, anthropic, ollama)");
|
|
3185
|
+
program2.hook("preAction", (thisCommand) => {
|
|
3186
|
+
const opts = thisCommand.optsWithGlobals();
|
|
3187
|
+
activeConfig = resolveConfig(
|
|
3188
|
+
typeof opts.local === "boolean" ? opts.local : typeof opts.local === "string" ? opts.local === "true" : void 0,
|
|
3189
|
+
typeof opts.provider === "string" ? opts.provider : void 0
|
|
3190
|
+
);
|
|
3191
|
+
applyConfig(activeConfig);
|
|
3192
|
+
if (activeConfig.dbPath) {
|
|
3193
|
+
setDbPath(activeConfig.dbPath);
|
|
3194
|
+
}
|
|
3195
|
+
});
|
|
3196
|
+
registerInitCommand(program2);
|
|
3197
|
+
registerImportCommand(program2);
|
|
3198
|
+
registerProfileCommand(program2);
|
|
3199
|
+
registerResearchCommand(program2);
|
|
3200
|
+
registerArticleCommand(program2);
|
|
3201
|
+
registerSeriesCommand(program2);
|
|
3202
|
+
registerExportCommand(program2);
|
|
3203
|
+
registerPublishCommand(program2);
|
|
3204
|
+
return program2;
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
// src/index.ts
|
|
3208
|
+
var program = createCli();
|
|
3209
|
+
program.parse();
|