Skip to content

Commit 1f94366

Browse files
author
StackMemory Bot (CLI)
committed
feat(provenant): implement Phase 2 — log-override, REST API, Voyage AI, evidence URLs
- Add `provenant log-override list|resolve` CLI for rejection log management - Add `--source-url` and `--source-file` options to `log-decision` command - Add VoyageEmbeddingProvider (voyage-3-lite default, VOYAGE_API_KEY env) - Add REST API server with 5 endpoints (status, search, node, decisions, contradictions) - Add `provenant serve --port 3847` CLI command - Add `findRejection()` prefix-match helper to database
1 parent e078489 commit 1f94366

8 files changed

Lines changed: 404 additions & 3 deletions

File tree

packages/provenant/src/adapters/manual.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,20 @@ export class ManualAdapter implements SourceAdapter {
4040
content: string;
4141
actor?: string;
4242
reasoning?: string;
43+
sourceUrl?: string;
44+
sourceFile?: string;
4345
}): RawRecord {
4446
return {
4547
external_id: `manual-${Date.now()}`,
4648
content: params.content,
4749
raw_payload: JSON.stringify(params),
4850
actor: params.actor,
4951
created_at: Date.now(),
50-
metadata: { reasoning: params.reasoning },
52+
metadata: {
53+
reasoning: params.reasoning,
54+
sourceUrl: params.sourceUrl,
55+
sourceFile: params.sourceFile,
56+
},
5157
};
5258
}
5359
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import {
2+
createServer,
3+
type IncomingMessage,
4+
type ServerResponse,
5+
} from 'node:http';
6+
import { Database } from '../schema/database.js';
7+
8+
interface ServerConfig {
9+
port: number;
10+
dbPath: string;
11+
}
12+
13+
function parseJson(req: IncomingMessage): Promise<unknown> {
14+
return new Promise((resolve, reject) => {
15+
const chunks: Buffer[] = [];
16+
req.on('data', (chunk: Buffer) => chunks.push(chunk));
17+
req.on('end', () => {
18+
try {
19+
resolve(JSON.parse(Buffer.concat(chunks).toString()));
20+
} catch {
21+
reject(new Error('Invalid JSON'));
22+
}
23+
});
24+
req.on('error', reject);
25+
});
26+
}
27+
28+
function json(res: ServerResponse, status: number, body: unknown): void {
29+
res.writeHead(status, { 'Content-Type': 'application/json' });
30+
res.end(JSON.stringify(body));
31+
}
32+
33+
function parseQuery(url: string): URLSearchParams {
34+
const idx = url.indexOf('?');
35+
return new URLSearchParams(idx >= 0 ? url.slice(idx + 1) : '');
36+
}
37+
38+
function parsePath(url: string): string {
39+
const idx = url.indexOf('?');
40+
return idx >= 0 ? url.slice(0, idx) : url;
41+
}
42+
43+
export function startServer(config: ServerConfig): void {
44+
const db = new Database(config.dbPath);
45+
46+
const server = createServer(async (req, res) => {
47+
const method = req.method ?? 'GET';
48+
const path = parsePath(req.url ?? '/');
49+
const query = parseQuery(req.url ?? '/');
50+
51+
try {
52+
// GET /api/status
53+
if (method === 'GET' && path === '/api/status') {
54+
json(res, 200, db.getStatus());
55+
return;
56+
}
57+
58+
// GET /api/nodes/:id
59+
const nodeMatch = path.match(/^\/api\/nodes\/([^/]+)$/);
60+
if (method === 'GET' && nodeMatch) {
61+
const id = nodeMatch[1]!;
62+
const node = db.getNode(id);
63+
if (!node) {
64+
json(res, 404, { error: 'Node not found' });
65+
return;
66+
}
67+
const edgesFrom = db.getEdgesFrom(id);
68+
const edgesTo = db.getEdgesTo(id);
69+
const sources = db.getSourcesForNode(id);
70+
json(res, 200, {
71+
...node,
72+
embedding: undefined,
73+
edges: { from: edgesFrom, to: edgesTo },
74+
sources,
75+
});
76+
return;
77+
}
78+
79+
// GET /api/nodes?keywords=...&limit=...&actor=...
80+
if (method === 'GET' && path === '/api/nodes') {
81+
const keywordsRaw = query.get('keywords') ?? '';
82+
const keywords = keywordsRaw
83+
? keywordsRaw.split(',').map((k) => k.trim())
84+
: [];
85+
const limit = parseInt(query.get('limit') ?? '20', 10);
86+
const actor = query.get('actor') ?? undefined;
87+
const nodes = db.searchNodesByKeywords(keywords, limit, actor);
88+
json(
89+
res,
90+
200,
91+
nodes.map((n) => ({ ...n, embedding: undefined }))
92+
);
93+
return;
94+
}
95+
96+
// POST /api/decisions
97+
if (method === 'POST' && path === '/api/decisions') {
98+
const body = (await parseJson(req)) as {
99+
content?: string;
100+
actor?: string;
101+
reasoning?: string;
102+
};
103+
if (!body.content) {
104+
json(res, 400, { error: 'content is required' });
105+
return;
106+
}
107+
const node = db.insertNode({
108+
type: 'decision',
109+
content: body.content,
110+
embedding: null,
111+
actor: body.actor ?? null,
112+
confidence: 0.75,
113+
});
114+
json(res, 201, { ...node, embedding: undefined });
115+
return;
116+
}
117+
118+
// GET /api/contradictions
119+
if (method === 'GET' && path === '/api/contradictions') {
120+
const contradictions = db.getPendingContradictions();
121+
json(res, 200, contradictions);
122+
return;
123+
}
124+
125+
json(res, 404, { error: 'Not found' });
126+
} catch (err) {
127+
const message = err instanceof Error ? err.message : 'Internal error';
128+
json(res, 500, { error: message });
129+
}
130+
});
131+
132+
server.listen(config.port, () => {
133+
console.log(`Provenant API server listening on port ${config.port}`);
134+
console.log(` Database: ${config.dbPath}`);
135+
console.log(` Endpoints:`);
136+
console.log(` GET /api/status`);
137+
console.log(` GET /api/nodes?keywords=...&limit=...&actor=...`);
138+
console.log(` GET /api/nodes/:id`);
139+
console.log(` POST /api/decisions`);
140+
console.log(` GET /api/contradictions`);
141+
});
142+
143+
process.on('SIGINT', () => {
144+
db.close();
145+
server.close();
146+
process.exit(0);
147+
});
148+
149+
process.on('SIGTERM', () => {
150+
db.close();
151+
server.close();
152+
process.exit(0);
153+
});
154+
}

packages/provenant/src/cli/commands/log-decision.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ interface LogDecisionOpts {
88
content: string;
99
actor?: string;
1010
reasoning?: string;
11+
sourceUrl?: string;
12+
sourceFile?: string;
1113
db: string;
1214
}
1315

@@ -21,6 +23,8 @@ export function logDecision(opts: LogDecisionOpts): void {
2123
content: opts.content,
2224
actor: opts.actor,
2325
reasoning: opts.reasoning,
26+
sourceUrl: opts.sourceUrl,
27+
sourceFile: opts.sourceFile,
2428
});
2529

2630
const result = scoreRecord(record, adapter.signalModel);
@@ -49,6 +53,12 @@ export function logDecision(opts: LogDecisionOpts): void {
4953
console.log(` type: decision`);
5054
console.log(` confidence: ${result.score.toFixed(2)} (${result.action})`);
5155
console.log(` actor: ${node.actor ?? '—'}`);
56+
if (opts.sourceUrl) {
57+
console.log(` source-url: ${opts.sourceUrl}`);
58+
}
59+
if (opts.sourceFile) {
60+
console.log(` source-file: ${opts.sourceFile}`);
61+
}
5262
console.log(` signals:`);
5363
for (const s of result.signals) {
5464
const icon = s.matched ? (s.weight >= 0 ? '+' : '−') : ' ';
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { existsSync } from 'node:fs';
2+
import { Database } from '../../schema/database.js';
3+
4+
interface LogOverrideListOpts {
5+
db: string;
6+
limit: string;
7+
}
8+
9+
interface LogOverrideResolveOpts {
10+
db: string;
11+
reasoning: string;
12+
}
13+
14+
export function logOverrideList(opts: LogOverrideListOpts): void {
15+
if (!existsSync(opts.db)) {
16+
console.error('No database found.');
17+
process.exit(1);
18+
}
19+
20+
const db = new Database(opts.db);
21+
22+
try {
23+
const entries = db.getUnresolvedRejections();
24+
const limit = parseInt(opts.limit, 10) || 20;
25+
26+
console.log(`Unresolved Rejections: ${entries.length}`);
27+
console.log('─'.repeat(60));
28+
29+
const shown = entries.slice(0, limit);
30+
for (const entry of shown) {
31+
const age = Math.floor((Date.now() - entry.created_at) / 86_400_000);
32+
const suggestion = db.getNode(entry.suggestion_node);
33+
const override = entry.override_node
34+
? db.getNode(entry.override_node)
35+
: undefined;
36+
37+
console.log(
38+
` ${entry.id.slice(0, 8)} ${age}d ago actor: ${entry.actor ?? '—'}`
39+
);
40+
console.log(
41+
` suggestion: ${suggestion?.content.slice(0, 80) ?? entry.suggestion_node}${(suggestion?.content.length ?? 0) > 80 ? '...' : ''}`
42+
);
43+
if (override) {
44+
console.log(
45+
` override: ${override.content.slice(0, 80)}${override.content.length > 80 ? '...' : ''}`
46+
);
47+
}
48+
console.log();
49+
}
50+
51+
if (entries.length > limit) {
52+
console.log(` ... and ${entries.length - limit} more`);
53+
}
54+
55+
if (entries.length === 0) {
56+
console.log(' No unresolved rejections.');
57+
}
58+
} finally {
59+
db.close();
60+
}
61+
}
62+
63+
export function logOverrideResolve(
64+
id: string,
65+
opts: LogOverrideResolveOpts
66+
): void {
67+
if (!existsSync(opts.db)) {
68+
console.error('No database found.');
69+
process.exit(1);
70+
}
71+
72+
const db = new Database(opts.db);
73+
74+
try {
75+
const entry = db.findRejection(id);
76+
if (!entry) {
77+
console.error(`No unresolved rejection matching: ${id}`);
78+
process.exit(1);
79+
}
80+
81+
db.resolveRejectionReasoning(entry.id, opts.reasoning);
82+
console.log(`Resolved rejection ${entry.id.slice(0, 8)}`);
83+
console.log(` reasoning: ${opts.reasoning}`);
84+
} finally {
85+
db.close();
86+
}
87+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { mkdirSync } from 'node:fs';
2+
import { dirname } from 'node:path';
3+
import { startServer } from '../../api/server.js';
4+
5+
interface ServeOpts {
6+
port: string;
7+
db: string;
8+
}
9+
10+
export function serve(opts: ServeOpts): void {
11+
mkdirSync(dirname(opts.db), { recursive: true });
12+
const port = parseInt(opts.port, 10);
13+
if (isNaN(port) || port < 1 || port > 65535) {
14+
console.error(`Invalid port: ${opts.port}`);
15+
process.exit(1);
16+
}
17+
startServer({ port, dbPath: opts.db });
18+
}

packages/provenant/src/cli/index.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ import {
1111
reviewDismiss,
1212
reviewExpire,
1313
} from './commands/review.js';
14+
import {
15+
logOverrideList,
16+
logOverrideResolve,
17+
} from './commands/log-override.js';
18+
import { serve } from './commands/serve.js';
1419

1520
const program = new Command();
1621

@@ -25,6 +30,8 @@ program
2530
.requiredOption('-c, --content <text>', 'Decision content')
2631
.option('-a, --actor <name>', 'Who made this decision')
2732
.option('-r, --reasoning <text>', 'Why this decision was made')
33+
.option('--source-url <url>', 'URL evidence for this decision')
34+
.option('--source-file <path>', 'File path evidence for this decision')
2835
.option('--db <path>', 'Database path', '.provenant/graph.db')
2936
.action(logDecision);
3037

@@ -97,4 +104,32 @@ review
97104
.option('--db <path>', 'Database path', '.provenant/graph.db')
98105
.action(reviewExpire);
99106

107+
// Log-override subcommands
108+
const logOverride = program
109+
.command('log-override')
110+
.description('Manage the rejection log');
111+
112+
logOverride
113+
.command('list')
114+
.description('List unresolved rejection log entries')
115+
.option('-l, --limit <n>', 'Max items to show', '20')
116+
.option('--db <path>', 'Database path', '.provenant/graph.db')
117+
.action(logOverrideList);
118+
119+
logOverride
120+
.command('resolve')
121+
.description('Resolve a rejection by adding reasoning')
122+
.argument('<id>', 'Rejection ID (or prefix)')
123+
.requiredOption('-r, --reasoning <text>', 'Resolution reasoning')
124+
.option('--db <path>', 'Database path', '.provenant/graph.db')
125+
.action(logOverrideResolve);
126+
127+
// REST API server
128+
program
129+
.command('serve')
130+
.description('Start the REST API server')
131+
.option('-p, --port <port>', 'Port to listen on', '3847')
132+
.option('--db <path>', 'Database path', '.provenant/graph.db')
133+
.action(serve);
134+
100135
program.parse();

0 commit comments

Comments
 (0)