n2n-nexus 0.4.2 → 0.7.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 (41) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +77 -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 +44 -21
  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 +422 -545
  13. package/build/daemon/tool-outputs.js +177 -0
  14. package/build/daemon/tools.js +321 -0
  15. package/build/index.js +1 -1
  16. package/build/server/instance-id.js +19 -0
  17. package/build/server/nexus.js +243 -47
  18. package/build/storage/direct-messages.js +89 -0
  19. package/build/storage/docs.js +14 -7
  20. package/build/storage/events.js +31 -0
  21. package/build/storage/fs-utils.js +42 -0
  22. package/build/storage/global-messages.js +44 -0
  23. package/build/storage/index.js +47 -41
  24. package/build/storage/migrations.js +86 -0
  25. package/build/storage/paths.js +0 -3
  26. package/build/storage/projects.js +4 -5
  27. package/build/storage/registry.js +67 -25
  28. package/build/storage/sessions.js +77 -0
  29. package/build/storage/sqlite-meeting.js +35 -23
  30. package/build/storage/sqlite.js +73 -24
  31. package/build/storage/tasks.js +29 -103
  32. package/build/types.js +1 -1
  33. package/docs/ARCHITECTURE.md +36 -25
  34. package/docs/ARCHITECTURE_zh.md +37 -26
  35. package/docs/ASSISTANT_GUIDE.md +77 -65
  36. package/docs/README_zh.md +34 -19
  37. package/llms.txt +12 -2
  38. package/package.json +24 -14
  39. package/build/storage/logs.js +0 -60
  40. package/build/storage/meetings.js +0 -276
  41. package/build/storage/store.js +0 -153
@@ -3,38 +3,28 @@ import { FILE_ENCODING } from "../constants.js";
3
3
  import { NexusPaths } from "./paths.js";
4
4
  import { RegistryStorage } from "./registry.js";
5
5
  import { ProjectStorage } from "./projects.js";
6
- import { LogStorage } from "./logs.js";
7
6
  import { DocStorage } from "./docs.js";
7
+ import { fileWriteLock, writeFileAtomic, writeJsonAtomic } from "./fs-utils.js";
8
+ export const BLUEPRINT_PLACEHOLDER = "# Global Coordination Blueprint\n\nShared meeting space.";
8
9
  export class StorageManager {
9
10
  static initialized = false;
10
11
  // --- Path Proxies (Backward Compatibility) ---
11
12
  static get globalDir() { return NexusPaths.globalDir; }
12
13
  static get globalBlueprint() { return NexusPaths.globalBlueprint; }
13
- static get globalDiscussion() { return NexusPaths.globalDiscussion; }
14
14
  static get projectsRoot() { return NexusPaths.projectsRoot; }
15
15
  static get registryFile() { return NexusPaths.registryFile; }
16
- static get archivesDir() { return NexusPaths.archivesDir; }
17
16
  static async init() {
18
17
  if (this.initialized)
19
18
  return;
20
19
  await fs.mkdir(NexusPaths.root, { recursive: true });
21
20
  await fs.mkdir(this.globalDir, { recursive: true });
22
21
  await fs.mkdir(this.projectsRoot, { recursive: true });
23
- await fs.mkdir(this.archivesDir, { recursive: true });
24
22
  // Self-healing initialization for critical files
25
23
  await this.loadJsonSafe(this.registryFile, { projects: {} });
26
- await this.loadJsonSafe(this.globalDiscussion, []);
27
24
  if (!await this.exists(this.globalBlueprint)) {
28
- await fs.writeFile(this.globalBlueprint, "# Global Coordination Blueprint\n\nShared meeting space.");
25
+ await writeFileAtomic(this.globalBlueprint, BLUEPRINT_PLACEHOLDER);
29
26
  }
30
27
  await DocStorage.init();
31
- try {
32
- const { initTasksTable } = await import("./tasks.js");
33
- initTasksTable();
34
- }
35
- catch {
36
- /* tasks module is optional */
37
- }
38
28
  this.initialized = true;
39
29
  }
40
30
  static resetInit() {
@@ -43,7 +33,7 @@ export class StorageManager {
43
33
  static async loadJsonSafe(filePath, defaultValue) {
44
34
  try {
45
35
  if (!await this.exists(filePath)) {
46
- await fs.writeFile(filePath, JSON.stringify(defaultValue, null, 2), FILE_ENCODING);
36
+ await writeJsonAtomic(filePath, defaultValue);
47
37
  return defaultValue;
48
38
  }
49
39
  const content = await fs.readFile(filePath, FILE_ENCODING);
@@ -51,7 +41,7 @@ export class StorageManager {
51
41
  }
52
42
  catch {
53
43
  console.warn(`[Nexus Storage] Repairing corrupted file: ${filePath}`);
54
- await fs.writeFile(filePath, JSON.stringify(defaultValue, null, 2), FILE_ENCODING);
44
+ await writeJsonAtomic(filePath, defaultValue);
55
45
  return defaultValue;
56
46
  }
57
47
  }
@@ -64,42 +54,58 @@ export class StorageManager {
64
54
  return false;
65
55
  }
66
56
  }
57
+ // Every mutation of the file store goes through this lock; reads are
58
+ // lock-free because writes are atomic (temp file + rename).
59
+ static write(fn) {
60
+ return fileWriteLock.withLock(fn);
61
+ }
67
62
  // --- Registry Methods ---
68
63
  static listRegistry() { return RegistryStorage.listRegistry(); }
69
64
  static getProjectManifest(id) { return RegistryStorage.getProjectManifest(id); }
70
- static saveProjectManifest(manifest) { return RegistryStorage.saveProjectManifest(manifest); }
71
- static patchProjectManifest(id, patch) { return RegistryStorage.patchProjectManifest(id, patch); }
65
+ static saveProjectManifest(manifest) { return this.write(() => RegistryStorage.saveProjectManifest(manifest)); }
66
+ /** Manifest and internal docs in one critical section, so a concurrent delete cannot interleave. */
67
+ static saveProject(manifest, docs) {
68
+ return this.write(async () => {
69
+ await RegistryStorage.saveProjectManifest(manifest);
70
+ await ProjectStorage.saveProjectDocs(manifest.id, docs);
71
+ });
72
+ }
73
+ static patchProjectManifest(id, patch) { return this.write(() => RegistryStorage.patchProjectManifest(id, patch)); }
72
74
  static calculateTopology(projectId) { return RegistryStorage.calculateTopology(projectId); }
73
75
  // --- Project Methods ---
74
76
  static getProjectDocs(id) { return ProjectStorage.getProjectDocs(id); }
75
- static saveProjectDocs(id, content) { return ProjectStorage.saveProjectDocs(id, content); }
76
- static saveAsset(id, fileName, content) { return ProjectStorage.saveAsset(id, fileName, content); }
77
- static async deleteProject(id) {
78
- await ProjectStorage.deleteProject(id);
79
- await RegistryStorage.deleteProject(id);
77
+ static saveProjectDocs(id, content) { return this.write(() => ProjectStorage.saveProjectDocs(id, content)); }
78
+ static saveAsset(id, fileName, content) { return this.write(() => ProjectStorage.saveAsset(id, fileName, content)); }
79
+ static deleteProject(id) {
80
+ return this.write(async () => {
81
+ await ProjectStorage.deleteProject(id);
82
+ await RegistryStorage.deleteProject(id);
83
+ });
80
84
  }
81
- static async renameProject(oldId, newId) {
82
- await RegistryStorage.renameProject(oldId, newId);
83
- try {
84
- const result = await ProjectStorage.renameProject(oldId, newId);
85
- if (result === 0) {
86
- await RegistryStorage.renameProject(newId, oldId);
85
+ static renameProject(oldId, newId) {
86
+ return this.write(async () => {
87
+ if (!await RegistryStorage.getProjectManifest(oldId))
88
+ throw new Error(`Project '${oldId}' not found.`);
89
+ const renamed = await RegistryStorage.renameProject(oldId, newId);
90
+ if (renamed === 0)
91
+ throw new Error(`Project '${oldId}' could not be renamed.`);
92
+ try {
93
+ const result = await ProjectStorage.renameProject(oldId, newId);
94
+ if (result === 0)
95
+ throw new Error(`Project '${oldId}' could not be renamed.`);
96
+ return result;
87
97
  }
88
- return result;
89
- }
90
- catch (error) {
91
- await RegistryStorage.renameProject(newId, oldId).catch(() => { });
92
- throw error;
93
- }
98
+ catch (error) {
99
+ await RegistryStorage.renameProject(newId, oldId).catch(() => { });
100
+ throw error;
101
+ }
102
+ });
94
103
  }
95
- // --- Log Methods ---
96
- static addGlobalLog(from, text, category) { return LogStorage.addLog(from, text, category); }
97
- static getRecentLogs(count = 10) { return LogStorage.getLogs(count); }
98
- static pruneGlobalLogs(count) { return LogStorage.pruneLogs(count); }
99
- static clearGlobalLogs() { return LogStorage.clearLogs(); }
100
104
  // --- Doc Methods ---
101
105
  static listGlobalDocs() { return DocStorage.listDocs(); }
102
106
  static getGlobalDoc(docId) { return DocStorage.getDoc(docId); }
103
- static saveGlobalDoc(docId, title, content, updatedBy) { return DocStorage.saveDoc(docId, title, content, updatedBy); }
104
- static deleteGlobalDoc(docId) { return DocStorage.deleteDoc(docId); }
107
+ static saveGlobalDoc(docId, title, content, updatedBy) { return this.write(() => DocStorage.saveDoc(docId, title, content, updatedBy)); }
108
+ static deleteGlobalDoc(docId) { return this.write(() => DocStorage.deleteDoc(docId)); }
109
+ static getGlobalBlueprint() { return DocStorage.getBlueprint(); }
110
+ static saveGlobalBlueprint(content) { return this.write(() => DocStorage.saveBlueprint(content)); }
105
111
  }
@@ -0,0 +1,86 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { NexusPaths } from "./paths.js";
4
+ import { GlobalMessageStore } from "./global-messages.js";
5
+ import { DocStorage } from "./docs.js";
6
+ import { BLUEPRINT_PLACEHOLDER } from "./index.js";
7
+ import { getDatabase } from "./sqlite.js";
8
+ import { MESSAGE_CATEGORIES } from "../types.js";
9
+ import { FILE_ENCODING } from "../constants.js";
10
+ /**
11
+ * One-time upgrades of data written by earlier releases. Each step is
12
+ * idempotent: migrated artifacts are retired or completion is persisted.
13
+ */
14
+ /** 0.5.0 kept the global chat in global/discussion.json; import it into SQLite. */
15
+ export async function importLegacyDiscussion() {
16
+ const file = path.join(NexusPaths.globalDir, "discussion.json");
17
+ let raw;
18
+ try {
19
+ raw = await fs.readFile(file, FILE_ENCODING);
20
+ }
21
+ catch {
22
+ return 0;
23
+ }
24
+ if (GlobalMessageStore.count() > 0)
25
+ return 0;
26
+ let entries;
27
+ try {
28
+ entries = JSON.parse(raw.replace(/^\uFEFF/, ""));
29
+ }
30
+ catch {
31
+ console.warn(`[n2n-nexus] Skipping unreadable legacy chat log: ${file}`);
32
+ return 0;
33
+ }
34
+ if (!Array.isArray(entries))
35
+ return 0;
36
+ const insert = getDatabase().prepare("INSERT INTO global_messages (sender, text, category, timestamp) VALUES (?, ?, ?, ?)");
37
+ const imported = getDatabase().transaction((rows) => {
38
+ let n = 0;
39
+ for (const row of rows) {
40
+ if (!row || typeof row !== "object")
41
+ continue;
42
+ const e = row;
43
+ if (typeof e.text !== "string")
44
+ continue;
45
+ const category = MESSAGE_CATEGORIES.includes(String(e.category)) ? e.category : null;
46
+ insert.run(typeof e.from === "string" && e.from ? e.from : "unknown", e.text, category, typeof e.timestamp === "string" && e.timestamp ? e.timestamp : new Date().toISOString());
47
+ n++;
48
+ }
49
+ return n;
50
+ })(entries);
51
+ await fs.rename(file, `${file}.migrated`);
52
+ console.error(`[n2n-nexus] Imported ${imported} legacy global messages from discussion.json`);
53
+ return imported;
54
+ }
55
+ /** Title 0.5.0's update_global_strategy gave the doc it wrote; a user doc with another title is left alone. */
56
+ export const LEGACY_STRATEGY_TITLE = "Global Collaboration Strategy";
57
+ const LEGACY_STRATEGY_MIGRATION = "migration:legacy_strategy";
58
+ /**
59
+ * 0.5.0's update_global_strategy saved the strategy as the global doc
60
+ * "strategy"; it now lives in global/blueprint.md. Move it there unless the
61
+ * blueprint has already been written. Record completion even when there
62
+ * is nothing to move, so later user documents cannot become legacy data.
63
+ */
64
+ export async function importLegacyStrategy() {
65
+ const db = getDatabase();
66
+ if (db.prepare("SELECT 1 FROM meeting_state WHERE key = ?").get(LEGACY_STRATEGY_MIGRATION))
67
+ return false;
68
+ const docs = await DocStorage.listDocs();
69
+ let moved = false;
70
+ if (docs.strategy?.title === LEGACY_STRATEGY_TITLE) {
71
+ const legacy = await DocStorage.getDoc("strategy");
72
+ const current = await DocStorage.getBlueprint();
73
+ if (legacy && (!current || current === BLUEPRINT_PLACEHOLDER)) {
74
+ await DocStorage.saveBlueprint(legacy);
75
+ await DocStorage.deleteDoc("strategy");
76
+ moved = true;
77
+ console.error("[n2n-nexus] Moved legacy global doc 'strategy' into global/blueprint.md");
78
+ }
79
+ }
80
+ db.prepare("INSERT INTO meeting_state (key, value) VALUES (?, ?)").run(LEGACY_STRATEGY_MIGRATION, "complete");
81
+ return moved;
82
+ }
83
+ export async function runMigrations() {
84
+ await importLegacyDiscussion();
85
+ await importLegacyStrategy();
86
+ }
@@ -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;
@@ -0,0 +1,77 @@
1
+ import { getDatabase } from "./sqlite.js";
2
+ export const SESSION_LEASE_MS = 60_000;
3
+ function toSession(row, now) {
4
+ return {
5
+ sessionId: row.session_id, instanceId: row.instance_id, name: row.name,
6
+ clientName: row.client_name, workspace: row.workspace, projectId: row.project_id,
7
+ inbound: row.inbound, reception: "pull",
8
+ online: row.connected === 1 && now - Date.parse(row.last_seen_at) < SESSION_LEASE_MS,
9
+ createdAt: row.created_at, lastSeenAt: row.last_seen_at
10
+ };
11
+ }
12
+ export class SessionStore {
13
+ static register(input, now = Date.now()) {
14
+ const db = getDatabase();
15
+ return db.transaction(() => {
16
+ const existing = this.get(input.sessionId, now);
17
+ if (existing && existing.instanceId !== input.instanceId)
18
+ throw new Error("Session belongs to another instance");
19
+ const timestamp = new Date(now).toISOString();
20
+ if (existing) {
21
+ db.prepare("UPDATE sessions SET client_name = ?, workspace = ?, connected = 1, last_seen_at = ? WHERE session_id = ?")
22
+ .run(input.clientName ?? existing.clientName, input.workspace ?? existing.workspace, timestamp, input.sessionId);
23
+ }
24
+ else {
25
+ db.prepare("INSERT INTO sessions (session_id, instance_id, name, client_name, workspace, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
26
+ .run(input.sessionId, input.instanceId, `session-${input.sessionId.slice(0, 8)}`, input.clientName ?? "", input.workspace ?? "", timestamp, timestamp);
27
+ }
28
+ return this.get(input.sessionId, now);
29
+ })();
30
+ }
31
+ static get(sessionId, now = Date.now()) {
32
+ const row = getDatabase().prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
33
+ return row ? toSession(row, now) : null;
34
+ }
35
+ static require(sessionId, instanceId) {
36
+ const session = this.get(sessionId);
37
+ if (!session)
38
+ throw new Error(`Unknown session: ${sessionId}`);
39
+ if (instanceId !== undefined && session.instanceId !== instanceId)
40
+ throw new Error("Session belongs to another instance");
41
+ return session;
42
+ }
43
+ static list(options = {}, now = Date.now()) {
44
+ const clauses = [];
45
+ const params = [];
46
+ if (options.projectId !== undefined) {
47
+ clauses.push("project_id = ?");
48
+ params.push(options.projectId);
49
+ }
50
+ if (options.onlineOnly) {
51
+ clauses.push("connected = 1 AND last_seen_at > ?");
52
+ params.push(new Date(now - SESSION_LEASE_MS).toISOString());
53
+ }
54
+ const rows = getDatabase().prepare(`SELECT * FROM sessions ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY last_seen_at DESC, rowid DESC LIMIT ?`)
55
+ .all(...params, options.limit ?? 100);
56
+ return rows.map(row => toSession(row, now));
57
+ }
58
+ static update(sessionId, changes, now = Date.now()) {
59
+ const db = getDatabase();
60
+ return db.transaction(() => {
61
+ const existing = this.require(sessionId);
62
+ db.prepare("UPDATE sessions SET name = ?, project_id = ?, inbound = ? WHERE session_id = ?")
63
+ .run(changes.name ?? existing.name, changes.projectId === undefined ? existing.projectId : changes.projectId, changes.inbound ?? existing.inbound, sessionId);
64
+ if (changes.inbound === "refuse") {
65
+ db.prepare("UPDATE direct_messages SET status = 'expired', reason = 'Message TTL elapsed' WHERE to_session_id = ? AND status IN ('accepted', 'fetched') AND expires_at <= ?")
66
+ .run(sessionId, new Date(now).toISOString());
67
+ db.prepare("UPDATE direct_messages SET status = 'refused', reason = 'recipient_refused' WHERE to_session_id = ? AND status IN ('accepted', 'fetched')")
68
+ .run(sessionId);
69
+ }
70
+ return this.get(sessionId, now);
71
+ })();
72
+ }
73
+ static disconnect(sessionId, instanceId) {
74
+ this.require(sessionId, instanceId);
75
+ getDatabase().prepare("UPDATE sessions SET connected = 0 WHERE session_id = ?").run(sessionId);
76
+ }
77
+ }
@@ -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)