@neethan/joa 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/dist/cli/main.js +702 -0
- package/dist/cli/output.js +22 -0
- package/dist/core/bootstrap.js +39 -0
- package/dist/core/config.js +131 -0
- package/dist/core/context.js +1 -0
- package/dist/core/db.js +254 -0
- package/dist/core/entry.js +84 -0
- package/dist/core/errors.js +24 -0
- package/dist/core/formatters.js +54 -0
- package/dist/core/ids.js +26 -0
- package/dist/core/import.js +34 -0
- package/dist/core/index.js +19 -0
- package/dist/core/journal.js +65 -0
- package/dist/core/log.js +49 -0
- package/dist/core/query.js +114 -0
- package/dist/core/status.js +30 -0
- package/dist/core/sync.js +94 -0
- package/dist/core/time.js +50 -0
- package/dist/mcp/server.js +149 -0
- package/package.json +59 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const enabled = !process.env.NO_COLOR && process.stdout.isTTY === true;
|
|
2
|
+
function wrap(code, text) {
|
|
3
|
+
return enabled ? `\x1b[${code}m${text}\x1b[0m` : text;
|
|
4
|
+
}
|
|
5
|
+
export const dim = (t) => wrap("2", t);
|
|
6
|
+
export const bold = (t) => wrap("1", t);
|
|
7
|
+
export const green = (t) => wrap("32", t);
|
|
8
|
+
export const red = (t) => wrap("31", t);
|
|
9
|
+
export const yellow = (t) => wrap("33", t);
|
|
10
|
+
export const cyan = (t) => wrap("36", t);
|
|
11
|
+
/**
|
|
12
|
+
* Colorizes a compact-formatted line.
|
|
13
|
+
* Input format: "[YYYY-MM-DD HH:mm] category: summary"
|
|
14
|
+
*/
|
|
15
|
+
export function colorizeCompactLine(line) {
|
|
16
|
+
if (!enabled)
|
|
17
|
+
return line;
|
|
18
|
+
const match = line.match(/^(\[[\d-]+ [\d:]+\]) ([^:]+): (.+)$/);
|
|
19
|
+
if (!match?.[1] || !match[2])
|
|
20
|
+
return line;
|
|
21
|
+
return `${dim(match[1])} ${cyan(match[2])}: ${match[3] ?? ""}`;
|
|
22
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { getDevice, loadConfig, resolveDbPath, resolveJournalsPath } from "./config.js";
|
|
4
|
+
import { openDatabase } from "./db.js";
|
|
5
|
+
import { ValidationError } from "./errors.js";
|
|
6
|
+
import { sessionId } from "./ids.js";
|
|
7
|
+
import { checkAndSyncIfStale } from "./sync.js";
|
|
8
|
+
const AGENT_RE = /^[a-zA-Z0-9_-]+$/;
|
|
9
|
+
const AGENT_MAX_LEN = 64;
|
|
10
|
+
/** Validates an agent name: 1-64 chars, alphanumeric plus hyphens and underscores. */
|
|
11
|
+
export function validateAgentName(agent) {
|
|
12
|
+
if (agent.length === 0 || agent.length > AGENT_MAX_LEN) {
|
|
13
|
+
throw new ValidationError(`agent name must be 1-${AGENT_MAX_LEN} characters, got ${agent.length}`);
|
|
14
|
+
}
|
|
15
|
+
if (!AGENT_RE.test(agent)) {
|
|
16
|
+
throw new ValidationError(`agent name must contain only alphanumeric characters, hyphens, and underscores: "${agent}"`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function bootstrap(opts) {
|
|
20
|
+
const config = loadConfig();
|
|
21
|
+
const dbPath = resolveDbPath(config);
|
|
22
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
23
|
+
mkdirSync(resolveJournalsPath(config), { recursive: true });
|
|
24
|
+
const db = await openDatabase(dbPath);
|
|
25
|
+
await checkAndSyncIfStale(db, resolveJournalsPath(config));
|
|
26
|
+
const sid = sessionId();
|
|
27
|
+
const agent = opts?.agent ?? config.defaults.agent ?? "cli";
|
|
28
|
+
validateAgentName(agent);
|
|
29
|
+
const readCtx = { db };
|
|
30
|
+
const logCtx = {
|
|
31
|
+
db,
|
|
32
|
+
journalsDir: resolveJournalsPath(config),
|
|
33
|
+
sessionId: sid,
|
|
34
|
+
agent,
|
|
35
|
+
device: getDevice(config),
|
|
36
|
+
defaultTags: config.defaults.tags,
|
|
37
|
+
};
|
|
38
|
+
return { config, db, readCtx, logCtx, sid };
|
|
39
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir, hostname } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import yaml from "js-yaml";
|
|
5
|
+
import { ConfigError } from "./errors.js";
|
|
6
|
+
/** Returns the hardcoded default configuration. */
|
|
7
|
+
export function defaultConfig() {
|
|
8
|
+
return {
|
|
9
|
+
defaults: {
|
|
10
|
+
device: null,
|
|
11
|
+
agent: null,
|
|
12
|
+
tags: [],
|
|
13
|
+
},
|
|
14
|
+
db: { path: "~/.joa/journal.db" },
|
|
15
|
+
journals: { path: "~/.joa/journals" },
|
|
16
|
+
presets: {
|
|
17
|
+
catchup: {
|
|
18
|
+
default_limit: 50,
|
|
19
|
+
},
|
|
20
|
+
threads: { thread_limit: 20 },
|
|
21
|
+
timeline: { default_limit: 50 },
|
|
22
|
+
decisions: { categories: ["decision"], default_limit: 50 },
|
|
23
|
+
changes: { categories: ["file change"], default_limit: 50 },
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function expandTilde(p) {
|
|
28
|
+
if (p.startsWith("~/")) {
|
|
29
|
+
return join(homedir(), p.slice(2));
|
|
30
|
+
}
|
|
31
|
+
return p;
|
|
32
|
+
}
|
|
33
|
+
function loadYamlFile(filePath) {
|
|
34
|
+
if (!existsSync(filePath))
|
|
35
|
+
return null;
|
|
36
|
+
try {
|
|
37
|
+
const content = readFileSync(filePath, "utf8");
|
|
38
|
+
const parsed = yaml.load(content);
|
|
39
|
+
if (parsed === null || parsed === undefined)
|
|
40
|
+
return null;
|
|
41
|
+
if (typeof parsed !== "object") {
|
|
42
|
+
throw new ConfigError(`Failed to parse config: ${filePath}`);
|
|
43
|
+
}
|
|
44
|
+
return parsed;
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
if (e instanceof ConfigError)
|
|
48
|
+
throw e;
|
|
49
|
+
throw new ConfigError(`Failed to parse config: ${filePath}`, { cause: e });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function mergeConfig(base, overlay, additiveTags) {
|
|
53
|
+
const result = structuredClone(base);
|
|
54
|
+
const defaults = overlay.defaults;
|
|
55
|
+
if (defaults) {
|
|
56
|
+
if (defaults.device !== undefined)
|
|
57
|
+
result.defaults.device = defaults.device;
|
|
58
|
+
if (defaults.agent !== undefined)
|
|
59
|
+
result.defaults.agent = defaults.agent;
|
|
60
|
+
if (Array.isArray(defaults.tags)) {
|
|
61
|
+
if (additiveTags) {
|
|
62
|
+
result.defaults.tags = [...result.defaults.tags, ...defaults.tags];
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
result.defaults.tags = defaults.tags;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const db = overlay.db;
|
|
70
|
+
if (db?.path)
|
|
71
|
+
result.db.path = db.path;
|
|
72
|
+
const journals = overlay.journals;
|
|
73
|
+
if (journals?.path)
|
|
74
|
+
result.journals.path = journals.path;
|
|
75
|
+
const presets = overlay.presets;
|
|
76
|
+
if (presets) {
|
|
77
|
+
for (const [key, value] of Object.entries(presets)) {
|
|
78
|
+
if (value && typeof value === "object") {
|
|
79
|
+
result.presets[key] = {
|
|
80
|
+
...result.presets[key],
|
|
81
|
+
...value,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Loads and merges configuration.
|
|
90
|
+
* 1. Start with defaultConfig()
|
|
91
|
+
* 2. Merge global ~/.joa/config.yaml
|
|
92
|
+
* 3. Walk CWD upward to homedir, load nearest .joa.yaml (tags are additive)
|
|
93
|
+
*/
|
|
94
|
+
export function loadConfig(cwd) {
|
|
95
|
+
let config = defaultConfig();
|
|
96
|
+
// Global config
|
|
97
|
+
const globalPath = join(homedir(), ".joa", "config.yaml");
|
|
98
|
+
const globalOverlay = loadYamlFile(globalPath);
|
|
99
|
+
if (globalOverlay) {
|
|
100
|
+
config = mergeConfig(config, globalOverlay, false);
|
|
101
|
+
}
|
|
102
|
+
// Directory config — walk upward from cwd, stop at homedir
|
|
103
|
+
const home = homedir();
|
|
104
|
+
const startDir = resolve(cwd ?? process.cwd());
|
|
105
|
+
let dir = startDir;
|
|
106
|
+
while (true) {
|
|
107
|
+
const candidate = join(dir, ".joa.yaml");
|
|
108
|
+
const dirOverlay = loadYamlFile(candidate);
|
|
109
|
+
if (dirOverlay) {
|
|
110
|
+
config = mergeConfig(config, dirOverlay, true);
|
|
111
|
+
break; // only nearest
|
|
112
|
+
}
|
|
113
|
+
const parent = dirname(dir);
|
|
114
|
+
if (parent === dir || dir === home)
|
|
115
|
+
break;
|
|
116
|
+
dir = parent;
|
|
117
|
+
}
|
|
118
|
+
return config;
|
|
119
|
+
}
|
|
120
|
+
/** Returns the device name from config, falling back to hostname. */
|
|
121
|
+
export function getDevice(config) {
|
|
122
|
+
return config.defaults.device ?? hostname();
|
|
123
|
+
}
|
|
124
|
+
/** Resolves the database path, expanding ~ to the home directory. */
|
|
125
|
+
export function resolveDbPath(config) {
|
|
126
|
+
return expandTilde(config.db.path);
|
|
127
|
+
}
|
|
128
|
+
/** Resolves the journals directory path, expanding ~ to the home directory. */
|
|
129
|
+
export function resolveJournalsPath(config) {
|
|
130
|
+
return expandTilde(config.journals.path);
|
|
131
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/core/db.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import { statSync } from "node:fs";
|
|
10
|
+
import { serializeEntry } from "./entry.js";
|
|
11
|
+
import { DatabaseError } from "./errors.js";
|
|
12
|
+
// Runtime shim: bun:sqlite under Bun, better-sqlite3 under Node.js.
|
|
13
|
+
// Lazily resolved on first openDatabase() call so that importing this module
|
|
14
|
+
// does not trigger a top-level await — fast-exit paths (--help, --version)
|
|
15
|
+
// never pay the cost of dynamically importing the SQLite driver.
|
|
16
|
+
let _DatabaseCtor = null;
|
|
17
|
+
async function getDatabaseCtor() {
|
|
18
|
+
if (_DatabaseCtor)
|
|
19
|
+
return _DatabaseCtor;
|
|
20
|
+
// String concatenation prevents tsc/Node from resolving the bun: specifier.
|
|
21
|
+
const isBun = typeof globalThis.Bun !== "undefined";
|
|
22
|
+
if (isBun) {
|
|
23
|
+
const mod = await import(__rewriteRelativeImportExtension("bun:" + "sqlite"));
|
|
24
|
+
_DatabaseCtor = mod.Database;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
const mod = await import("better-sqlite3");
|
|
28
|
+
_DatabaseCtor = mod.default;
|
|
29
|
+
}
|
|
30
|
+
return _DatabaseCtor;
|
|
31
|
+
}
|
|
32
|
+
const SCHEMA = `
|
|
33
|
+
CREATE TABLE IF NOT EXISTS metadata (
|
|
34
|
+
key TEXT PRIMARY KEY,
|
|
35
|
+
value TEXT NOT NULL
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
39
|
+
id TEXT PRIMARY KEY,
|
|
40
|
+
timestamp TEXT NOT NULL,
|
|
41
|
+
category TEXT NOT NULL,
|
|
42
|
+
summary TEXT NOT NULL,
|
|
43
|
+
thread_id TEXT,
|
|
44
|
+
session_id TEXT,
|
|
45
|
+
agent TEXT,
|
|
46
|
+
device TEXT,
|
|
47
|
+
resources TEXT,
|
|
48
|
+
tags TEXT,
|
|
49
|
+
detail TEXT,
|
|
50
|
+
annotations TEXT,
|
|
51
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
-- NOTE: FTS5 external content table. INSERT is handled in writeEntry transaction.
|
|
55
|
+
-- DELETE/UPDATE requires manual FTS cleanup — must be implemented when entry
|
|
56
|
+
-- deletion is added. See: https://www.sqlite.org/fts5.html#external_content_tables
|
|
57
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
|
|
58
|
+
summary, detail, resources, tags,
|
|
59
|
+
content=entries,
|
|
60
|
+
content_rowid=rowid,
|
|
61
|
+
tokenize='porter'
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_entries_timestamp ON entries(timestamp DESC);
|
|
65
|
+
CREATE INDEX IF NOT EXISTS idx_entries_category ON entries(category);
|
|
66
|
+
CREATE INDEX IF NOT EXISTS idx_entries_thread ON entries(thread_id);
|
|
67
|
+
CREATE INDEX IF NOT EXISTS idx_entries_session ON entries(session_id);
|
|
68
|
+
CREATE INDEX IF NOT EXISTS idx_entries_agent ON entries(agent);
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_entries_device ON entries(device);
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_entries_thread_ts ON entries(thread_id, timestamp DESC);
|
|
71
|
+
|
|
72
|
+
CREATE VIEW IF NOT EXISTS thread_summary AS
|
|
73
|
+
SELECT
|
|
74
|
+
e.thread_id,
|
|
75
|
+
COUNT(*) AS entry_count,
|
|
76
|
+
MIN(e.timestamp) AS first_entry_at,
|
|
77
|
+
MAX(e.timestamp) AS last_active_at,
|
|
78
|
+
(SELECT summary FROM entries
|
|
79
|
+
WHERE thread_id = e.thread_id
|
|
80
|
+
ORDER BY timestamp ASC LIMIT 1) AS first_summary,
|
|
81
|
+
GROUP_CONCAT(DISTINCT e.agent) AS agents
|
|
82
|
+
FROM entries e
|
|
83
|
+
WHERE e.thread_id IS NOT NULL
|
|
84
|
+
GROUP BY e.thread_id
|
|
85
|
+
ORDER BY last_active_at DESC;
|
|
86
|
+
`;
|
|
87
|
+
function buildWhereClause(params) {
|
|
88
|
+
const conditions = [];
|
|
89
|
+
const values = [];
|
|
90
|
+
if (params.thread_id) {
|
|
91
|
+
conditions.push("e.thread_id = ?");
|
|
92
|
+
values.push(params.thread_id);
|
|
93
|
+
}
|
|
94
|
+
if (params.session_id) {
|
|
95
|
+
conditions.push("e.session_id = ?");
|
|
96
|
+
values.push(params.session_id);
|
|
97
|
+
}
|
|
98
|
+
if (params.category) {
|
|
99
|
+
conditions.push("e.category = ?");
|
|
100
|
+
values.push(params.category.trim().toLowerCase());
|
|
101
|
+
}
|
|
102
|
+
if (params.agent) {
|
|
103
|
+
conditions.push("e.agent = ?");
|
|
104
|
+
values.push(params.agent);
|
|
105
|
+
}
|
|
106
|
+
if (params.device) {
|
|
107
|
+
conditions.push("e.device = ?");
|
|
108
|
+
values.push(params.device);
|
|
109
|
+
}
|
|
110
|
+
if (params.since) {
|
|
111
|
+
conditions.push("e.timestamp >= ?");
|
|
112
|
+
values.push(params.since);
|
|
113
|
+
}
|
|
114
|
+
if (params.until) {
|
|
115
|
+
conditions.push("e.timestamp <= ?");
|
|
116
|
+
values.push(params.until);
|
|
117
|
+
}
|
|
118
|
+
if (params.tags?.length) {
|
|
119
|
+
for (const tag of params.tags) {
|
|
120
|
+
conditions.push("e.id IN (SELECT je.id FROM entries je, json_each(je.tags) AS t WHERE t.value = ?)");
|
|
121
|
+
values.push(tag);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (params.search) {
|
|
125
|
+
conditions.push("e.rowid IN (SELECT rowid FROM entries_fts WHERE entries_fts MATCH ?)");
|
|
126
|
+
values.push(params.search);
|
|
127
|
+
}
|
|
128
|
+
const sql = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
129
|
+
return { sql, values };
|
|
130
|
+
}
|
|
131
|
+
/** Opens and initializes the joa SQLite database. Returns a JoaDb instance. */
|
|
132
|
+
export async function openDatabase(dbPath) {
|
|
133
|
+
const DatabaseCtor = await getDatabaseCtor();
|
|
134
|
+
const db = new DatabaseCtor(dbPath);
|
|
135
|
+
db.exec("PRAGMA journal_mode = WAL;");
|
|
136
|
+
db.exec("PRAGMA synchronous = NORMAL;");
|
|
137
|
+
db.exec("PRAGMA foreign_keys = ON;");
|
|
138
|
+
db.exec("PRAGMA cache_size = -32000;");
|
|
139
|
+
db.exec("PRAGMA busy_timeout = 5000;");
|
|
140
|
+
db.exec(SCHEMA);
|
|
141
|
+
// Prepared statements
|
|
142
|
+
const insertEntry = db.prepare(`
|
|
143
|
+
INSERT OR IGNORE INTO entries
|
|
144
|
+
(id, timestamp, category, summary, thread_id, session_id, agent, device, resources, tags, detail, annotations)
|
|
145
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
146
|
+
`);
|
|
147
|
+
const insertFts = db.prepare(`
|
|
148
|
+
INSERT INTO entries_fts(rowid, summary, detail, resources, tags)
|
|
149
|
+
SELECT rowid, summary, detail, resources, tags FROM entries WHERE id = ?
|
|
150
|
+
`);
|
|
151
|
+
const getMetadata = db.prepare("SELECT value FROM metadata WHERE key = ?");
|
|
152
|
+
const setMetadata = db.prepare("INSERT INTO metadata (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value");
|
|
153
|
+
const countByCat = db.prepare("SELECT category, COUNT(*) as cnt FROM entries GROUP BY category");
|
|
154
|
+
const tsRange = db.prepare("SELECT MIN(timestamp) as oldest, MAX(timestamp) as newest FROM entries");
|
|
155
|
+
const threadSummary = db.prepare("SELECT * FROM thread_summary LIMIT ?");
|
|
156
|
+
return {
|
|
157
|
+
writeEntry(entry) {
|
|
158
|
+
try {
|
|
159
|
+
const row = serializeEntry(entry);
|
|
160
|
+
db.transaction(() => {
|
|
161
|
+
const result = insertEntry.run(row.id, row.timestamp, row.category, row.summary, row.thread_id, row.session_id, row.agent, row.device, row.resources, row.tags, row.detail, row.annotations);
|
|
162
|
+
if (result.changes > 0) {
|
|
163
|
+
insertFts.run(row.id);
|
|
164
|
+
}
|
|
165
|
+
})();
|
|
166
|
+
}
|
|
167
|
+
catch (cause) {
|
|
168
|
+
throw new DatabaseError("Failed to write entry to database", { cause });
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
writeEntries(entries) {
|
|
172
|
+
try {
|
|
173
|
+
db.transaction(() => {
|
|
174
|
+
for (const entry of entries) {
|
|
175
|
+
const row = serializeEntry(entry);
|
|
176
|
+
const result = insertEntry.run(row.id, row.timestamp, row.category, row.summary, row.thread_id, row.session_id, row.agent, row.device, row.resources, row.tags, row.detail, row.annotations);
|
|
177
|
+
if (result.changes > 0) {
|
|
178
|
+
insertFts.run(row.id);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
})();
|
|
182
|
+
}
|
|
183
|
+
catch (cause) {
|
|
184
|
+
throw new DatabaseError("Failed to write entries to database", { cause });
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
clearEntries() {
|
|
188
|
+
db.exec("DELETE FROM entries_fts");
|
|
189
|
+
db.exec("DELETE FROM entries");
|
|
190
|
+
},
|
|
191
|
+
queryEntries(params) {
|
|
192
|
+
const { sql: where, values } = buildWhereClause(params);
|
|
193
|
+
let query = `SELECT e.* FROM entries e ${where} ORDER BY e.timestamp DESC`;
|
|
194
|
+
if (params.limit) {
|
|
195
|
+
query += " LIMIT ?";
|
|
196
|
+
values.push(params.limit);
|
|
197
|
+
}
|
|
198
|
+
return db.prepare(query).all(...values);
|
|
199
|
+
},
|
|
200
|
+
countEntries(params) {
|
|
201
|
+
const { sql: where, values } = buildWhereClause(params);
|
|
202
|
+
const query = `SELECT COUNT(*) as cnt FROM entries e ${where}`;
|
|
203
|
+
const row = db.prepare(query).get(...values);
|
|
204
|
+
return row?.cnt ?? 0;
|
|
205
|
+
},
|
|
206
|
+
queryThreadSummary(limit) {
|
|
207
|
+
return threadSummary.all(limit);
|
|
208
|
+
},
|
|
209
|
+
countByCategory() {
|
|
210
|
+
const rows = countByCat.all();
|
|
211
|
+
const result = {};
|
|
212
|
+
for (const row of rows) {
|
|
213
|
+
result[row.category] = row.cnt;
|
|
214
|
+
}
|
|
215
|
+
return result;
|
|
216
|
+
},
|
|
217
|
+
getEntryTimestampRange() {
|
|
218
|
+
const row = tsRange.get();
|
|
219
|
+
return { oldest: row?.oldest ?? null, newest: row?.newest ?? null };
|
|
220
|
+
},
|
|
221
|
+
getLastIndexedAt() {
|
|
222
|
+
const row = getMetadata.get("last_indexed_at");
|
|
223
|
+
return row?.value ?? null;
|
|
224
|
+
},
|
|
225
|
+
setLastIndexedAt(ts) {
|
|
226
|
+
setMetadata.run("last_indexed_at", ts);
|
|
227
|
+
},
|
|
228
|
+
rebuildFts() {
|
|
229
|
+
db.exec("INSERT INTO entries_fts(entries_fts) VALUES('rebuild')");
|
|
230
|
+
},
|
|
231
|
+
getDbSizeBytes() {
|
|
232
|
+
if (dbPath === ":memory:")
|
|
233
|
+
return 0;
|
|
234
|
+
try {
|
|
235
|
+
return statSync(dbPath).size;
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
isHealthy() {
|
|
242
|
+
try {
|
|
243
|
+
const row = db.prepare("PRAGMA integrity_check").get();
|
|
244
|
+
return row?.integrity_check === "ok";
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
close() {
|
|
251
|
+
db.close();
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { InvalidThreadId, ValidationError } from "./errors.js";
|
|
2
|
+
import { isThreadId } from "./ids.js";
|
|
3
|
+
/** Normalizes a category string: lowercase + trim. Throws if empty after normalization. */
|
|
4
|
+
export function normalizeCategory(category) {
|
|
5
|
+
const normalized = category.trim().toLowerCase();
|
|
6
|
+
if (normalized.length === 0) {
|
|
7
|
+
throw new ValidationError("category must not be empty");
|
|
8
|
+
}
|
|
9
|
+
return normalized;
|
|
10
|
+
}
|
|
11
|
+
/** Converts an Entry to an EntryRow for SQLite storage. */
|
|
12
|
+
export function serializeEntry(entry) {
|
|
13
|
+
return {
|
|
14
|
+
id: entry.id,
|
|
15
|
+
timestamp: entry.timestamp,
|
|
16
|
+
category: entry.category,
|
|
17
|
+
summary: entry.summary,
|
|
18
|
+
thread_id: entry.thread_id,
|
|
19
|
+
session_id: entry.session_id,
|
|
20
|
+
agent: entry.agent,
|
|
21
|
+
device: entry.device,
|
|
22
|
+
resources: JSON.stringify(entry.resources),
|
|
23
|
+
tags: JSON.stringify(entry.tags),
|
|
24
|
+
detail: JSON.stringify(entry.detail),
|
|
25
|
+
annotations: JSON.stringify(entry.annotations),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Converts an EntryRow from SQLite back to an Entry. */
|
|
29
|
+
export function deserializeEntry(row) {
|
|
30
|
+
return {
|
|
31
|
+
id: row.id,
|
|
32
|
+
timestamp: row.timestamp,
|
|
33
|
+
category: row.category,
|
|
34
|
+
summary: row.summary,
|
|
35
|
+
thread_id: row.thread_id,
|
|
36
|
+
session_id: row.session_id,
|
|
37
|
+
agent: row.agent,
|
|
38
|
+
device: row.device,
|
|
39
|
+
resources: JSON.parse(row.resources),
|
|
40
|
+
tags: JSON.parse(row.tags),
|
|
41
|
+
detail: JSON.parse(row.detail),
|
|
42
|
+
annotations: JSON.parse(row.annotations),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Validates EntryInput. Throws ValidationError or InvalidThreadId before any I/O. */
|
|
46
|
+
export function validateEntryInput(input) {
|
|
47
|
+
// category
|
|
48
|
+
const cat = input.category?.trim().toLowerCase() ?? "";
|
|
49
|
+
if (cat.length === 0) {
|
|
50
|
+
throw new ValidationError("category must not be empty");
|
|
51
|
+
}
|
|
52
|
+
// summary
|
|
53
|
+
if (!input.summary || input.summary.trim().length === 0) {
|
|
54
|
+
throw new ValidationError("summary must not be empty");
|
|
55
|
+
}
|
|
56
|
+
// thread_id
|
|
57
|
+
if (input.thread_id !== undefined && input.thread_id !== null) {
|
|
58
|
+
if (input.thread_id !== "new" && !isThreadId(input.thread_id)) {
|
|
59
|
+
throw new InvalidThreadId(`Invalid thread_id: "${input.thread_id}". Must be null, "new", or a th_ prefixed ULID.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// tags
|
|
63
|
+
if (input.tags) {
|
|
64
|
+
for (const tag of input.tags) {
|
|
65
|
+
if (typeof tag !== "string") {
|
|
66
|
+
throw new ValidationError("tags must be strings");
|
|
67
|
+
}
|
|
68
|
+
if (tag.trim().length === 0) {
|
|
69
|
+
throw new ValidationError("tag must not be empty");
|
|
70
|
+
}
|
|
71
|
+
if (tag.includes('"') || tag.includes("\\")) {
|
|
72
|
+
throw new ValidationError('tag must not contain " or \\');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// resources
|
|
77
|
+
if (input.resources) {
|
|
78
|
+
for (const r of input.resources) {
|
|
79
|
+
if (typeof r !== "string") {
|
|
80
|
+
throw new ValidationError("resources must be strings");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Base error for all joa errors. */
|
|
2
|
+
export class JoaError extends Error {
|
|
3
|
+
name = "JoaError";
|
|
4
|
+
}
|
|
5
|
+
/** Thrown when input validation fails. */
|
|
6
|
+
export class ValidationError extends JoaError {
|
|
7
|
+
name = "ValidationError";
|
|
8
|
+
}
|
|
9
|
+
/** Thrown when a thread_id is malformed. */
|
|
10
|
+
export class InvalidThreadId extends ValidationError {
|
|
11
|
+
name = "InvalidThreadId";
|
|
12
|
+
}
|
|
13
|
+
/** Thrown when a database operation fails. */
|
|
14
|
+
export class DatabaseError extends JoaError {
|
|
15
|
+
name = "DatabaseError";
|
|
16
|
+
}
|
|
17
|
+
/** Thrown when a JSONL journal write fails. */
|
|
18
|
+
export class JournalWriteError extends JoaError {
|
|
19
|
+
name = "JournalWriteError";
|
|
20
|
+
}
|
|
21
|
+
/** Thrown when config loading or parsing fails. */
|
|
22
|
+
export class ConfigError extends JoaError {
|
|
23
|
+
name = "ConfigError";
|
|
24
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formats entries as Markdown for agent context windows.
|
|
3
|
+
* Compact enough for context efficiency; structured for readability.
|
|
4
|
+
*/
|
|
5
|
+
export function formatMd(entries) {
|
|
6
|
+
if (entries.length === 0) {
|
|
7
|
+
return "No entries found.";
|
|
8
|
+
}
|
|
9
|
+
const parts = [];
|
|
10
|
+
for (const entry of entries) {
|
|
11
|
+
const date = entry.timestamp.slice(0, 10);
|
|
12
|
+
let block = `## ${date} \u00b7 ${entry.category}\n\n`;
|
|
13
|
+
block += `**${entry.summary}**\n`;
|
|
14
|
+
const meta = [];
|
|
15
|
+
if (entry.thread_id)
|
|
16
|
+
meta.push(`Thread: ${entry.thread_id}`);
|
|
17
|
+
if (entry.session_id)
|
|
18
|
+
meta.push(`Session: ${entry.session_id}`);
|
|
19
|
+
if (entry.agent)
|
|
20
|
+
meta.push(`Agent: ${entry.agent}`);
|
|
21
|
+
if (meta.length > 0) {
|
|
22
|
+
block += `${meta.join(" \u00b7 ")}\n`;
|
|
23
|
+
}
|
|
24
|
+
if (entry.tags.length > 0) {
|
|
25
|
+
block += `Tags: ${entry.tags.join(", ")}\n`;
|
|
26
|
+
}
|
|
27
|
+
if (Object.keys(entry.detail).length > 0) {
|
|
28
|
+
block += `\nDetail: ${JSON.stringify(entry.detail)}\n`;
|
|
29
|
+
}
|
|
30
|
+
parts.push(block);
|
|
31
|
+
}
|
|
32
|
+
return parts.join("\n---\n\n");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Formats entries as a JSON string (JSON.stringify with 2-space indent).
|
|
36
|
+
*/
|
|
37
|
+
export function formatJson(entries) {
|
|
38
|
+
return JSON.stringify(entries, null, 2);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Formats entries as plain text (ANSI color to be added in Phase 1B CLI).
|
|
42
|
+
*/
|
|
43
|
+
export function formatCompact(entries) {
|
|
44
|
+
if (entries.length === 0) {
|
|
45
|
+
return "No entries found.";
|
|
46
|
+
}
|
|
47
|
+
return entries
|
|
48
|
+
.map((e) => {
|
|
49
|
+
const date = e.timestamp.slice(0, 10);
|
|
50
|
+
const time = e.timestamp.slice(11, 16);
|
|
51
|
+
return `[${date} ${time}] ${e.category}: ${e.summary}`;
|
|
52
|
+
})
|
|
53
|
+
.join("\n");
|
|
54
|
+
}
|
package/dist/core/ids.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { monotonicFactory } from "ulidx";
|
|
2
|
+
const mono = monotonicFactory();
|
|
3
|
+
/** Generate a new entry ID (e_<ulid>). */
|
|
4
|
+
export function entryId() {
|
|
5
|
+
return `e_${mono()}`;
|
|
6
|
+
}
|
|
7
|
+
/** Generate a new thread ID (th_<ulid>). */
|
|
8
|
+
export function threadId() {
|
|
9
|
+
return `th_${mono()}`;
|
|
10
|
+
}
|
|
11
|
+
/** Generate a new session ID (s_<ulid>). Call once at process start; pass through LogContext. */
|
|
12
|
+
export function sessionId() {
|
|
13
|
+
return `s_${mono()}`;
|
|
14
|
+
}
|
|
15
|
+
/** Check if a string is a valid entry ID. */
|
|
16
|
+
export function isEntryId(id) {
|
|
17
|
+
return id.startsWith("e_") && id.length === 28;
|
|
18
|
+
}
|
|
19
|
+
/** Check if a string is a valid thread ID. */
|
|
20
|
+
export function isThreadId(id) {
|
|
21
|
+
return id.startsWith("th_") && id.length === 29;
|
|
22
|
+
}
|
|
23
|
+
/** Check if a string is a valid session ID. */
|
|
24
|
+
export function isSessionId(id) {
|
|
25
|
+
return id.startsWith("s_") && id.length === 28;
|
|
26
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { deserializeEntry } from "./entry.js";
|
|
2
|
+
import { appendEntry } from "./journal.js";
|
|
3
|
+
/**
|
|
4
|
+
* Import entries from JSONL lines into the journal + database.
|
|
5
|
+
* Validates each line, skips malformed entries and duplicates.
|
|
6
|
+
*/
|
|
7
|
+
export async function importEntries(lines, db, journalsDir) {
|
|
8
|
+
const countBefore = db.countEntries({});
|
|
9
|
+
let parsedCount = 0;
|
|
10
|
+
let malformed = 0;
|
|
11
|
+
for (const line of lines) {
|
|
12
|
+
try {
|
|
13
|
+
const parsed = JSON.parse(line);
|
|
14
|
+
if (typeof parsed !== "object" ||
|
|
15
|
+
!parsed ||
|
|
16
|
+
typeof parsed.id !== "string" ||
|
|
17
|
+
typeof parsed.summary !== "string") {
|
|
18
|
+
malformed++;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const entry = deserializeEntry(parsed);
|
|
22
|
+
await appendEntry(entry, journalsDir);
|
|
23
|
+
db.writeEntry(entry);
|
|
24
|
+
parsedCount++;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
malformed++;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const countAfter = db.countEntries({});
|
|
31
|
+
const imported = countAfter - countBefore;
|
|
32
|
+
const skipped = parsedCount - imported;
|
|
33
|
+
return { imported, skipped, malformed };
|
|
34
|
+
}
|