sessionmem 1.3.0 → 1.5.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/.github/sessionmem-logo.svg +57 -0
- package/CHANGELOG.md +128 -0
- package/CONTRIBUTING.md +130 -0
- package/README.md +292 -108
- package/SECURITY.md +33 -0
- package/dist/adapters/diagnostics.js +109 -0
- package/dist/adapters/generic.js +6 -2
- package/dist/adapters/global/antigravity.js +4 -1
- package/dist/adapters/global/claudeCode.js +19 -4
- package/dist/adapters/global/codex.js +7 -6
- package/dist/adapters/global/qcoder.js +4 -1
- package/dist/adapters/ide/cline.js +3 -0
- package/dist/adapters/ide/cursor.js +3 -0
- package/dist/adapters/ide/installer.js +17 -5
- package/dist/adapters/ide/windsurf.js +3 -0
- package/dist/adapters/installedHosts.js +51 -0
- package/dist/cli/commands/dedupe.js +1 -1
- package/dist/cli/commands/doctor.js +146 -0
- package/dist/cli/commands/entities.js +27 -0
- package/dist/cli/commands/history.js +23 -0
- package/dist/cli/commands/import.js +15 -44
- package/dist/cli/commands/ingestHook.js +4 -78
- package/dist/cli/commands/install.js +37 -59
- package/dist/cli/commands/list.js +3 -0
- package/dist/cli/commands/run.js +10 -12
- package/dist/cli/commands/scan.js +16 -0
- package/dist/cli/commands/search.js +9 -4
- package/dist/cli/commands/team.js +7 -3
- package/dist/cli/commands/ui.js +67 -0
- package/dist/cli/commands/uninstall.js +2 -1
- package/dist/cli/commands/watch.js +48 -0
- package/dist/cli/context.js +8 -6
- package/dist/cli/index.js +48 -7
- package/dist/cli/projectId.js +1 -2
- package/dist/cli/transcriptSweep.js +182 -0
- package/dist/core/api/contracts.js +1 -0
- package/dist/core/api/memoryCoreService.js +8 -0
- package/dist/core/api/sessionLifecycleService.js +13 -1
- package/dist/core/capture/toolEvent.js +82 -0
- package/dist/core/config/policyConfig.js +5 -1
- package/dist/core/extract/factClaims.js +97 -0
- package/dist/core/extract/knowledge.js +127 -0
- package/dist/core/injection/tokenBudget.js +10 -2
- package/dist/core/retrieve/retrieveMemories.js +20 -4
- package/dist/core/schema/migrations/012_transcript_checkpoints.sql +4 -0
- package/dist/core/schema/migrations/013_fts_porter.sql +32 -0
- package/dist/core/schema/migrations/014_memory_entities.sql +19 -0
- package/dist/core/schema/migrations/015_memory_supersessions.sql +41 -0
- package/dist/core/schema/runMigrations.js +55 -5
- package/dist/core/storage/db.js +31 -9
- package/dist/core/storage/knowledgeRepo.js +67 -0
- package/dist/core/storage/memoryRepo.js +45 -12
- package/dist/core/storage/memorySearchRepo.js +30 -20
- package/dist/core/storage/supersessionRepo.js +101 -0
- package/dist/core/summarize/cloudSummarizer.js +3 -8
- package/dist/core/summarize/summaryShape.js +1 -1
- package/dist/ui/assets.js +181 -0
- package/dist/ui/server.js +147 -0
- package/docs/REGISTRY_LISTING.md +88 -0
- package/docs/architecture.md +77 -0
- package/docs/benchmark.md +122 -0
- package/docs/cloud-summarization.md +57 -0
- package/docs/migration.md +44 -0
- package/docs/privacy-and-retention.md +139 -0
- package/docs/team-mode.md +117 -0
- package/docs/troubleshooting.md +126 -0
- package/docs/typescript-upgrade.md +48 -0
- package/package.json +25 -13
|
@@ -27,6 +27,7 @@ function getSearchStatements(db) {
|
|
|
27
27
|
embedding, embedding_dim, embedding_version
|
|
28
28
|
FROM memories
|
|
29
29
|
WHERE project_id = @projectId
|
|
30
|
+
AND (@includeHistory = 1 OR id NOT IN (SELECT memory_id FROM superseded_memories))
|
|
30
31
|
AND (expires_at IS NULL OR expires_at > @now)
|
|
31
32
|
AND (
|
|
32
33
|
importance >= 8
|
|
@@ -38,13 +39,15 @@ function getSearchStatements(db) {
|
|
|
38
39
|
m.id, m.project_id, m.session_id, m.source_adapter, m.kind, m.content,
|
|
39
40
|
m.normalized_content, m.importance, m.author, m.origin_project_id,
|
|
40
41
|
m.access_count, m.created_at, m.updated_at,
|
|
41
|
-
m.embedding, m.embedding_dim, m.embedding_version
|
|
42
|
+
m.embedding, m.embedding_dim, m.embedding_version,
|
|
43
|
+
-bm25(memories_fts) AS lexical_score
|
|
42
44
|
FROM memories_fts
|
|
43
45
|
JOIN memories m ON m.rowid = memories_fts.rowid
|
|
44
46
|
WHERE memories_fts MATCH @match
|
|
45
47
|
AND m.project_id = @projectId
|
|
48
|
+
AND (@includeHistory = 1 OR m.id NOT IN (SELECT memory_id FROM superseded_memories))
|
|
46
49
|
AND (m.expires_at IS NULL OR m.expires_at > @now)
|
|
47
|
-
ORDER BY rank
|
|
50
|
+
ORDER BY rank, m.id
|
|
48
51
|
LIMIT @limit
|
|
49
52
|
`),
|
|
50
53
|
};
|
|
@@ -83,34 +86,40 @@ function mapRows(rows) {
|
|
|
83
86
|
const versionMatch = row.embedding_version === EMBEDDING_VERSION;
|
|
84
87
|
return {
|
|
85
88
|
...row,
|
|
89
|
+
lexical_score: row.lexical_score ?? 0,
|
|
86
90
|
embedding: versionMatch ? parsed : null,
|
|
87
91
|
};
|
|
88
92
|
});
|
|
89
93
|
}
|
|
90
|
-
export function searchMemoryCandidates(db, projectId, now = new Date()) {
|
|
94
|
+
export function searchMemoryCandidates(db, projectId, now = new Date(), includeHistory = false) {
|
|
91
95
|
const rows = getSearchStatements(db).searchCandidates.all({
|
|
92
96
|
projectId,
|
|
97
|
+
includeHistory: Number(includeHistory),
|
|
93
98
|
now: toStorageTimestamp(now),
|
|
94
99
|
recencyCutoff: toStorageTimestamp(new Date(now.getTime() - FALLBACK_RECENCY_DAYS * MS_PER_DAY)),
|
|
95
100
|
});
|
|
96
101
|
return mapRows(rows);
|
|
97
102
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
const QUERY_STOP_WORDS = new Set(("a an and are as at be been being but by can could did do does doing for from " +
|
|
104
|
+
"had has have he her hers herself him himself his how i if in into is it its itself " +
|
|
105
|
+
"me my myself of on or our ours ourselves she should so than that the their theirs " +
|
|
106
|
+
"them themselves then there these they this those to too us was we were what when " +
|
|
107
|
+
"where which who whom why will with would you your yours yourself yourselves " +
|
|
108
|
+
"s t don doesn didn isn wasn weren won wouldn couldn shouldn").split(" "));
|
|
109
|
+
/** Match any meaningful query term; question grammar must not exclude evidence. */
|
|
104
110
|
function sanitizeFtsQuery(queryText) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
111
|
+
const tokens = [...new Set(queryText.normalize("NFKC").toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [])];
|
|
112
|
+
const terms = tokens.filter((token) => !QUERY_STOP_WORDS.has(token));
|
|
113
|
+
// Preserve literal searches consisting entirely of stop words. Tokens contain
|
|
114
|
+
// only letters/numbers, so FTS operators and punctuation cannot become syntax.
|
|
115
|
+
return (terms.length ? terms : tokens)
|
|
116
|
+
.slice(0, 64)
|
|
117
|
+
.map((token) => `"${token}"`)
|
|
118
|
+
.join(" OR ");
|
|
110
119
|
}
|
|
111
120
|
/**
|
|
112
|
-
*
|
|
113
|
-
* Returns up to FTS_CANDIDATE_LIMIT
|
|
121
|
+
* Retrieve BM25-ranked lexical candidates, including stored summaries.
|
|
122
|
+
* Returns up to FTS_CANDIDATE_LIMIT matches with deterministic ID tie breaks.
|
|
114
123
|
*
|
|
115
124
|
* FTS keyword overlap can be sparse, so the fallback recency/importance scan is
|
|
116
125
|
* UNIONed with (never substituted for) the FTS hits:
|
|
@@ -124,28 +133,29 @@ function sanitizeFtsQuery(queryText) {
|
|
|
124
133
|
* (<8) — exactly the rows the fallback's filter excludes — returning zero
|
|
125
134
|
* candidates for a query that matched only such rows.
|
|
126
135
|
*/
|
|
127
|
-
export function searchMemoryCandidatesFTS(db, projectId, queryText, now = new Date()) {
|
|
136
|
+
export function searchMemoryCandidatesFTS(db, projectId, queryText, now = new Date(), includeHistory = false) {
|
|
128
137
|
const sanitized = sanitizeFtsQuery(queryText);
|
|
129
138
|
if (!sanitized) {
|
|
130
|
-
return searchMemoryCandidates(db, projectId, now);
|
|
139
|
+
return searchMemoryCandidates(db, projectId, now, includeHistory);
|
|
131
140
|
}
|
|
132
141
|
let rows;
|
|
133
142
|
try {
|
|
134
143
|
rows = getSearchStatements(db).searchCandidatesFTS.all({
|
|
135
144
|
match: sanitized,
|
|
136
145
|
projectId,
|
|
146
|
+
includeHistory: Number(includeHistory),
|
|
137
147
|
now: toStorageTimestamp(now),
|
|
138
148
|
limit: FTS_CANDIDATE_LIMIT,
|
|
139
149
|
});
|
|
140
150
|
}
|
|
141
151
|
catch {
|
|
142
152
|
// FTS5 MATCH can throw on malformed queries — fall back to full scan
|
|
143
|
-
return searchMemoryCandidates(db, projectId, now);
|
|
153
|
+
return searchMemoryCandidates(db, projectId, now, includeHistory);
|
|
144
154
|
}
|
|
145
155
|
if (rows.length >= FTS_FALLBACK_THRESHOLD) {
|
|
146
156
|
return dedupById(mapRows(rows));
|
|
147
157
|
}
|
|
148
|
-
const fallback = searchMemoryCandidates(db, projectId, now);
|
|
158
|
+
const fallback = searchMemoryCandidates(db, projectId, now, includeHistory);
|
|
149
159
|
if (rows.length === 0) {
|
|
150
160
|
return fallback;
|
|
151
161
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { claimKey, claimSlot, extractFactClaims, supersessionReason } from "../extract/factClaims.js";
|
|
2
|
+
const toClaim = (row) => ({ source: { kind: row.source_kind, name: row.source_name },
|
|
3
|
+
type: row.relation, target: { kind: row.target_kind, name: row.target_name }, context: row.context,
|
|
4
|
+
attribute: row.attribute, negative: !!row.negative, explicitChange: !!row.explicit_change, replaces: row.replaces_value });
|
|
5
|
+
function prepareStatements(db) {
|
|
6
|
+
return {
|
|
7
|
+
memory: db.prepare("SELECT project_id, created_at FROM memories WHERE id = ?"),
|
|
8
|
+
projectGroups: db.prepare(`SELECT DISTINCT c.source_kind, c.source_name, c.relation FROM memory_claims c
|
|
9
|
+
JOIN memories m ON m.id = c.memory_id WHERE m.project_id = ?`),
|
|
10
|
+
previous: db.prepare("SELECT * FROM memory_claims WHERE memory_id = ?"),
|
|
11
|
+
clear: db.prepare("DELETE FROM memory_claims WHERE memory_id = ? AND claim_key NOT IN (SELECT value FROM json_each(?))"),
|
|
12
|
+
insert: db.prepare(`INSERT INTO memory_claims
|
|
13
|
+
(memory_id, claim_key, source_kind, source_name, relation, target_kind, target_name, context, attribute, negative, explicit_change, replaces_value, observed_at)
|
|
14
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
15
|
+
ON CONFLICT(memory_id, claim_key) DO UPDATE SET negative=excluded.negative,
|
|
16
|
+
explicit_change=excluded.explicit_change, replaces_value=excluded.replaces_value, observed_at=excluded.observed_at`),
|
|
17
|
+
select: db.prepare(`SELECT c.*, m.expires_at FROM memory_claims c JOIN memories m ON m.id = c.memory_id
|
|
18
|
+
WHERE m.project_id = ? AND c.source_kind = ? AND c.source_name = ? AND c.relation = ?
|
|
19
|
+
ORDER BY c.observed_at, m.rowid, c.claim_key`),
|
|
20
|
+
remove: db.prepare("DELETE FROM memory_supersessions WHERE supersedes = ? AND claim_key = ?"),
|
|
21
|
+
link: db.prepare(`INSERT INTO memory_supersessions (supersedes, claim_key, superseded_by, new_claim_key, superseded_at, reason)
|
|
22
|
+
VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(supersedes, claim_key) DO UPDATE SET
|
|
23
|
+
superseded_by=excluded.superseded_by, new_claim_key=excluded.new_claim_key, superseded_at=excluded.superseded_at,
|
|
24
|
+
reason=excluded.reason, detected_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
25
|
+
WHERE superseded_by <> excluded.superseded_by OR new_claim_key <> excluded.new_claim_key OR superseded_at <> excluded.superseded_at OR reason <> excluded.reason`),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const cache = new WeakMap();
|
|
29
|
+
function statements(db) {
|
|
30
|
+
let stmts = cache.get(db);
|
|
31
|
+
if (!stmts) {
|
|
32
|
+
stmts = prepareStatements(db);
|
|
33
|
+
cache.set(db, stmts);
|
|
34
|
+
}
|
|
35
|
+
return stmts;
|
|
36
|
+
}
|
|
37
|
+
/** Reconcile affected entity/relation groups, including out-of-order imports and rewrites. */
|
|
38
|
+
export function indexMemorySupersessions(db, memoryId, content, sources) {
|
|
39
|
+
const stmts = statements(db);
|
|
40
|
+
const memory = stmts.memory.get(memoryId);
|
|
41
|
+
const previous = stmts.previous.all(memoryId);
|
|
42
|
+
const finalClaims = new Map();
|
|
43
|
+
for (const source of sources ?? [{ text: content, observedAt: memory.created_at }]) {
|
|
44
|
+
for (const claim of extractFactClaims(source.text))
|
|
45
|
+
finalClaims.set(claimSlot(claim), { ...claim, observedAt: source.observedAt });
|
|
46
|
+
}
|
|
47
|
+
const claims = [...finalClaims.values()];
|
|
48
|
+
const groups = new Map();
|
|
49
|
+
for (const row of previous)
|
|
50
|
+
groups.set(JSON.stringify([row.source_kind, row.source_name, row.relation]), [row.source_kind, row.source_name, row.relation]);
|
|
51
|
+
const keys = claims.map(claimKey);
|
|
52
|
+
// Preserve identical claims and links on replays (including detected_at).
|
|
53
|
+
stmts.clear.run(memoryId, JSON.stringify(keys));
|
|
54
|
+
for (const claim of claims) {
|
|
55
|
+
const group = [claim.source.kind, claim.source.name, claim.type];
|
|
56
|
+
groups.set(JSON.stringify(group), group);
|
|
57
|
+
stmts.insert.run(memoryId, claimKey(claim), ...group, claim.target.kind, claim.target.name, claim.context, claim.attribute, Number(claim.negative), Number(claim.explicitChange), claim.replaces, claim.observedAt);
|
|
58
|
+
}
|
|
59
|
+
reconcileGroups(db, memory.project_id, [...groups.values()]);
|
|
60
|
+
}
|
|
61
|
+
/** Deleting an intermediate replacement must not revive an older contradicted fact. */
|
|
62
|
+
export function reconcileProjectSupersessions(db, projectId) {
|
|
63
|
+
const groups = statements(db).projectGroups.all(projectId);
|
|
64
|
+
reconcileGroups(db, projectId, groups.map(row => [row.source_kind, row.source_name, row.relation]));
|
|
65
|
+
}
|
|
66
|
+
function reconcileGroups(db, projectId, groups) {
|
|
67
|
+
const stmts = statements(db);
|
|
68
|
+
for (const group of groups) {
|
|
69
|
+
const rows = stmts.select.all(projectId, ...group);
|
|
70
|
+
for (let i = 0; i < rows.length; i++) {
|
|
71
|
+
const older = rows[i];
|
|
72
|
+
let found = false;
|
|
73
|
+
for (let j = i + 1; j < rows.length; j++) {
|
|
74
|
+
const newer = rows[j];
|
|
75
|
+
if (older.memory_id === newer.memory_id || (older.expires_at && older.expires_at <= newer.observed_at)
|
|
76
|
+
|| (newer.expires_at && newer.expires_at <= newer.observed_at))
|
|
77
|
+
continue;
|
|
78
|
+
const reason = supersessionReason(toClaim(older), toClaim(newer));
|
|
79
|
+
if (!reason)
|
|
80
|
+
continue;
|
|
81
|
+
stmts.link.run(older.memory_id, older.claim_key, newer.memory_id, newer.claim_key, newer.observed_at, reason);
|
|
82
|
+
found = true;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
if (!found)
|
|
86
|
+
stmts.remove.run(older.memory_id, older.claim_key);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export function listSupersessions(db, projectId) {
|
|
91
|
+
return db.prepare(`SELECT s.supersedes, s.superseded_by AS supersededBy, s.superseded_at AS supersededAt,
|
|
92
|
+
s.detected_at AS detectedAt, s.reason, c.source_name AS entity, c.relation,
|
|
93
|
+
CASE WHEN c.negative THEN 'not ' ELSE '' END || c.target_name AS oldValue,
|
|
94
|
+
CASE WHEN n.negative THEN 'not ' ELSE '' END || n.target_name AS newValue,
|
|
95
|
+
EXISTS (SELECT 1 FROM superseded_memories f WHERE f.memory_id = s.supersedes) AS fullySuperseded
|
|
96
|
+
FROM memory_supersessions s JOIN memories m ON m.id = s.supersedes
|
|
97
|
+
JOIN memory_claims c ON c.memory_id = s.supersedes AND c.claim_key = s.claim_key
|
|
98
|
+
JOIN memory_claims n ON n.memory_id = s.superseded_by AND n.claim_key = s.new_claim_key
|
|
99
|
+
WHERE m.project_id = ? ORDER BY s.superseded_at, s.supersedes, s.superseded_by, s.claim_key`)
|
|
100
|
+
.all(projectId).map(row => ({ ...row, fullySuperseded: !!row.fullySuperseded }));
|
|
101
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import { capTokens } from "../injection/tokenBudget.js";
|
|
2
3
|
import { summarizeLocalSessionEvents } from "./localSummarizer.js";
|
|
3
4
|
import { DEFAULT_SUMMARIZER_MODEL } from "../config/policyConfig.js";
|
|
4
5
|
const CLOUD_SYSTEM_PROMPT = "You are a memory compressor for AI coding sessions. Given this session transcript summary, produce a compact, high-signal list of facts, decisions, and context that should be remembered. Be extremely concise.";
|
|
@@ -26,7 +27,7 @@ export async function summarizeWithCloud(input) {
|
|
|
26
27
|
messages: [{ role: "user", content: localResult.summary }],
|
|
27
28
|
});
|
|
28
29
|
// Extract text from response content blocks
|
|
29
|
-
|
|
30
|
+
const text = response.content
|
|
30
31
|
.filter((block) => block.type === "text")
|
|
31
32
|
.map((block) => block.text)
|
|
32
33
|
.join("\n");
|
|
@@ -35,14 +36,8 @@ export async function summarizeWithCloud(input) {
|
|
|
35
36
|
if (!text || text.trim().length === 0) {
|
|
36
37
|
throw new Error("Cloud summarizer returned empty response");
|
|
37
38
|
}
|
|
38
|
-
// Cap cloud output to summaryTokenCap (rough token→char ratio) for parity
|
|
39
|
-
// with the local summarizer, so a verbose cloud response cannot blow past the
|
|
40
|
-
// configured budget.
|
|
41
|
-
if (input.summaryTokenCap && text.length > input.summaryTokenCap * 4) {
|
|
42
|
-
text = text.slice(0, input.summaryTokenCap * 4);
|
|
43
|
-
}
|
|
44
39
|
return {
|
|
45
|
-
summary: text,
|
|
40
|
+
summary: capTokens(text, input.summaryTokenCap),
|
|
46
41
|
warningCodes: localResult.warningCodes,
|
|
47
42
|
};
|
|
48
43
|
}
|
|
@@ -7,7 +7,7 @@ const MAX_FACT_LINES = 8;
|
|
|
7
7
|
* carrying a one-line description ("Ran: npm test", "Edited src/foo.ts"); fall
|
|
8
8
|
* back to the raw payload for events written by other producers.
|
|
9
9
|
*/
|
|
10
|
-
function extractEventText(event) {
|
|
10
|
+
export function extractEventText(event) {
|
|
11
11
|
try {
|
|
12
12
|
const parsed = JSON.parse(event.payload_json);
|
|
13
13
|
if (typeof parsed.text === "string" && parsed.text.trim().length > 0) {
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Bundled as JavaScript strings so the existing tsc build and npm package include every asset.
|
|
2
|
+
export const HTML = `<!doctype html>
|
|
3
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
4
|
+
<title>sessionmem · Memory viewer</title><link rel="stylesheet" href="/style.css"><script src="/app.js" defer></script></head>
|
|
5
|
+
<body><header><div class="brand"><span class="mark" aria-hidden="true">s</span><div><strong>sessionmem</strong><span class="muted">Memory viewer</span></div></div><span class="badge">Local · Read-only</span></header>
|
|
6
|
+
<main><div class="intro"><div><p class="eyebrow">YOUR PROJECT CONTEXT</p><h1>What sessionmem knows</h1><p class="muted">Explore saved context, connections, and how facts changed.</p></div><label class="project-label">Project<select id="project" aria-label="Project"></select></label></div>
|
|
7
|
+
<nav aria-label="Views"><button type="button" data-view="memories" aria-pressed="true">Memories</button><button type="button" data-view="entities" aria-pressed="false">Entities & relations</button><button type="button" data-view="history" aria-pressed="false">Supersession chains</button></nav>
|
|
8
|
+
<form id="search"><label for="query">Search memories</label><div class="search-row"><input id="query" type="search" maxlength="1000" placeholder="Search decisions, facts, warnings…"><button type="submit" class="primary">Search</button></div><label class="check"><input id="include-history" type="checkbox"> Include superseded facts in search</label></form>
|
|
9
|
+
<div class="section-heading"><h2 id="view-title">Memories</h2><button type="button" id="refresh">Refresh</button></div><p id="status" role="status" aria-live="polite">Loading your memory store…</p><div id="results"></div>
|
|
10
|
+
<div id="pagination"><button id="previous" type="button">← Previous</button><span id="page-info"></span><button id="next" type="button">Next →</button></div>
|
|
11
|
+
<footer>Served from this computer. Browsing and searching do not change your memories.</footer></main>
|
|
12
|
+
<dialog id="detail"><div class="section-heading"><h2>Memory details</h2><button id="close-detail" type="button">Close</button></div><div id="detail-content"></div></dialog></body></html>`;
|
|
13
|
+
export const CSS = `
|
|
14
|
+
:root{color-scheme:light;--ink:#202b33;--muted:#626f78;--line:#dce3e5;--accent:#176c58;--pale:#eaf4ef;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f6f8f7;color:var(--ink)}
|
|
15
|
+
*{box-sizing:border-box}body{margin:0;font-size:15px;line-height:1.6}header{background:#fff;border-bottom:1px solid var(--line);padding:18px max(24px,calc((100vw - 1160px)/2));display:flex;align-items:center;justify-content:space-between;gap:16px}.brand{display:flex;align-items:center;gap:12px}.brand strong{font-size:20px;letter-spacing:-.7px}.brand .muted{margin-left:14px;font-size:13px}.mark{display:grid;place-items:center;background:var(--accent);color:#fff;border-radius:10px;width:36px;height:36px;font:bold 28px Georgia,serif}.badge{background:var(--pale);color:#215e4d;border:1px solid #cae2d5;border-radius:20px;padding:4px 12px;font-size:12px;white-space:nowrap}main{max-width:1160px;margin:auto;padding:36px 24px}.intro{display:flex;justify-content:space-between;align-items:center;gap:24px;margin-bottom:28px}h1{font-size:32px;line-height:1.2;letter-spacing:-1.1px;margin:4px 0 12px}h2{font-size:19px;margin:0}h3{font-size:16px;margin:0 0 14px}.eyebrow{font-size:11px;font-weight:700;letter-spacing:1.6px;color:var(--accent);margin:0}.muted{color:var(--muted)}p{margin:6px 0}.project-label{font-size:12px;font-weight:600;display:grid;gap:6px;min-width:240px;max-width:40%}select,input[type=search]{background:#fff;border:1px solid #b8c5c7;border-radius:7px;padding:10px 12px;color:var(--ink);font:inherit;width:100%}nav{display:flex;gap:8px;border-bottom:1px solid var(--line);padding-bottom:12px;margin-bottom:24px;flex-wrap:wrap}button{font:inherit;cursor:pointer;color:var(--ink);background:#fff;border:1px solid var(--line);border-radius:7px;padding:7px 13px}button:hover{border-color:var(--accent);background:#f1f7f4}button[aria-pressed=true],.primary{background:var(--accent);border-color:var(--accent);color:#fff}button:disabled{opacity:.45;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #73b6a1;outline-offset:3px}#search>label:first-child{font-size:13px;font-weight:600}.search-row{display:flex;gap:10px;margin:7px 0 10px}.check{display:flex;align-items:center;gap:7px;font-size:13px;color:var(--muted)}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:26px 0 12px}#status{font-size:13px;color:var(--muted);margin-bottom:18px}#status.error{color:#a43327}#results{display:grid;gap:14px}.card{background:#fff;border:1px solid var(--line);border-radius:10px;padding:20px}.card-top{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px}.kind{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--accent);background:var(--pale);padding:2px 8px;border-radius:4px}.meta{font-size:12px;color:var(--muted)}.content{white-space:pre-wrap;overflow-wrap:anywhere;margin:10px 0 16px}.memory-link{font:12px ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--accent);padding:4px 7px;overflow-wrap:anywhere;max-width:100%;text-align:left}.source-links{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.columns{display:grid;grid-template-columns:1fr 1.4fr;gap:18px}.row{padding:14px 0;border-top:1px solid var(--line);overflow-wrap:anywhere}.row:first-child{border:0}.relation{color:var(--accent);font-size:12px;font-weight:600;margin:0 8px}.timeline{border-left:2px solid #c7ddd3;margin-left:6px;padding-left:20px}.transition{position:relative;padding:0 0 24px;overflow-wrap:anywhere}.transition:before{content:"";position:absolute;left:-26px;top:8px;width:9px;height:9px;border-radius:50%;background:var(--accent);border:1px solid #fff}.values{font-size:17px;margin:8px 0}.arrow{color:var(--accent);margin:0 10px}.empty{text-align:center;padding:48px 24px;background:#fff;border:1px dashed #c4d0cd;border-radius:10px;color:var(--muted)}#pagination{display:flex;align-items:center;justify-content:center;gap:20px;margin:24px 0}footer{text-align:center;font-size:12px;color:var(--muted);margin-top:42px}dialog{max-width:800px;width:calc(100% - 32px);max-height:85vh;border:1px solid var(--line);border-radius:12px;padding:24px;color:var(--ink)}dialog::backdrop{background:#152a2780}dialog .section-heading{margin-top:0}dl{display:grid;grid-template-columns:110px 1fr;gap:6px 12px;font-size:13px}dt{color:var(--muted)}dd{margin:0;overflow-wrap:anywhere}[hidden]{display:none!important}
|
|
16
|
+
@media(max-width:700px){header{padding:16px}.brand .muted{display:none}main{padding:24px 16px}.intro{align-items:stretch;flex-direction:column;gap:16px}.project-label{max-width:100%;min-width:0}.columns{grid-template-columns:1fr}h1{font-size:28px}.card{padding:16px}nav button{font-size:13px}}
|
|
17
|
+
`;
|
|
18
|
+
export const JS = String.raw `
|
|
19
|
+
'use strict';
|
|
20
|
+
const $ = (id) => document.getElementById(id);
|
|
21
|
+
const state = { project: '', view: 'memories', query: '', offset: 0, generation: 0 };
|
|
22
|
+
const titles = { memories: 'Memories', entities: 'Entities & relations', history: 'Supersession chains' };
|
|
23
|
+
function el(tag, text, className) {
|
|
24
|
+
const node = document.createElement(tag);
|
|
25
|
+
if (text !== undefined) node.textContent = text;
|
|
26
|
+
if (className) node.className = className;
|
|
27
|
+
return node;
|
|
28
|
+
}
|
|
29
|
+
function timestamp(value) {
|
|
30
|
+
if (!value) return 'Unknown';
|
|
31
|
+
const date = new Date(value);
|
|
32
|
+
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
|
33
|
+
}
|
|
34
|
+
async function api(path, params = {}) {
|
|
35
|
+
const url = new URL('/api/' + path, location.origin);
|
|
36
|
+
url.search = new URLSearchParams({ ...(state.project ? { project: state.project } : {}), ...params });
|
|
37
|
+
const response = await fetch(url, { cache: 'no-store' });
|
|
38
|
+
const result = await response.json();
|
|
39
|
+
if (!response.ok) throw new Error(result.error || 'Unable to load the memory store');
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
function source(id) {
|
|
43
|
+
const button = el('button', id, 'memory-link');
|
|
44
|
+
button.type = 'button';
|
|
45
|
+
button.title = 'Inspect source memory';
|
|
46
|
+
button.addEventListener('click', () => showMemory(id));
|
|
47
|
+
return button;
|
|
48
|
+
}
|
|
49
|
+
function sources(ids) {
|
|
50
|
+
const wrapper = el('div', undefined, 'source-links');
|
|
51
|
+
ids.forEach(id => wrapper.append(source(id)));
|
|
52
|
+
return wrapper;
|
|
53
|
+
}
|
|
54
|
+
async function showMemory(id) {
|
|
55
|
+
const project = state.project;
|
|
56
|
+
$('detail-content').textContent = 'Loading…';
|
|
57
|
+
if (!$('detail').open) $('detail').showModal();
|
|
58
|
+
try {
|
|
59
|
+
const { memory } = await api('memory', { id });
|
|
60
|
+
if (project !== state.project) return;
|
|
61
|
+
$('detail-content').replaceChildren(el('div', memory.content, 'content'));
|
|
62
|
+
const details = el('dl');
|
|
63
|
+
for (const key of ['id', 'kind', 'importance', 'author', 'sessionId', 'sourceAdapter', 'createdAt', 'updatedAt', 'expiresAt']) {
|
|
64
|
+
if (memory[key] != null) details.append(el('dt', key), el('dd', String(memory[key])));
|
|
65
|
+
}
|
|
66
|
+
$('detail-content').append(details);
|
|
67
|
+
} catch (error) { $('detail-content').textContent = error.message; }
|
|
68
|
+
}
|
|
69
|
+
function renderMemories(data) {
|
|
70
|
+
for (const memory of data.memories) {
|
|
71
|
+
const card = el('article', undefined, 'card');
|
|
72
|
+
const top = el('div', undefined, 'card-top');
|
|
73
|
+
top.append(el('span', memory.kind, 'kind'), el('span', 'Importance ' + memory.importance + '/10', 'meta'));
|
|
74
|
+
if (memory.score) top.append(el('span', 'Recall score ' + memory.score.total.toFixed(3), 'meta'));
|
|
75
|
+
card.append(top, el('div', memory.content, 'content'), source(memory.id), el('p', 'Updated ' + timestamp(memory.updatedAt) + (memory.author ? ' · ' + memory.author : ''), 'meta'));
|
|
76
|
+
if (memory.expiresAt) card.append(el('p', 'Expires ' + timestamp(memory.expiresAt), 'meta'));
|
|
77
|
+
$('results').append(card);
|
|
78
|
+
}
|
|
79
|
+
if (!data.memories.length) empty(state.query ? 'No matching memories.' : 'No memories in this project yet.');
|
|
80
|
+
$('status').textContent = state.query ? data.total + ' recall results · current facts by default · showing up to 50' : data.total + ' saved memories · includes superseded and expired records';
|
|
81
|
+
$('pagination').hidden = !!state.query || data.total === 0;
|
|
82
|
+
$('previous').disabled = state.offset === 0;
|
|
83
|
+
$('next').disabled = state.offset + 50 >= data.total;
|
|
84
|
+
$('page-info').textContent = (state.offset + 1) + '–' + Math.min(state.offset + 50, data.total) + ' of ' + data.total;
|
|
85
|
+
}
|
|
86
|
+
function empty(message) { $('results').append(el('div', message, 'empty')); }
|
|
87
|
+
function renderKnowledge(data) {
|
|
88
|
+
$('status').textContent = data.entities.length + ' entities · ' + data.relations.length + ' relations · source IDs open memory details';
|
|
89
|
+
if (!data.entities.length && !data.relations.length) { empty('No entities or relations captured for this project.'); return; }
|
|
90
|
+
const columns = el('div', undefined, 'columns');
|
|
91
|
+
for (const [title, rows] of [['Entities', data.entities], ['Relations', data.relations]]) {
|
|
92
|
+
const panel = el('section', undefined, 'card');
|
|
93
|
+
panel.append(el('h3', title));
|
|
94
|
+
for (const row of rows) {
|
|
95
|
+
const item = el('div', undefined, 'row');
|
|
96
|
+
if (title === 'Entities') item.append(el('span', row.kind, 'kind'), el('p', row.name));
|
|
97
|
+
else item.append(el('span', row.sourceName), el('span', row.relation + ' →', 'relation'), el('span', row.targetName), el('p', row.sourceKind + ' → ' + row.targetKind, 'meta'));
|
|
98
|
+
item.append(sources(row.memoryIds));
|
|
99
|
+
panel.append(item);
|
|
100
|
+
}
|
|
101
|
+
if (!rows.length) panel.append(el('p', 'None captured.', 'muted'));
|
|
102
|
+
columns.append(panel);
|
|
103
|
+
}
|
|
104
|
+
$('results').append(columns);
|
|
105
|
+
}
|
|
106
|
+
function renderHistory(data) {
|
|
107
|
+
$('status').textContent = data.links.length + ' replacement links · ordered by assertion time · source IDs open memory details';
|
|
108
|
+
if (!data.links.length) { empty('No supersession chains captured for this project.'); return; }
|
|
109
|
+
const groups = new Map();
|
|
110
|
+
for (const link of data.links) {
|
|
111
|
+
const key = JSON.stringify([link.entity, link.relation]);
|
|
112
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
113
|
+
groups.get(key).push(link);
|
|
114
|
+
}
|
|
115
|
+
for (const links of groups.values()) {
|
|
116
|
+
const card = el('section', undefined, 'card');
|
|
117
|
+
card.append(el('h3', links[0].entity + ' · ' + links[0].relation));
|
|
118
|
+
const timeline = el('div', undefined, 'timeline');
|
|
119
|
+
for (const link of links) {
|
|
120
|
+
const item = el('article', undefined, 'transition');
|
|
121
|
+
item.append(el('div', timestamp(link.supersededAt), 'meta'));
|
|
122
|
+
const values = el('div', undefined, 'values');
|
|
123
|
+
values.append(el('span', link.oldValue), el('span', '→', 'arrow'), el('strong', link.newValue));
|
|
124
|
+
item.append(values);
|
|
125
|
+
const refs = el('div', undefined, 'source-links');
|
|
126
|
+
refs.append(source(link.supersedes), el('span', '→', 'arrow'), source(link.supersededBy));
|
|
127
|
+
item.append(refs, el('p', (link.fullySuperseded ? 'Fully superseded' : 'Partially superseded') + ' · ' + link.reason.replaceAll('_', ' '), 'meta'), el('p', 'Detected ' + timestamp(link.detectedAt), 'meta'));
|
|
128
|
+
timeline.append(item);
|
|
129
|
+
}
|
|
130
|
+
card.append(timeline);
|
|
131
|
+
$('results').append(card);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function load() {
|
|
135
|
+
const generation = ++state.generation;
|
|
136
|
+
$('view-title').textContent = titles[state.view];
|
|
137
|
+
$('search').hidden = state.view !== 'memories';
|
|
138
|
+
$('pagination').hidden = true;
|
|
139
|
+
$('results').replaceChildren();
|
|
140
|
+
$('status').className = '';
|
|
141
|
+
$('status').textContent = 'Loading…';
|
|
142
|
+
try {
|
|
143
|
+
const data = await api(state.view === 'entities' ? 'entities' : state.view === 'history' ? 'history' : 'memories', { q: state.query, offset: String(state.offset), history: String($('include-history').checked) });
|
|
144
|
+
if (generation !== state.generation) return;
|
|
145
|
+
if (state.view === 'memories') renderMemories(data);
|
|
146
|
+
else if (state.view === 'entities') renderKnowledge(data);
|
|
147
|
+
else renderHistory(data);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (generation !== state.generation) return;
|
|
150
|
+
$('status').className = 'error';
|
|
151
|
+
$('status').textContent = error.message;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', () => {
|
|
155
|
+
state.view = button.dataset.view;
|
|
156
|
+
document.querySelectorAll('[data-view]').forEach(other => other.setAttribute('aria-pressed', String(other === button)));
|
|
157
|
+
load();
|
|
158
|
+
}));
|
|
159
|
+
$('project').addEventListener('change', () => { state.project = $('project').value; state.offset = 0; $('detail').close(); load(); });
|
|
160
|
+
$('search').addEventListener('submit', event => { event.preventDefault(); state.query = $('query').value.trim(); state.offset = 0; load(); });
|
|
161
|
+
$('query').addEventListener('input', () => { if (!$('query').value && state.query) { state.query = ''; state.offset = 0; load(); } });
|
|
162
|
+
$('include-history').addEventListener('change', () => { if (state.query) load(); });
|
|
163
|
+
$('refresh').addEventListener('click', load);
|
|
164
|
+
$('previous').addEventListener('click', () => { state.offset = Math.max(0, state.offset - 50); load(); });
|
|
165
|
+
$('next').addEventListener('click', () => { state.offset += 50; load(); });
|
|
166
|
+
$('close-detail').addEventListener('click', () => $('detail').close());
|
|
167
|
+
(async () => {
|
|
168
|
+
try {
|
|
169
|
+
const data = await api('projects');
|
|
170
|
+
state.project = data.defaultProjectId;
|
|
171
|
+
if (!data.projects.some(project => project.id === state.project)) data.projects.unshift({ id: state.project, count: 0 });
|
|
172
|
+
for (const project of data.projects) {
|
|
173
|
+
const option = el('option', project.id + ' (' + project.count + ')');
|
|
174
|
+
option.value = project.id;
|
|
175
|
+
$('project').append(option);
|
|
176
|
+
}
|
|
177
|
+
$('project').value = state.project;
|
|
178
|
+
await load();
|
|
179
|
+
} catch (error) { $('status').className = 'error'; $('status').textContent = error.message; }
|
|
180
|
+
})();
|
|
181
|
+
`;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { listProjectKnowledge } from "../core/storage/knowledgeRepo.js";
|
|
3
|
+
import { listSupersessions } from "../core/storage/supersessionRepo.js";
|
|
4
|
+
import { HTML, CSS, JS } from "./assets.js";
|
|
5
|
+
export const DEFAULT_UI_PORT = 3777;
|
|
6
|
+
export function parseUiPort(value = DEFAULT_UI_PORT) {
|
|
7
|
+
const port = Number(value);
|
|
8
|
+
if (!/^\d+$/.test(String(value)) || !Number.isInteger(port) || port < 0 || port > 65535) {
|
|
9
|
+
throw new Error("--port must be an integer from 0 to 65535 (0 selects a free port)");
|
|
10
|
+
}
|
|
11
|
+
return port;
|
|
12
|
+
}
|
|
13
|
+
function json(response, status, value) {
|
|
14
|
+
response.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
15
|
+
response.end(JSON.stringify(value));
|
|
16
|
+
}
|
|
17
|
+
function integer(value, fallback, max) {
|
|
18
|
+
if (value === null)
|
|
19
|
+
return fallback;
|
|
20
|
+
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) > max) {
|
|
21
|
+
throw new Error("Invalid pagination parameter");
|
|
22
|
+
}
|
|
23
|
+
return Number(value);
|
|
24
|
+
}
|
|
25
|
+
/** Fixed read routes only: never dispatch a caller-supplied service method. */
|
|
26
|
+
export async function startUiServer(context, port = DEFAULT_UI_PORT) {
|
|
27
|
+
parseUiPort(port);
|
|
28
|
+
let origin = "";
|
|
29
|
+
const server = createServer(async (request, response) => {
|
|
30
|
+
response.setHeader("Cache-Control", "no-store");
|
|
31
|
+
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
32
|
+
response.setHeader("Referrer-Policy", "no-referrer");
|
|
33
|
+
response.setHeader("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'");
|
|
34
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
35
|
+
response.setHeader("Allow", "GET, HEAD");
|
|
36
|
+
json(response, 405, { error: "Read-only viewer: method not allowed" });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// Block DNS rebinding and cross-origin browser reads of the unauthenticated store.
|
|
40
|
+
if (request.headers.host !== new URL(origin).host
|
|
41
|
+
|| (request.headers.origin && request.headers.origin !== origin)
|
|
42
|
+
|| request.headers["sec-fetch-site"] === "cross-site") {
|
|
43
|
+
json(response, 403, { error: "Local origin required" });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const url = new URL(request.url ?? "/", origin);
|
|
48
|
+
if (url.origin !== origin) {
|
|
49
|
+
json(response, 403, { error: "Local origin required" });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const assets = {
|
|
53
|
+
"/": ["text/html", HTML], "/style.css": ["text/css", CSS], "/app.js": ["text/javascript", JS],
|
|
54
|
+
};
|
|
55
|
+
const asset = Object.hasOwn(assets, url.pathname) ? assets[url.pathname] : undefined;
|
|
56
|
+
if (asset) {
|
|
57
|
+
response.writeHead(200, { "Content-Type": `${asset[0]}; charset=utf-8` });
|
|
58
|
+
response.end(request.method === "HEAD" ? undefined : asset[1]);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const params = url.searchParams;
|
|
62
|
+
const projectId = params.get("project") ?? context.projectId;
|
|
63
|
+
if (!projectId || projectId.length > 2000)
|
|
64
|
+
throw new Error("Invalid project ID");
|
|
65
|
+
switch (url.pathname) {
|
|
66
|
+
case "/api/projects": {
|
|
67
|
+
const projects = context.db.prepare(`SELECT project_id AS id, COUNT(*) AS count
|
|
68
|
+
FROM memories GROUP BY project_id ORDER BY project_id`).all();
|
|
69
|
+
json(response, 200, { defaultProjectId: context.projectId, projects });
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
case "/api/memories": {
|
|
73
|
+
const query = params.get("q")?.trim();
|
|
74
|
+
const limit = integer(params.get("limit"), 50, 100);
|
|
75
|
+
if (!limit)
|
|
76
|
+
throw new Error("Limit must be at least 1");
|
|
77
|
+
if (query) {
|
|
78
|
+
if (query.length > 1000)
|
|
79
|
+
throw new Error("Query must be at most 1000 characters");
|
|
80
|
+
const result = await context.service.call("retrieveMemories", {
|
|
81
|
+
projectId, query, limit, mode: "on-demand", depth: "default",
|
|
82
|
+
includeHistory: params.get("history") === "true",
|
|
83
|
+
});
|
|
84
|
+
if (!result.ok) {
|
|
85
|
+
json(response, 400, { error: result.error.message });
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
json(response, 200, { memories: result.memories, total: result.total });
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
const offset = integer(params.get("offset"), 0, Number.MAX_SAFE_INTEGER);
|
|
92
|
+
const memories = context.db.prepare(`SELECT id, kind, content, importance, author,
|
|
93
|
+
created_at AS createdAt, updated_at AS updatedAt, expires_at AS expiresAt
|
|
94
|
+
FROM memories WHERE project_id = ? ORDER BY updated_at DESC, id LIMIT ? OFFSET ?`)
|
|
95
|
+
.all(projectId, limit, offset);
|
|
96
|
+
const { total } = context.db.prepare("SELECT COUNT(*) AS total FROM memories WHERE project_id = ?")
|
|
97
|
+
.get(projectId);
|
|
98
|
+
json(response, 200, { memories, total });
|
|
99
|
+
}
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
case "/api/memory": {
|
|
103
|
+
const id = params.get("id");
|
|
104
|
+
if (!id || id.length > 200)
|
|
105
|
+
throw new Error("Invalid memory ID");
|
|
106
|
+
const result = await context.service.call("getMemory", { projectId, memoryId: id });
|
|
107
|
+
json(response, result.ok ? 200 : 404, result.ok ? { memory: result.memory } : { error: result.error.message });
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
case "/api/entities":
|
|
111
|
+
json(response, 200, listProjectKnowledge(context.db, projectId));
|
|
112
|
+
break;
|
|
113
|
+
case "/api/history":
|
|
114
|
+
json(response, 200, { links: listSupersessions(context.db, projectId) });
|
|
115
|
+
break;
|
|
116
|
+
default:
|
|
117
|
+
json(response, 404, { error: "Not found" });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
// Do not send database paths or raw SQL errors to the browser.
|
|
122
|
+
const invalid = error instanceof Error && /^(Invalid |Limit must|Query must)/.test(error.message);
|
|
123
|
+
json(response, invalid ? 400 : 500, { error: invalid ? error.message : "Unable to read the memory store" });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
// Bound incomplete requests and idle clients; close() also terminates open connections.
|
|
127
|
+
server.requestTimeout = 10000;
|
|
128
|
+
server.headersTimeout = 10000;
|
|
129
|
+
await new Promise((resolve, reject) => {
|
|
130
|
+
server.once("error", reject);
|
|
131
|
+
server.listen(port, "127.0.0.1", () => {
|
|
132
|
+
server.removeListener("error", reject);
|
|
133
|
+
const address = server.address();
|
|
134
|
+
if (!address || typeof address === "string")
|
|
135
|
+
throw new Error("Missing HTTP address");
|
|
136
|
+
origin = `http://127.0.0.1:${address.port}`;
|
|
137
|
+
resolve();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
return {
|
|
141
|
+
server, url: `${origin}/`,
|
|
142
|
+
close: () => new Promise((resolve, reject) => {
|
|
143
|
+
server.close(error => error ? reject(error) : resolve());
|
|
144
|
+
server.closeAllConnections();
|
|
145
|
+
}),
|
|
146
|
+
};
|
|
147
|
+
}
|