@graphmemory/server 1.2.0 → 1.3.1

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 (111) hide show
  1. package/LICENSE +84 -12
  2. package/README.md +7 -1
  3. package/dist/api/index.js +151 -54
  4. package/dist/api/rest/code.js +2 -1
  5. package/dist/api/rest/docs.js +2 -1
  6. package/dist/api/rest/embed.js +8 -1
  7. package/dist/api/rest/index.js +39 -18
  8. package/dist/api/rest/knowledge.js +4 -2
  9. package/dist/api/rest/skills.js +2 -1
  10. package/dist/api/rest/tasks.js +2 -1
  11. package/dist/api/rest/tools.js +8 -1
  12. package/dist/api/rest/validation.js +41 -40
  13. package/dist/api/rest/websocket.js +24 -7
  14. package/dist/api/tools/code/search-code.js +12 -9
  15. package/dist/api/tools/code/search-files.js +1 -1
  16. package/dist/api/tools/docs/cross-references.js +3 -2
  17. package/dist/api/tools/docs/explain-symbol.js +2 -1
  18. package/dist/api/tools/docs/find-examples.js +2 -1
  19. package/dist/api/tools/docs/search-files.js +1 -1
  20. package/dist/api/tools/docs/search-snippets.js +1 -1
  21. package/dist/api/tools/docs/search.js +5 -4
  22. package/dist/api/tools/file-index/search-all-files.js +1 -1
  23. package/dist/api/tools/knowledge/add-attachment.js +15 -3
  24. package/dist/api/tools/knowledge/remove-attachment.js +5 -1
  25. package/dist/api/tools/knowledge/search-notes.js +5 -4
  26. package/dist/api/tools/skills/add-attachment.js +15 -3
  27. package/dist/api/tools/skills/recall-skills.js +1 -1
  28. package/dist/api/tools/skills/remove-attachment.js +5 -1
  29. package/dist/api/tools/skills/search-skills.js +6 -5
  30. package/dist/api/tools/tasks/add-attachment.js +15 -3
  31. package/dist/api/tools/tasks/remove-attachment.js +5 -1
  32. package/dist/api/tools/tasks/search-tasks.js +5 -4
  33. package/dist/cli/index.js +63 -52
  34. package/dist/cli/indexer.js +62 -29
  35. package/dist/graphs/attachment-types.js +5 -0
  36. package/dist/graphs/code.js +99 -10
  37. package/dist/graphs/docs.js +20 -5
  38. package/dist/graphs/file-index.js +22 -6
  39. package/dist/graphs/file-lang.js +1 -1
  40. package/dist/graphs/knowledge.js +31 -7
  41. package/dist/graphs/skill.js +35 -9
  42. package/dist/graphs/task.js +35 -9
  43. package/dist/lib/defaults.js +78 -0
  44. package/dist/lib/embedder.js +11 -12
  45. package/dist/lib/embedding-codec.js +63 -0
  46. package/dist/lib/graph-persistence.js +68 -0
  47. package/dist/lib/jwt.js +4 -4
  48. package/dist/lib/mirror-watcher.js +4 -3
  49. package/dist/lib/multi-config.js +6 -1
  50. package/dist/lib/parsers/code.js +158 -31
  51. package/dist/lib/parsers/codeblock.js +11 -6
  52. package/dist/lib/parsers/docs.js +60 -31
  53. package/dist/lib/parsers/languages/registry.js +2 -2
  54. package/dist/lib/parsers/languages/typescript.js +214 -46
  55. package/dist/lib/project-manager.js +21 -11
  56. package/dist/lib/search/bm25.js +23 -5
  57. package/dist/lib/search/code.js +13 -3
  58. package/dist/lib/search/docs.js +2 -1
  59. package/dist/lib/search/file-index.js +2 -1
  60. package/dist/lib/search/files.js +3 -2
  61. package/dist/lib/search/knowledge.js +2 -1
  62. package/dist/lib/search/skills.js +2 -1
  63. package/dist/lib/search/tasks.js +2 -1
  64. package/dist/ui/assets/NoteForm-aZX9f6-3.js +1 -0
  65. package/dist/ui/assets/SkillForm-KYa3o92l.js +1 -0
  66. package/dist/ui/assets/TaskForm-Bl5nkybO.js +1 -0
  67. package/dist/ui/assets/_articleId_-DjbCByxM.js +1 -0
  68. package/dist/ui/assets/_docId_-hdCDjclV.js +1 -0
  69. package/dist/ui/assets/_filePath_-CpG836v4.js +1 -0
  70. package/dist/ui/assets/_noteId_-C1enaQd1.js +1 -0
  71. package/dist/ui/assets/_skillId_-hPoCet7J.js +1 -0
  72. package/dist/ui/assets/_taskId_-DSB3dLVz.js +1 -0
  73. package/dist/ui/assets/_toolName_-3SmCfxZy.js +2 -0
  74. package/dist/ui/assets/api-BMnBjMMf.js +1 -0
  75. package/dist/ui/assets/api-BlFF6gX-.js +1 -0
  76. package/dist/ui/assets/api-CrGJOcaN.js +1 -0
  77. package/dist/ui/assets/api-DuX-0a_X.js +1 -0
  78. package/dist/ui/assets/attachments-CEQ-2nMo.js +1 -0
  79. package/dist/ui/assets/client-Bq88u7gN.js +1 -0
  80. package/dist/ui/assets/docs-CrXsRcOG.js +1 -0
  81. package/dist/ui/assets/edit-BYiy1FZy.js +1 -0
  82. package/dist/ui/assets/edit-TUIIpUMF.js +1 -0
  83. package/dist/ui/assets/edit-hc-ZWz3y.js +1 -0
  84. package/dist/ui/assets/esm-BWiKNcBW.js +1 -0
  85. package/dist/ui/assets/files-0bPg6NH9.js +1 -0
  86. package/dist/ui/assets/graph-DXGud_wF.js +1 -0
  87. package/dist/ui/assets/help-CEMQqZUR.js +891 -0
  88. package/dist/ui/assets/help-DJ52_fxN.js +1 -0
  89. package/dist/ui/assets/index-BCZDAYZi.js +2 -0
  90. package/dist/ui/assets/index-D6zSNtzo.css +1 -0
  91. package/dist/ui/assets/knowledge-DeygeGGH.js +1 -0
  92. package/dist/ui/assets/new-CpD7hOBA.js +1 -0
  93. package/dist/ui/assets/new-DHTg3Dqq.js +1 -0
  94. package/dist/ui/assets/new-s8c0M75X.js +1 -0
  95. package/dist/ui/assets/prompts-BgOmdxgM.js +295 -0
  96. package/dist/ui/assets/rolldown-runtime-Dw2cE7zH.js +1 -0
  97. package/dist/ui/assets/search-EpJhdP2a.js +1 -0
  98. package/dist/ui/assets/skill-y9pizyqE.js +1 -0
  99. package/dist/ui/assets/skills-Cga9iUZN.js +1 -0
  100. package/dist/ui/assets/tasks-CobouTKV.js +1 -0
  101. package/dist/ui/assets/tools-JxKH5BDF.js +1 -0
  102. package/dist/ui/assets/vendor-graph-BWpSgpMe.js +321 -0
  103. package/dist/ui/assets/vendor-markdown-CT8ZVEPu.js +50 -0
  104. package/dist/ui/assets/vendor-md-editor-DmWafJvr.js +44 -0
  105. package/dist/ui/assets/{index-kKd4mVrh.css → vendor-md-editor-HrwGbQou.css} +1 -1
  106. package/dist/ui/assets/vendor-mui-BPj7d3Sw.js +139 -0
  107. package/dist/ui/assets/vendor-mui-icons-B196sG3f.js +1 -0
  108. package/dist/ui/assets/vendor-react-CHUjhoxh.js +11 -0
  109. package/dist/ui/index.html +11 -3
  110. package/package.json +6 -3
  111. package/dist/ui/assets/index-0hRezICt.js +0 -1702
@@ -7,6 +7,7 @@ exports.register = register;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const zod_1 = require("zod");
10
+ const defaults_1 = require("../../../lib/defaults");
10
11
  function register(server, mgr) {
11
12
  server.registerTool('add_note_attachment', {
12
13
  description: 'Attach a file to a note. Provide the absolute path to a local file. ' +
@@ -17,11 +18,22 @@ function register(server, mgr) {
17
18
  filePath: zod_1.z.string().describe('Absolute path to the file on disk'),
18
19
  },
19
20
  }, async ({ noteId, filePath }) => {
20
- if (!fs_1.default.existsSync(filePath)) {
21
+ const resolved = path_1.default.resolve(filePath);
22
+ let stat;
23
+ try {
24
+ stat = fs_1.default.statSync(resolved);
25
+ }
26
+ catch {
21
27
  return { content: [{ type: 'text', text: JSON.stringify({ error: 'File not found' }) }], isError: true };
22
28
  }
23
- const data = fs_1.default.readFileSync(filePath);
24
- const filename = path_1.default.basename(filePath);
29
+ if (!stat.isFile()) {
30
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'Path is not a regular file' }) }], isError: true };
31
+ }
32
+ if (stat.size > defaults_1.MAX_UPLOAD_SIZE) {
33
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'File exceeds 50 MB limit' }) }], isError: true };
34
+ }
35
+ const data = fs_1.default.readFileSync(resolved);
36
+ const filename = path_1.default.basename(resolved);
25
37
  const meta = mgr.addAttachment(noteId, filename, data);
26
38
  if (!meta) {
27
39
  return { content: [{ type: 'text', text: JSON.stringify({ error: 'Note not found or no project dir' }) }], isError: true };
@@ -7,7 +7,11 @@ function register(server, mgr) {
7
7
  description: 'Remove an attachment from a note. The file is deleted from disk.',
8
8
  inputSchema: {
9
9
  noteId: zod_1.z.string().describe('ID of the note'),
10
- filename: zod_1.z.string().describe('Filename of the attachment to remove'),
10
+ filename: zod_1.z.string().min(1).max(255)
11
+ .refine(s => !/[/\\]/.test(s), 'Filename must not contain path separators')
12
+ .refine(s => !s.includes('..'), 'Filename must not contain ..')
13
+ .refine(s => !s.includes('\0'), 'Filename must not contain null bytes')
14
+ .describe('Filename of the attachment to remove'),
11
15
  },
12
16
  }, async ({ noteId, filename }) => {
13
17
  const ok = mgr.removeAttachment(noteId, filename);
@@ -5,15 +5,16 @@ const zod_1 = require("zod");
5
5
  function register(server, mgr) {
6
6
  server.registerTool('search_notes', {
7
7
  description: 'Semantic search over the knowledge graph (facts and notes). ' +
8
- 'Finds the most relevant notes using vector similarity, then expands results ' +
8
+ 'Supports three modes: hybrid (default, BM25 + vector), vector, keyword. ' +
9
+ 'Finds the most relevant notes, then expands results ' +
9
10
  'by traversing relations between notes (graph walk). ' +
10
11
  'Returns an array sorted by relevance score (0–1), each with: ' +
11
12
  'id, title, content, tags, score.',
12
13
  inputSchema: {
13
14
  query: zod_1.z.string().describe('Natural language search query'),
14
- topK: zod_1.z.number().optional().describe('How many top similar notes to use as seeds (default 5)'),
15
- bfsDepth: zod_1.z.number().optional().describe('How many hops to follow relations from each seed (default 1; 0 = no expansion)'),
16
- maxResults: zod_1.z.number().optional().describe('Maximum number of results to return (default 20)'),
15
+ topK: zod_1.z.number().min(1).max(500).optional().describe('How many top similar notes to use as seeds (default 5)'),
16
+ bfsDepth: zod_1.z.number().min(0).max(10).optional().describe('How many hops to follow relations from each seed (default 1; 0 = no expansion)'),
17
+ maxResults: zod_1.z.number().min(1).max(500).optional().describe('Maximum number of results to return (default 20)'),
17
18
  minScore: zod_1.z.number().min(0).max(1).optional().describe('Minimum relevance score 0–1 (default 0.5)'),
18
19
  bfsDecay: zod_1.z.number().min(0).max(1).optional().describe('Score multiplier per hop (default 0.8)'),
19
20
  searchMode: zod_1.z.enum(['hybrid', 'vector', 'keyword']).optional().describe('Search mode: hybrid (default, BM25 + vector), vector (embedding only), keyword (BM25 only)'),
@@ -7,6 +7,7 @@ exports.register = register;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const zod_1 = require("zod");
10
+ const defaults_1 = require("../../../lib/defaults");
10
11
  function register(server, mgr) {
11
12
  server.registerTool('add_skill_attachment', {
12
13
  description: 'Attach a file to a skill. Provide the absolute path to a local file. ' +
@@ -17,11 +18,22 @@ function register(server, mgr) {
17
18
  filePath: zod_1.z.string().describe('Absolute path to the file on disk'),
18
19
  },
19
20
  }, async ({ skillId, filePath }) => {
20
- if (!fs_1.default.existsSync(filePath)) {
21
+ const resolved = path_1.default.resolve(filePath);
22
+ let stat;
23
+ try {
24
+ stat = fs_1.default.statSync(resolved);
25
+ }
26
+ catch {
21
27
  return { content: [{ type: 'text', text: JSON.stringify({ error: 'File not found' }) }], isError: true };
22
28
  }
23
- const data = fs_1.default.readFileSync(filePath);
24
- const filename = path_1.default.basename(filePath);
29
+ if (!stat.isFile()) {
30
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'Path is not a regular file' }) }], isError: true };
31
+ }
32
+ if (stat.size > defaults_1.MAX_UPLOAD_SIZE) {
33
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'File exceeds 50 MB limit' }) }], isError: true };
34
+ }
35
+ const data = fs_1.default.readFileSync(resolved);
36
+ const filename = path_1.default.basename(resolved);
25
37
  const meta = mgr.addAttachment(skillId, filename, data);
26
38
  if (!meta) {
27
39
  return { content: [{ type: 'text', text: JSON.stringify({ error: 'Skill not found or no project dir' }) }], isError: true };
@@ -8,7 +8,7 @@ function register(server, mgr) {
8
8
  'minScore default (0.3) for higher recall. Use at the start of a task to find applicable recipes.',
9
9
  inputSchema: {
10
10
  context: zod_1.z.string().describe('Description of the current task or context to match skills against'),
11
- topK: zod_1.z.number().optional().describe('How many top similar skills to use as seeds (default 5)'),
11
+ topK: zod_1.z.number().min(1).max(500).optional().describe('How many top similar skills to use as seeds (default 5)'),
12
12
  minScore: zod_1.z.number().min(0).max(1).optional().describe('Minimum relevance score 0–1 (default 0.3)'),
13
13
  },
14
14
  }, async ({ context, topK, minScore }) => {
@@ -7,7 +7,11 @@ function register(server, mgr) {
7
7
  description: 'Remove an attachment from a skill. The file is deleted from disk.',
8
8
  inputSchema: {
9
9
  skillId: zod_1.z.string().describe('ID of the skill'),
10
- filename: zod_1.z.string().describe('Filename of the attachment to remove'),
10
+ filename: zod_1.z.string().min(1).max(255)
11
+ .refine(s => !/[/\\]/.test(s), 'Filename must not contain path separators')
12
+ .refine(s => !s.includes('..'), 'Filename must not contain ..')
13
+ .refine(s => !s.includes('\0'), 'Filename must not contain null bytes')
14
+ .describe('Filename of the attachment to remove'),
11
15
  },
12
16
  }, async ({ skillId, filename }) => {
13
17
  const ok = mgr.removeAttachment(skillId, filename);
@@ -5,15 +5,16 @@ const zod_1 = require("zod");
5
5
  function register(server, mgr) {
6
6
  server.registerTool('search_skills', {
7
7
  description: 'Semantic search over the skill graph. ' +
8
- 'Finds the most relevant skills using vector similarity, then expands results ' +
8
+ 'Supports three modes: hybrid (default, BM25 + vector), vector, keyword. ' +
9
+ 'Finds the most relevant skills, then expands results ' +
9
10
  'by traversing relations between skills (graph walk). ' +
10
11
  'Returns an array sorted by relevance score (0–1), each with: ' +
11
- 'id, title, description, tags, source, confidence, score.',
12
+ 'id, title, description, tags, source, confidence, usageCount, score.',
12
13
  inputSchema: {
13
14
  query: zod_1.z.string().describe('Natural language search query'),
14
- topK: zod_1.z.number().optional().describe('How many top similar skills to use as seeds (default 5)'),
15
- bfsDepth: zod_1.z.number().optional().describe('How many hops to follow relations from each seed (default 1; 0 = no expansion)'),
16
- maxResults: zod_1.z.number().optional().describe('Maximum number of results to return (default 20)'),
15
+ topK: zod_1.z.number().min(1).max(500).optional().describe('How many top similar skills to use as seeds (default 5)'),
16
+ bfsDepth: zod_1.z.number().min(0).max(10).optional().describe('How many hops to follow relations from each seed (default 1; 0 = no expansion)'),
17
+ maxResults: zod_1.z.number().min(1).max(500).optional().describe('Maximum number of results to return (default 20)'),
17
18
  minScore: zod_1.z.number().min(0).max(1).optional().describe('Minimum relevance score 0–1 (default 0.5)'),
18
19
  bfsDecay: zod_1.z.number().min(0).max(1).optional().describe('Score multiplier per hop (default 0.8)'),
19
20
  searchMode: zod_1.z.enum(['hybrid', 'vector', 'keyword']).optional().describe('Search mode: hybrid (default, BM25 + vector), vector (embedding only), keyword (BM25 only)'),
@@ -7,6 +7,7 @@ exports.register = register;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const zod_1 = require("zod");
10
+ const defaults_1 = require("../../../lib/defaults");
10
11
  function register(server, mgr) {
11
12
  server.registerTool('add_task_attachment', {
12
13
  description: 'Attach a file to a task. Provide the absolute path to a local file. ' +
@@ -17,11 +18,22 @@ function register(server, mgr) {
17
18
  filePath: zod_1.z.string().describe('Absolute path to the file on disk'),
18
19
  },
19
20
  }, async ({ taskId, filePath }) => {
20
- if (!fs_1.default.existsSync(filePath)) {
21
+ const resolved = path_1.default.resolve(filePath);
22
+ let stat;
23
+ try {
24
+ stat = fs_1.default.statSync(resolved);
25
+ }
26
+ catch {
21
27
  return { content: [{ type: 'text', text: JSON.stringify({ error: 'File not found' }) }], isError: true };
22
28
  }
23
- const data = fs_1.default.readFileSync(filePath);
24
- const filename = path_1.default.basename(filePath);
29
+ if (!stat.isFile()) {
30
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'Path is not a regular file' }) }], isError: true };
31
+ }
32
+ if (stat.size > defaults_1.MAX_UPLOAD_SIZE) {
33
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'File exceeds 50 MB limit' }) }], isError: true };
34
+ }
35
+ const data = fs_1.default.readFileSync(resolved);
36
+ const filename = path_1.default.basename(resolved);
25
37
  const meta = mgr.addAttachment(taskId, filename, data);
26
38
  if (!meta) {
27
39
  return { content: [{ type: 'text', text: JSON.stringify({ error: 'Task not found or no project dir' }) }], isError: true };
@@ -7,7 +7,11 @@ function register(server, mgr) {
7
7
  description: 'Remove an attachment from a task. The file is deleted from disk.',
8
8
  inputSchema: {
9
9
  taskId: zod_1.z.string().describe('ID of the task'),
10
- filename: zod_1.z.string().describe('Filename of the attachment to remove'),
10
+ filename: zod_1.z.string().min(1).max(255)
11
+ .refine(s => !/[/\\]/.test(s), 'Filename must not contain path separators')
12
+ .refine(s => !s.includes('..'), 'Filename must not contain ..')
13
+ .refine(s => !s.includes('\0'), 'Filename must not contain null bytes')
14
+ .describe('Filename of the attachment to remove'),
11
15
  },
12
16
  }, async ({ taskId, filename }) => {
13
17
  const ok = mgr.removeAttachment(taskId, filename);
@@ -5,15 +5,16 @@ const zod_1 = require("zod");
5
5
  function register(server, mgr) {
6
6
  server.registerTool('search_tasks', {
7
7
  description: 'Semantic search over the task graph. ' +
8
- 'Finds the most relevant tasks using vector similarity, then expands results ' +
8
+ 'Supports three modes: hybrid (default, BM25 + vector), vector, keyword. ' +
9
+ 'Finds the most relevant tasks, then expands results ' +
9
10
  'by traversing relations between tasks (graph walk). ' +
10
11
  'Returns an array sorted by relevance score (0–1), each with: ' +
11
12
  'id, title, description, status, priority, tags, score.',
12
13
  inputSchema: {
13
14
  query: zod_1.z.string().describe('Natural language search query'),
14
- topK: zod_1.z.number().optional().describe('How many top similar tasks to use as seeds (default 5)'),
15
- bfsDepth: zod_1.z.number().optional().describe('How many hops to follow relations from each seed (default 1; 0 = no expansion)'),
16
- maxResults: zod_1.z.number().optional().describe('Maximum number of results to return (default 20)'),
15
+ topK: zod_1.z.number().min(1).max(500).optional().describe('How many top similar tasks to use as seeds (default 5)'),
16
+ bfsDepth: zod_1.z.number().min(0).max(10).optional().describe('How many hops to follow relations from each seed (default 1; 0 = no expansion)'),
17
+ maxResults: zod_1.z.number().min(1).max(500).optional().describe('Maximum number of results to return (default 20)'),
17
18
  minScore: zod_1.z.number().min(0).max(1).optional().describe('Minimum relevance score 0–1 (default 0.5)'),
18
19
  bfsDecay: zod_1.z.number().min(0).max(1).optional().describe('Score multiplier per hop (default 0.8)'),
19
20
  searchMode: zod_1.z.enum(['hybrid', 'vector', 'keyword']).optional().describe('Search mode: hybrid (default, BM25 + vector), vector (embedding only), keyword (BM25 only)'),
package/dist/cli/index.js CHANGED
@@ -14,11 +14,12 @@ const jwt_1 = require("../lib/jwt");
14
14
  const project_manager_1 = require("../lib/project-manager");
15
15
  const embedder_1 = require("../lib/embedder");
16
16
  const index_1 = require("../api/index");
17
+ const defaults_1 = require("../lib/defaults");
17
18
  const program = new commander_1.Command();
18
19
  program
19
20
  .name('graphmemory')
20
21
  .description('MCP server for semantic graph memory from markdown docs and source code')
21
- .version('1.2.0');
22
+ .version('1.3.1');
22
23
  const parseIntArg = (v) => parseInt(v, 10);
23
24
  // ---------------------------------------------------------------------------
24
25
  // Helper: load config from file, or fall back to default (cwd as single project)
@@ -146,7 +147,48 @@ program
146
147
  }
147
148
  // Embedding API model name (loaded in background with other models)
148
149
  const embeddingApiModelName = mc.server.embeddingApi?.enabled ? '__server__' : undefined;
149
- // Start HTTP server immediately (before models are loaded)
150
+ // Load models and index all projects before starting HTTP
151
+ // Load embedding API model if enabled
152
+ if (embeddingApiModelName) {
153
+ try {
154
+ await (0, embedder_1.loadModel)(mc.server.model, mc.server.embedding, mc.server.modelsDir, embeddingApiModelName);
155
+ process.stderr.write(`[serve] Embedding API model ready\n`);
156
+ }
157
+ catch (err) {
158
+ process.stderr.write(`[serve] Failed to load embedding API model: ${err}\n`);
159
+ }
160
+ }
161
+ // Load workspace models
162
+ for (const wsId of manager.listWorkspaces()) {
163
+ try {
164
+ await manager.loadWorkspaceModels(wsId);
165
+ }
166
+ catch (err) {
167
+ process.stderr.write(`[serve] Failed to load workspace "${wsId}" models: ${err}\n`);
168
+ }
169
+ }
170
+ // Load project models and start indexing
171
+ for (const id of manager.listProjects()) {
172
+ try {
173
+ await manager.loadModels(id);
174
+ await manager.startIndexing(id);
175
+ }
176
+ catch (err) {
177
+ process.stderr.write(`[serve] Failed to initialize project "${id}": ${err}\n`);
178
+ }
179
+ }
180
+ // Start workspace mirror watchers (after all projects are indexed)
181
+ for (const wsId of manager.listWorkspaces()) {
182
+ try {
183
+ await manager.startWorkspaceMirror(wsId);
184
+ }
185
+ catch (err) {
186
+ process.stderr.write(`[serve] Failed to start workspace "${wsId}" mirror: ${err}\n`);
187
+ }
188
+ }
189
+ // Start auto-save
190
+ manager.startAutoSave();
191
+ // Start HTTP server (all models loaded, all projects indexed)
150
192
  const httpServer = await (0, index_1.startMultiProjectHttpServer)(host, port, sessionTimeoutMs, manager, {
151
193
  serverConfig: mc.server,
152
194
  users: mc.users,
@@ -158,52 +200,6 @@ program
158
200
  openSockets.add(socket);
159
201
  socket.on('close', () => openSockets.delete(socket));
160
202
  });
161
- // Start auto-save
162
- manager.startAutoSave();
163
- // Load models and start indexing in background (workspaces first, then projects)
164
- async function initProjects() {
165
- // Load embedding API model if enabled
166
- if (embeddingApiModelName) {
167
- try {
168
- await (0, embedder_1.loadModel)(mc.server.model, mc.server.embedding, mc.server.modelsDir, embeddingApiModelName);
169
- process.stderr.write(`[serve] Embedding API model ready\n`);
170
- }
171
- catch (err) {
172
- process.stderr.write(`[serve] Failed to load embedding API model: ${err}\n`);
173
- }
174
- }
175
- // Load workspace models
176
- for (const wsId of manager.listWorkspaces()) {
177
- try {
178
- await manager.loadWorkspaceModels(wsId);
179
- }
180
- catch (err) {
181
- process.stderr.write(`[serve] Failed to load workspace "${wsId}" models: ${err}\n`);
182
- }
183
- }
184
- // Load project models and start indexing
185
- for (const id of manager.listProjects()) {
186
- try {
187
- await manager.loadModels(id);
188
- await manager.startIndexing(id);
189
- }
190
- catch (err) {
191
- process.stderr.write(`[serve] Failed to initialize project "${id}": ${err}\n`);
192
- }
193
- }
194
- // Start workspace mirror watchers (after all projects are indexed)
195
- for (const wsId of manager.listWorkspaces()) {
196
- try {
197
- await manager.startWorkspaceMirror(wsId);
198
- }
199
- catch (err) {
200
- process.stderr.write(`[serve] Failed to start workspace "${wsId}" mirror: ${err}\n`);
201
- }
202
- }
203
- }
204
- initProjects().catch((err) => {
205
- process.stderr.write(`[serve] Init error: ${err}\n`);
206
- });
207
203
  let shuttingDown = false;
208
204
  async function shutdown() {
209
205
  if (shuttingDown) {
@@ -216,7 +212,7 @@ program
216
212
  const forceTimer = setTimeout(() => {
217
213
  process.stderr.write('[serve] Shutdown timeout, force exit\n');
218
214
  process.exit(1);
219
- }, 5000);
215
+ }, defaults_1.GRACEFUL_SHUTDOWN_TIMEOUT_MS);
220
216
  try {
221
217
  httpServer.close();
222
218
  // Destroy all open connections (including WebSocket) so the server can close
@@ -320,13 +316,28 @@ usersCmd
320
316
  process.stderr.write('Passwords do not match\n');
321
317
  process.exit(1);
322
318
  }
319
+ // Validate inputs
320
+ if (/[\x00-\x1f\x7f]/.test(name)) {
321
+ process.stderr.write('Name contains invalid characters\n');
322
+ process.exit(1);
323
+ }
324
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
325
+ process.stderr.write('Invalid email format\n');
326
+ process.exit(1);
327
+ }
328
+ if (password.length > defaults_1.MAX_PASSWORD_LEN) {
329
+ process.stderr.write(`Password too long (max ${defaults_1.MAX_PASSWORD_LEN})\n`);
330
+ process.exit(1);
331
+ }
323
332
  const pwHash = await (0, jwt_1.hashPassword)(password);
324
333
  const apiKey = `mgm-${crypto_1.default.randomBytes(24).toString('base64url')}`;
325
- // Build YAML block for the new user
334
+ // Build YAML block for the new user — escape quotes to prevent YAML injection
335
+ const safeName = name.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
336
+ const safeEmail = email.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
326
337
  const userBlock = [
327
338
  ` ${id}:`,
328
- ` name: "${name}"`,
329
- ` email: "${email}"`,
339
+ ` name: "${safeName}"`,
340
+ ` email: "${safeEmail}"`,
330
341
  ` apiKey: "${apiKey}"`,
331
342
  ` passwordHash: "${pwHash}"`,
332
343
  ].join('\n');
@@ -13,37 +13,58 @@ const docs_2 = require("../graphs/docs");
13
13
  const code_1 = require("../lib/parsers/code");
14
14
  const code_2 = require("../graphs/code");
15
15
  const watcher_1 = require("../lib/watcher");
16
+ const defaults_1 = require("../lib/defaults");
16
17
  const knowledge_1 = require("../graphs/knowledge");
17
18
  const task_1 = require("../graphs/task");
18
19
  const skill_1 = require("../graphs/skill");
19
20
  const file_index_1 = require("../graphs/file-index");
20
21
  function createProjectIndexer(docGraph, codeGraph, config, knowledgeGraph, fileIndexGraph, taskGraph, skillGraph) {
21
22
  // Three independent serial queues — docs, code, and file index.
22
- let docsQueue = Promise.resolve();
23
- let codeQueue = Promise.resolve();
24
- let fileQueue = Promise.resolve();
25
- // Error tracking
26
- let docErrors = 0;
27
- let codeErrors = 0;
28
- let fileErrors = 0;
29
- function enqueueDoc(fn) {
30
- docsQueue = docsQueue.then(fn).catch((err) => {
31
- docErrors++;
32
- process.stderr.write(`[indexer] Doc error: ${err}\n`);
33
- });
34
- }
35
- function enqueueCode(fn) {
36
- codeQueue = codeQueue.then(fn).catch((err) => {
37
- codeErrors++;
38
- process.stderr.write(`[indexer] Code error: ${err}\n`);
39
- });
40
- }
41
- function enqueueFile(fn) {
42
- fileQueue = fileQueue.then(fn).catch((err) => {
43
- fileErrors++;
44
- process.stderr.write(`[indexer] File index error: ${err}\n`);
45
- });
23
+ // Array-based to avoid promise chain memory accumulation during scan.
24
+ function createSerialQueue(label) {
25
+ const pending = [];
26
+ let running = false;
27
+ let errors = 0;
28
+ let idleResolve = null;
29
+ let idlePromise = Promise.resolve();
30
+ async function pump() {
31
+ running = true;
32
+ while (pending.length > 0) {
33
+ const fn = pending.shift();
34
+ try {
35
+ await fn();
36
+ }
37
+ catch (err) {
38
+ errors++;
39
+ process.stderr.write(`[indexer] ${label} error: ${err}\n`);
40
+ }
41
+ }
42
+ running = false;
43
+ if (idleResolve) {
44
+ idleResolve();
45
+ idleResolve = null;
46
+ }
47
+ }
48
+ return {
49
+ enqueue(fn) {
50
+ pending.push(fn);
51
+ if (!running) {
52
+ idlePromise = new Promise(r => { idleResolve = r; });
53
+ void pump();
54
+ }
55
+ },
56
+ waitIdle() {
57
+ return (pending.length === 0 && !running) ? Promise.resolve() : idlePromise;
58
+ },
59
+ get errors() { return errors; },
60
+ };
46
61
  }
62
+ const docsQueue = createSerialQueue('Doc');
63
+ const codeQueue = createSerialQueue('Code');
64
+ const fileQueue = createSerialQueue('File index');
65
+ function enqueueDoc(fn) { docsQueue.enqueue(fn); }
66
+ function enqueueCode(fn) { codeQueue.enqueue(fn); }
67
+ function enqueueFile(fn) { fileQueue.enqueue(fn); }
47
68
  // ---------------------------------------------------------------------------
48
69
  // Per-file indexing
49
70
  // ---------------------------------------------------------------------------
@@ -83,7 +104,7 @@ function createProjectIndexer(docGraph, codeGraph, config, knowledgeGraph, fileI
83
104
  const rootChunk = chunks.find(c => c.level === 1);
84
105
  const embedText = rootChunk?.title
85
106
  ? `${fileId} ${rootChunk.title}`
86
- : `${fileId} ${rootChunk?.content.slice(0, 200) ?? ''}`;
107
+ : `${fileId} ${rootChunk?.content.slice(0, defaults_1.INDEXER_PREVIEW_LEN) ?? ''}`;
87
108
  batchInputs.push({ title: embedText, content: '' });
88
109
  const embeddings = await (0, embedder_1.embedBatch)(batchInputs, config.docsModelName);
89
110
  for (let i = 0; i < chunks.length; i++) {
@@ -126,7 +147,16 @@ function createProjectIndexer(docGraph, codeGraph, config, knowledgeGraph, fileI
126
147
  const parsed = await (0, code_1.parseCodeFile)(absolutePath, config.projectDir, mtime);
127
148
  // Batch-embed all symbols + file-level in one forward pass
128
149
  const batchInputs = parsed.nodes.map(({ attrs }) => ({ title: attrs.signature, content: attrs.docComment }));
129
- batchInputs.push({ title: fileId, content: '' });
150
+ // File-level embedding: path + exported symbol names + import summary
151
+ const fileNode = parsed.nodes.find(n => n.attrs.kind === 'file');
152
+ const exportedNames = parsed.nodes
153
+ .filter(n => n.attrs.isExported && n.attrs.kind !== 'file')
154
+ .map(n => n.attrs.name);
155
+ const fileEmbedTitle = exportedNames.length > 0
156
+ ? `${fileId} ${exportedNames.join(' ')}`
157
+ : fileId;
158
+ const fileEmbedContent = fileNode?.attrs.body ?? ''; // body = importSummary for file nodes
159
+ batchInputs.push({ title: fileEmbedTitle, content: fileEmbedContent });
130
160
  const embeddings = await (0, embedder_1.embedBatch)(batchInputs, config.codeModelName);
131
161
  for (let i = 0; i < parsed.nodes.length; i++) {
132
162
  parsed.nodes[i].attrs.embedding = embeddings[i];
@@ -252,7 +282,7 @@ function createProjectIndexer(docGraph, codeGraph, config, knowledgeGraph, fileI
252
282
  }, '**/*', allExcludePatterns.length > 0 ? allExcludePatterns : undefined);
253
283
  }
254
284
  async function drain() {
255
- await Promise.all([docsQueue, codeQueue, fileQueue]);
285
+ await Promise.all([docsQueue.waitIdle(), codeQueue.waitIdle(), fileQueue.waitIdle()]);
256
286
  if (fileIndexGraph)
257
287
  (0, file_index_1.rebuildDirectoryStats)(fileIndexGraph);
258
288
  // Resolve cross-file edges that were deferred during indexing
@@ -265,10 +295,13 @@ function createProjectIndexer(docGraph, codeGraph, config, knowledgeGraph, fileI
265
295
  const codeImports = (0, code_2.resolvePendingImports)(codeGraph);
266
296
  if (codeImports > 0)
267
297
  process.stderr.write(`[indexer] Resolved ${codeImports} deferred code import edge(s)\n`);
298
+ const codeEdges = (0, code_2.resolvePendingEdges)(codeGraph);
299
+ if (codeEdges > 0)
300
+ process.stderr.write(`[indexer] Resolved ${codeEdges} deferred code extends/implements edge(s)\n`);
268
301
  }
269
- const totalErrors = docErrors + codeErrors + fileErrors;
302
+ const totalErrors = docsQueue.errors + codeQueue.errors + fileQueue.errors;
270
303
  if (totalErrors > 0) {
271
- process.stderr.write(`[indexer] Completed with ${totalErrors} error(s): docs=${docErrors}, code=${codeErrors}, files=${fileErrors}\n`);
304
+ process.stderr.write(`[indexer] Completed with ${totalErrors} error(s): docs=${docsQueue.errors}, code=${codeQueue.errors}, files=${fileQueue.errors}\n`);
272
305
  }
273
306
  }
274
307
  return { scan, watch, drain };
@@ -36,10 +36,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.MAX_ATTACHMENTS_PER_ENTITY = exports.MAX_ATTACHMENT_SIZE = void 0;
39
40
  exports.scanAttachments = scanAttachments;
40
41
  const fs = __importStar(require("fs"));
41
42
  const path = __importStar(require("path"));
42
43
  const mime_1 = __importDefault(require("mime"));
44
+ /** Maximum size of a single attachment in bytes (10 MB). */
45
+ exports.MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
46
+ /** Maximum number of attachments per entity (note/task/skill). */
47
+ exports.MAX_ATTACHMENTS_PER_ENTITY = 20;
43
48
  /**
44
49
  * Scan the attachments/ subdirectory of an entity directory.
45
50
  * Returns metadata for each file found.