n2n-nexus 0.4.2 → 0.6.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +74 -117
  3. package/assets/n2n-nexus-banner.jpg +0 -0
  4. package/assets/n2n-nexus-icon.png +0 -0
  5. package/assets/n2n-nexus-logo.png +0 -0
  6. package/build/client/nexus-client.js +30 -14
  7. package/build/config/cli.js +0 -3
  8. package/build/config/index.js +0 -10
  9. package/build/constants.js +7 -11
  10. package/build/daemon/index.js +35 -19
  11. package/build/daemon/rate-limit.js +62 -0
  12. package/build/daemon/server.js +349 -542
  13. package/build/daemon/tool-outputs.js +155 -0
  14. package/build/daemon/tools.js +271 -0
  15. package/build/index.js +1 -1
  16. package/build/server/instance-id.js +19 -0
  17. package/build/server/nexus.js +152 -43
  18. package/build/storage/docs.js +14 -7
  19. package/build/storage/events.js +31 -0
  20. package/build/storage/fs-utils.js +42 -0
  21. package/build/storage/global-messages.js +44 -0
  22. package/build/storage/index.js +44 -41
  23. package/build/storage/migrations.js +79 -0
  24. package/build/storage/paths.js +0 -3
  25. package/build/storage/projects.js +4 -5
  26. package/build/storage/registry.js +67 -25
  27. package/build/storage/sqlite-meeting.js +35 -23
  28. package/build/storage/sqlite.js +41 -24
  29. package/build/storage/tasks.js +29 -103
  30. package/build/types.js +1 -1
  31. package/docs/ARCHITECTURE.md +29 -20
  32. package/docs/ARCHITECTURE_zh.md +29 -20
  33. package/docs/ASSISTANT_GUIDE.md +70 -67
  34. package/docs/README_zh.md +25 -15
  35. package/llms.txt +11 -1
  36. package/package.json +24 -14
  37. package/build/storage/logs.js +0 -60
  38. package/build/storage/meetings.js +0 -276
  39. package/build/storage/store.js +0 -153
@@ -4,10 +4,8 @@ export class NexusPaths {
4
4
  static get root() { return getRootPath(); }
5
5
  static get globalDir() { return path.join(this.root, "global"); }
6
6
  static get globalBlueprint() { return path.join(this.globalDir, "blueprint.md"); }
7
- static get globalDiscussion() { return path.join(this.globalDir, "discussion.json"); }
8
7
  static get projectsRoot() { return path.join(this.root, "projects"); }
9
8
  static get registryFile() { return path.join(this.root, "registry.json"); }
10
- static get archivesDir() { return path.join(this.root, "archives"); }
11
9
  static assertSafeSegment(value, label) {
12
10
  if (!/^[a-zA-Z0-9._-]+$/.test(value) || value === "." || value === "..") {
13
11
  throw new Error(`Invalid ${label}: '${value}'. Use only letters, numbers, dots, underscores, and hyphens.`);
@@ -19,7 +17,6 @@ export class NexusPaths {
19
17
  static projectManifestPath(id) { return path.join(this.projectDir(id), "manifest.json"); }
20
18
  static projectAssetsDir(id) { return path.join(this.projectDir(id), "assets"); }
21
19
  static get dbFile() { return path.join(this.root, "nexus.db"); }
22
- static taskFile() { return path.join(this.root, "tasks.json"); }
23
20
  static docsDir() { return path.join(this.globalDir, "docs"); }
24
21
  static get docsIndexFile() { return path.join(this.globalDir, "docs_index.json"); }
25
22
  static docFile(docId) { return path.join(this.docsDir(), `${this.assertSafeSegment(docId, "document id")}.md`); }
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { NexusPaths } from "./paths.js";
4
4
  import { FILE_ENCODING } from "../constants.js";
5
+ import { writeFileAtomic, writeJsonAtomic } from "./fs-utils.js";
5
6
  export class ProjectStorage {
6
7
  static projectFilePath(projectId) {
7
8
  return {
@@ -22,15 +23,13 @@ export class ProjectStorage {
22
23
  }
23
24
  static async saveProjectDocs(projectId, content) {
24
25
  const paths = this.projectFilePath(projectId);
25
- await fs.mkdir(paths.projectDir, { recursive: true });
26
- await fs.writeFile(paths.docsPath, content, FILE_ENCODING);
26
+ await writeFileAtomic(paths.docsPath, content);
27
27
  }
28
28
  static async saveAsset(projectId, fileName, content) {
29
29
  const safeName = this.sanitizeFileName(fileName);
30
30
  const paths = this.projectFilePath(projectId);
31
- await fs.mkdir(paths.assetsDir, { recursive: true });
32
31
  const assetPath = path.join(paths.assetsDir, safeName);
33
- await fs.writeFile(assetPath, content, FILE_ENCODING);
32
+ await writeFileAtomic(assetPath, content);
34
33
  return path.relative(NexusPaths.root, assetPath);
35
34
  }
36
35
  static async deleteProject(projectId) {
@@ -60,7 +59,7 @@ export class ProjectStorage {
60
59
  const manifestRaw = await fs.readFile(newPath.manifestPath, FILE_ENCODING);
61
60
  const manifest = JSON.parse(manifestRaw);
62
61
  manifest.id = newId;
63
- await fs.writeFile(newPath.manifestPath, JSON.stringify(manifest, null, 2), FILE_ENCODING);
62
+ await writeJsonAtomic(newPath.manifestPath, manifest);
64
63
  }
65
64
  catch {
66
65
  // Manifest is optional for some flows.
@@ -1,6 +1,7 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import { NexusPaths } from "./paths.js";
3
- const emptyRegistry = { projects: {} };
3
+ import { toDict, writeJsonAtomic } from "./fs-utils.js";
4
+ const emptyRegistry = () => ({ projects: toDict() });
4
5
  async function readRegistry() {
5
6
  try {
6
7
  const raw = await fs.readFile(NexusPaths.registryFile, "utf-8");
@@ -8,17 +9,17 @@ async function readRegistry() {
8
9
  return sanitizeRegistry(parsed);
9
10
  }
10
11
  catch {
11
- return { ...emptyRegistry };
12
+ return emptyRegistry();
12
13
  }
13
14
  }
14
15
  function sanitizeRegistry(data) {
15
16
  if (!data || typeof data !== "object")
16
- return { ...emptyRegistry };
17
+ return emptyRegistry();
17
18
  const root = data;
18
19
  if (!root.projects || typeof root.projects !== "object")
19
- return { ...emptyRegistry };
20
+ return emptyRegistry();
20
21
  return {
21
- projects: Object.fromEntries(Object.entries(root.projects).map(([id, item]) => {
22
+ projects: toDict(Object.entries(root.projects).map(([id, item]) => {
22
23
  if (!item || typeof item !== "object") {
23
24
  return [id, {
24
25
  summary: "",
@@ -35,8 +36,7 @@ function sanitizeRegistry(data) {
35
36
  };
36
37
  }
37
38
  async function writeRegistry(data) {
38
- await fs.mkdir(NexusPaths.root, { recursive: true });
39
- await fs.writeFile(NexusPaths.registryFile, JSON.stringify(data, null, 2), "utf-8");
39
+ await writeJsonAtomic(NexusPaths.registryFile, data);
40
40
  }
41
41
  async function readProjectManifest(id) {
42
42
  try {
@@ -47,6 +47,46 @@ async function readProjectManifest(id) {
47
47
  return null;
48
48
  }
49
49
  }
50
+ const RELATION_TYPES = ["dependency", "parent", "child", "related"];
51
+ const str = (v, fallback = "") => (typeof v === "string" ? v : fallback);
52
+ const objects = (v) => Array.isArray(v) ? v.filter((x) => !!x && typeof x === "object" && !Array.isArray(x)) : [];
53
+ const stringRecord = (v) => {
54
+ if (!v || typeof v !== "object" || Array.isArray(v))
55
+ return undefined;
56
+ return Object.fromEntries(Object.entries(v).filter(([, x]) => typeof x === "string"));
57
+ };
58
+ /** Relations with a string targetId; an unknown or missing type reads as "related". */
59
+ export function normalizeRelations(v) {
60
+ return objects(v)
61
+ .filter((r) => typeof r.targetId === "string" && r.targetId)
62
+ .map((r) => ({
63
+ targetId: r.targetId,
64
+ type: RELATION_TYPES.includes(r.type) ? r.type : "related"
65
+ }));
66
+ }
67
+ function normalizeManifestFields(raw) {
68
+ const m = (raw ?? {});
69
+ const billing = m.billing && typeof m.billing === "object"
70
+ ? { plain: str(m.billing.plain), price: str(m.billing.price) }
71
+ : undefined;
72
+ const tiers = ["standard", "advanced", "team"];
73
+ return {
74
+ name: typeof m.name === "string" ? m.name : undefined,
75
+ description: typeof m.description === "string" ? m.description : undefined,
76
+ techStack: Array.isArray(m.techStack) ? m.techStack.filter((t) => typeof t === "string") : [],
77
+ relations: normalizeRelations(m.relations),
78
+ lastUpdated: typeof m.lastUpdated === "string" && m.lastUpdated ? m.lastUpdated : undefined,
79
+ repositoryUrl: str(m.repositoryUrl),
80
+ localPath: typeof m.localPath === "string" && m.localPath ? m.localPath : undefined,
81
+ endpoints: objects(m.endpoints).map((e) => ({ name: str(e.name), url: str(e.url), description: str(e.description) })),
82
+ apiSpec: objects(m.apiSpec).map((e) => ({ method: str(e.method), path: str(e.path), summary: str(e.summary) })),
83
+ billing,
84
+ apiDependencies: stringRecord(m.apiDependencies),
85
+ gatewayCompatibility: typeof m.gatewayCompatibility === "string" ? m.gatewayCompatibility : undefined,
86
+ api_versions: stringRecord(m.api_versions),
87
+ feature_tier: tiers.includes(m.feature_tier) ? m.feature_tier : undefined
88
+ };
89
+ }
50
90
  export class RegistryStorage {
51
91
  static async listRegistry() {
52
92
  return readRegistry();
@@ -62,29 +102,30 @@ export class RegistryStorage {
62
102
  };
63
103
  await writeRegistry(registry);
64
104
  await fs.mkdir(projectDir, { recursive: true });
65
- await fs.writeFile(manifestPath, JSON.stringify({ ...manifest, lastUpdated: manifest.lastUpdated || new Date().toISOString() }, null, 2), "utf-8");
105
+ await writeJsonAtomic(manifestPath, { ...manifest, lastUpdated: manifest.lastUpdated || new Date().toISOString() });
66
106
  }
67
107
  static async getProjectManifest(id) {
68
108
  const registry = await readRegistry();
69
109
  const entry = registry.projects[id];
70
110
  if (!entry)
71
111
  return null;
72
- const rawManifest = await readProjectManifest(id);
112
+ const m = normalizeManifestFields(await readProjectManifest(id));
73
113
  return {
74
114
  id,
75
- name: entry.name || rawManifest?.name || id,
76
- description: entry.summary || rawManifest?.description || "",
77
- techStack: rawManifest?.techStack || [],
78
- relations: rawManifest?.relations || [],
79
- lastUpdated: rawManifest?.lastUpdated || entry.lastActive,
80
- repositoryUrl: rawManifest?.repositoryUrl || "",
81
- localPath: rawManifest?.localPath || NexusPaths.projectDir(id),
82
- endpoints: rawManifest?.endpoints || [],
83
- apiSpec: rawManifest?.apiSpec || [],
84
- apiDependencies: rawManifest?.apiDependencies,
85
- gatewayCompatibility: rawManifest?.gatewayCompatibility,
86
- api_versions: rawManifest?.api_versions,
87
- feature_tier: rawManifest?.feature_tier
115
+ name: entry.name || m.name || id,
116
+ description: entry.summary || m.description || "",
117
+ techStack: m.techStack,
118
+ relations: m.relations,
119
+ lastUpdated: m.lastUpdated || entry.lastActive,
120
+ repositoryUrl: m.repositoryUrl,
121
+ localPath: m.localPath || NexusPaths.projectDir(id),
122
+ endpoints: m.endpoints,
123
+ apiSpec: m.apiSpec,
124
+ billing: m.billing,
125
+ apiDependencies: m.apiDependencies,
126
+ gatewayCompatibility: m.gatewayCompatibility,
127
+ api_versions: m.api_versions,
128
+ feature_tier: m.feature_tier
88
129
  };
89
130
  }
90
131
  static async patchProjectManifest(id, patch) {
@@ -111,12 +152,13 @@ export class RegistryStorage {
111
152
  localPath: patch.localPath ?? manifest.localPath ?? NexusPaths.projectDir(id),
112
153
  endpoints: patch.endpoints ?? manifest.endpoints ?? [],
113
154
  apiSpec: patch.apiSpec ?? manifest.apiSpec ?? [],
155
+ billing: patch.billing ?? manifest.billing,
114
156
  apiDependencies: patch.apiDependencies ?? manifest.apiDependencies,
115
157
  gatewayCompatibility: patch.gatewayCompatibility ?? manifest.gatewayCompatibility,
116
158
  api_versions: patch.api_versions ?? manifest.api_versions,
117
159
  feature_tier: patch.feature_tier ?? manifest.feature_tier
118
160
  };
119
- await fs.writeFile(NexusPaths.projectManifestPath(id), JSON.stringify(patchedManifest, null, 2), "utf-8");
161
+ await writeJsonAtomic(NexusPaths.projectManifestPath(id), patchedManifest);
120
162
  }
121
163
  static async calculateTopology(projectId) {
122
164
  const registry = await readRegistry();
@@ -127,7 +169,7 @@ export class RegistryStorage {
127
169
  name: manifest?.name || project.name || id,
128
170
  summary: project.summary,
129
171
  lastActive: project.lastActive,
130
- relations: manifest?.relations || []
172
+ relations: normalizeRelations(manifest?.relations)
131
173
  };
132
174
  }));
133
175
  const nodeMap = new Map(projectRecords.map((record) => [record.id, record]));
@@ -222,7 +264,7 @@ export class RegistryStorage {
222
264
  manifest.relations = relations;
223
265
  }
224
266
  if (changed) {
225
- await fs.writeFile(NexusPaths.projectManifestPath(manifestId), JSON.stringify(manifest, null, 2), "utf-8");
267
+ await writeJsonAtomic(NexusPaths.projectManifestPath(manifestId), manifest);
226
268
  }
227
269
  }
228
270
  return 1;
@@ -154,13 +154,7 @@ export class SqliteMeetingStore {
154
154
  const activeMeetings = this.getActiveMeetingIds().filter(id => id !== meetingId);
155
155
  this.updateState("active_meetings", JSON.stringify(activeMeetings));
156
156
  this.updateState("default_meeting", activeMeetings.length > 0 ? activeMeetings[activeMeetings.length - 1] : "");
157
- // Refresh meeting data
158
- const updatedMeeting = this.getMeeting(meetingId);
159
- // Suggest sync targets based on participants
160
- const suggestedSyncTargets = updatedMeeting.participants
161
- .map(p => p.split("@")[1])
162
- .filter((v, i, a) => v && v !== "Global" && a.indexOf(v) === i);
163
- return { meeting: updatedMeeting, suggestedSyncTargets };
157
+ return this.getMeeting(meetingId);
164
158
  }
165
159
  /**
166
160
  * Archive a meeting
@@ -182,13 +176,17 @@ export class SqliteMeetingStore {
182
176
  /**
183
177
  * Reopen a closed or archived meeting
184
178
  */
185
- static reopenMeeting(meetingId, _callerId) {
179
+ static reopenMeeting(meetingId, callerId) {
186
180
  const db = getDatabase();
187
181
  const meeting = this.getMeeting(meetingId);
188
182
  if (!meeting)
189
183
  throw new Error(`Meeting '${meetingId}' not found.`);
190
184
  if (meeting.status === "active")
191
185
  throw new Error(`Meeting '${meetingId}' is already active.`);
186
+ // Permission check: Only initiator can reopen (same rule as end/archive)
187
+ if (callerId && meeting.initiator && meeting.initiator !== callerId) {
188
+ throw new Error(`Permission denied: Only initiator (${meeting.initiator}) can reopen this meeting.`);
189
+ }
192
190
  // Update status to active
193
191
  const stmt = db.prepare("UPDATE meetings SET status = 'active', closed_at = NULL WHERE id = ?");
194
192
  stmt.run(meetingId);
@@ -204,33 +202,47 @@ export class SqliteMeetingStore {
204
202
  /**
205
203
  * List meetings with optional status filter
206
204
  */
207
- static listMeetings(status) {
205
+ static listMeetings(status, limit = 50) {
208
206
  const db = getDatabase();
209
- let query = "SELECT id, topic, status, participants, created_at FROM meetings";
210
- const params = [];
211
- if (status) {
212
- query += " WHERE status = ?";
213
- params.push(status);
214
- }
215
- query += " ORDER BY created_at DESC";
216
- const stmt = db.prepare(query);
217
- const meetings = (params.length > 0 ? stmt.all(...params) : stmt.all());
218
- return meetings.map(m => ({
207
+ const rows = db.prepare(`
208
+ SELECT m.id, m.topic, m.status, m.initiator, m.participants, m.created_at, m.closed_at, m.summary,
209
+ (SELECT COUNT(*) FROM messages WHERE meeting_id = m.id) AS message_count,
210
+ (SELECT COUNT(*) FROM decisions WHERE meeting_id = m.id) AS decision_count
211
+ FROM meetings m
212
+ WHERE (? IS NULL OR m.status = ?)
213
+ ORDER BY m.created_at DESC
214
+ LIMIT ?
215
+ `).all(status ?? null, status ?? null, limit);
216
+ return rows.map(m => ({
219
217
  id: m.id,
220
218
  topic: m.topic,
221
219
  status: m.status,
220
+ initiator: m.initiator || "Unknown",
222
221
  startTime: m.created_at,
223
- participantCount: JSON.parse(m.participants).length
222
+ endTime: m.closed_at || undefined,
223
+ participants: JSON.parse(m.participants),
224
+ messageCount: m.message_count,
225
+ decisionCount: m.decision_count,
226
+ summary: m.summary || undefined
224
227
  }));
225
228
  }
226
229
  /**
227
- * Get the current active meeting
230
+ * Id of the current default meeting, if it is still active.
231
+ * Cheap: does not load the meeting's messages.
228
232
  */
229
- static getActiveMeeting() {
233
+ static getActiveMeetingId() {
230
234
  const defaultId = this.getState("default_meeting");
231
235
  if (!defaultId)
232
236
  return null;
233
- return this.getMeeting(defaultId);
237
+ const row = getDatabase().prepare("SELECT id FROM meetings WHERE id = ? AND status = 'active'").get(defaultId);
238
+ return row?.id ?? null;
239
+ }
240
+ /**
241
+ * Get the current active meeting
242
+ */
243
+ static getActiveMeeting() {
244
+ const id = this.getActiveMeetingId();
245
+ return id ? this.getMeeting(id) : null;
234
246
  }
235
247
  /**
236
248
  * Get recent messages from a meeting (incremental read based on instance cursor)
@@ -1,5 +1,4 @@
1
1
  import Database from "better-sqlite3";
2
- import { createRequire } from "module";
3
2
  import { NexusPaths } from "./paths.js";
4
3
  let db = null;
5
4
  /**
@@ -50,6 +49,46 @@ CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp);
50
49
  CREATE INDEX IF NOT EXISTS idx_decisions_meeting ON decisions(meeting_id);
51
50
  CREATE INDEX IF NOT EXISTS idx_meetings_status ON meetings(status);
52
51
 
52
+ -- Global (non-meeting) chat stream
53
+ CREATE TABLE IF NOT EXISTS global_messages (
54
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55
+ sender TEXT NOT NULL,
56
+ text TEXT NOT NULL,
57
+ category TEXT CHECK(category IN ('MEETING_START', 'PROPOSAL', 'DECISION', 'UPDATE', 'CHAT')),
58
+ timestamp TEXT NOT NULL
59
+ );
60
+
61
+ -- Audit trail of state changes made through the daemon
62
+ CREATE TABLE IF NOT EXISTS events (
63
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
64
+ timestamp TEXT NOT NULL,
65
+ actor TEXT NOT NULL,
66
+ action TEXT NOT NULL,
67
+ target TEXT,
68
+ detail TEXT
69
+ );
70
+ CREATE INDEX IF NOT EXISTS idx_events_action ON events(action);
71
+
72
+ -- Tasks managed by assistants
73
+ CREATE TABLE IF NOT EXISTS tasks (
74
+ id TEXT PRIMARY KEY,
75
+ status TEXT NOT NULL CHECK(status IN ('pending', 'running', 'completed', 'failed', 'cancelled')) DEFAULT 'pending',
76
+ progress REAL DEFAULT 0.0 CHECK(progress >= 0.0 AND progress <= 1.0),
77
+ source_meeting_id TEXT,
78
+ metadata TEXT,
79
+ result_uri TEXT,
80
+ error_message TEXT,
81
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
82
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
83
+ ttl INTEGER,
84
+ FOREIGN KEY (source_meeting_id) REFERENCES meetings(id) ON DELETE SET NULL
85
+ );
86
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
87
+ CREATE INDEX IF NOT EXISTS idx_tasks_meeting ON tasks(source_meeting_id);
88
+ -- updateTask sets updated_at itself in ISO 8601; the old trigger overwrote it
89
+ -- with datetime('now') in a different format, breaking timestamp comparisons.
90
+ DROP TRIGGER IF EXISTS tasks_updated_at;
91
+
53
92
  -- Read cursors table (tracks each IDE's last read message per context)
54
93
  CREATE TABLE IF NOT EXISTS read_cursors (
55
94
  instance_id TEXT NOT NULL,
@@ -76,6 +115,7 @@ export function initDatabase() {
76
115
  db = new Database(dbPath);
77
116
  // Enable WAL mode for better concurrent access
78
117
  db.pragma("journal_mode = WAL");
118
+ db.pragma("foreign_keys = ON");
79
119
  // Initialize schema
80
120
  db.exec(SCHEMA);
81
121
  // Migration: Add initiator column if it doesn't exist (Upgrade from v0.1.7)
@@ -116,26 +156,3 @@ export function closeDatabase() {
116
156
  console.error("[Nexus] SQLite database closed");
117
157
  }
118
158
  }
119
- export function isSqliteAvailable() {
120
- const require = createRequire(import.meta.url);
121
- try {
122
- // Try to load better-sqlite3
123
- require("better-sqlite3");
124
- return true;
125
- }
126
- catch {
127
- return false;
128
- }
129
- }
130
- // Cleanup on process exit
131
- process.on("exit", () => {
132
- closeDatabase();
133
- });
134
- process.on("SIGINT", () => {
135
- closeDatabase();
136
- process.exit(0);
137
- });
138
- process.on("SIGTERM", () => {
139
- closeDatabase();
140
- process.exit(0);
141
- });
@@ -6,59 +6,13 @@
6
6
  */
7
7
  import { getDatabase } from "./sqlite.js";
8
8
  import { TASK_CLEANUP_MAX_AGE_MS } from "../constants.js";
9
+ export const TASK_STATUSES = ["pending", "running", "completed", "failed", "cancelled"];
9
10
  // Generate unique task ID
10
11
  function generateTaskId() {
11
12
  const timestamp = new Date().toISOString().replace(/[-:T.Z]/g, "").slice(0, 14);
12
13
  const random = Math.random().toString(36).substring(2, 6);
13
14
  return `task_${timestamp}_${random}`;
14
15
  }
15
- let initialized = false;
16
- /**
17
- * Initialize the tasks table (run migrations)
18
- */
19
- export function initTasksTable() {
20
- if (initialized)
21
- return;
22
- const db = getDatabase();
23
- const TASKS_SCHEMA = `
24
- CREATE TABLE IF NOT EXISTS tasks (
25
- id TEXT PRIMARY KEY,
26
- status TEXT NOT NULL CHECK(status IN ('pending', 'running', 'completed', 'failed', 'cancelled')) DEFAULT 'pending',
27
- progress REAL DEFAULT 0.0 CHECK(progress >= 0.0 AND progress <= 1.0),
28
- source_meeting_id TEXT,
29
- metadata TEXT,
30
- result_uri TEXT,
31
- error_message TEXT,
32
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
33
- updated_at TEXT NOT NULL DEFAULT (datetime('now')),
34
- ttl INTEGER,
35
- FOREIGN KEY (source_meeting_id) REFERENCES meetings(id) ON DELETE SET NULL
36
- );
37
-
38
- CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
39
- CREATE INDEX IF NOT EXISTS idx_tasks_meeting ON tasks(source_meeting_id);
40
- `;
41
- db.exec(TASKS_SCHEMA);
42
- // Add trigger for auto-updating updated_at (separate exec to handle IF NOT EXISTS)
43
- try {
44
- db.exec(`
45
- CREATE TRIGGER IF NOT EXISTS tasks_updated_at
46
- AFTER UPDATE ON tasks
47
- FOR EACH ROW
48
- BEGIN
49
- UPDATE tasks SET updated_at = datetime('now') WHERE id = OLD.id;
50
- END;
51
- `);
52
- }
53
- catch {
54
- // Trigger may already exist in older SQLite versions without IF NOT EXISTS support
55
- }
56
- console.error("[Nexus] Tasks table initialized");
57
- initialized = true;
58
- }
59
- export function resetTasksInit() {
60
- initialized = false;
61
- }
62
16
  /**
63
17
  * Create a new task
64
18
  */
@@ -73,14 +27,7 @@ export function createTask(input = {}) {
73
27
  stmt.run(id, input.source_meeting_id || null, input.metadata ? JSON.stringify(input.metadata) : null, now, now, input.ttl || null);
74
28
  return getTask(id);
75
29
  }
76
- /**
77
- * Get a task by ID
78
- */
79
- export function getTask(id) {
80
- const db = getDatabase();
81
- const row = db.prepare("SELECT * FROM tasks WHERE id = ?").get(id);
82
- if (!row)
83
- return null;
30
+ function rowToTask(row) {
84
31
  return {
85
32
  id: row.id,
86
33
  status: row.status,
@@ -94,6 +41,13 @@ export function getTask(id) {
94
41
  ttl: row.ttl || null
95
42
  };
96
43
  }
44
+ /**
45
+ * Get a task by ID
46
+ */
47
+ export function getTask(id) {
48
+ const row = getDatabase().prepare("SELECT * FROM tasks WHERE id = ?").get(id);
49
+ return row ? rowToTask(row) : null;
50
+ }
97
51
  /**
98
52
  * Update task status and progress
99
53
  */
@@ -141,39 +95,7 @@ export function listTasks(status, limit = 50) {
141
95
  sql += " ORDER BY created_at DESC LIMIT ?";
142
96
  params.push(limit);
143
97
  const rows = db.prepare(sql).all(...params);
144
- return rows.map(row => ({
145
- id: row.id,
146
- status: row.status,
147
- progress: row.progress,
148
- source_meeting_id: row.source_meeting_id || null,
149
- metadata: row.metadata ? JSON.parse(row.metadata) : {},
150
- result_uri: row.result_uri || null,
151
- error_message: row.error_message || null,
152
- created_at: row.created_at,
153
- updated_at: row.updated_at,
154
- ttl: row.ttl || null
155
- }));
156
- }
157
- /**
158
- * Get tasks by meeting ID
159
- */
160
- export function getTasksByMeeting(meetingId) {
161
- const db = getDatabase();
162
- const rows = db.prepare(`
163
- SELECT * FROM tasks WHERE source_meeting_id = ? ORDER BY created_at DESC
164
- `).all(meetingId);
165
- return rows.map(row => ({
166
- id: row.id,
167
- status: row.status,
168
- progress: row.progress,
169
- source_meeting_id: row.source_meeting_id || null,
170
- metadata: row.metadata ? JSON.parse(row.metadata) : {},
171
- result_uri: row.result_uri || null,
172
- error_message: row.error_message || null,
173
- created_at: row.created_at,
174
- updated_at: row.updated_at,
175
- ttl: row.ttl || null
176
- }));
98
+ return rows.map(rowToTask);
177
99
  }
178
100
  /**
179
101
  * Cancel a pending or running task
@@ -187,26 +109,30 @@ export function cancelTask(id) {
187
109
  updateTask(id, { status: "cancelled" });
188
110
  return true;
189
111
  }
112
+ /**
113
+ * Mark pending/running tasks whose TTL has elapsed as failed.
114
+ */
115
+ export function expireTasks(now = Date.now()) {
116
+ return getDatabase().prepare(`
117
+ UPDATE tasks
118
+ SET status = 'failed', error_message = 'TTL expired', updated_at = ?
119
+ WHERE status IN ('pending', 'running')
120
+ AND ttl IS NOT NULL
121
+ AND strftime('%s', created_at) * 1000 + ttl < ?
122
+ `).run(new Date(now).toISOString(), now).changes;
123
+ }
190
124
  /**
191
125
  * Delete completed/failed/cancelled tasks older than specified age
192
126
  */
193
- export function cleanupTasks(maxAgeMs = TASK_CLEANUP_MAX_AGE_MS) {
194
- const db = getDatabase();
195
- const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
196
- const result = db.prepare(`
127
+ export function cleanupTasks(maxAgeMs = TASK_CLEANUP_MAX_AGE_MS, now = Date.now()) {
128
+ const cutoff = new Date(now - maxAgeMs).toISOString();
129
+ return getDatabase().prepare(`
197
130
  DELETE FROM tasks
198
131
  WHERE status IN ('completed', 'failed', 'cancelled')
199
132
  AND updated_at < ?
200
- `).run(cutoff);
201
- return result.changes;
133
+ `).run(cutoff).changes;
202
134
  }
203
- /**
204
- * Get active (pending/running) task count
205
- */
206
- export function getActiveTaskCount() {
207
- const db = getDatabase();
208
- const row = db.prepare(`
209
- SELECT COUNT(*) as count FROM tasks WHERE status IN ('pending', 'running')
210
- `).get();
211
- return row.count;
135
+ /** Expire overdue tasks, then purge finished ones past the retention window. */
136
+ export function runTaskMaintenance(now = Date.now()) {
137
+ return { expired: expireTasks(now), removed: cleanupTasks(TASK_CLEANUP_MAX_AGE_MS, now) };
212
138
  }
package/build/types.js CHANGED
@@ -1 +1 @@
1
- export {};
1
+ export const MESSAGE_CATEGORIES = ["MEETING_START", "PROPOSAL", "DECISION", "UPDATE", "CHAT"];