neoagent 1.4.1 → 1.4.4

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.
@@ -186,7 +186,9 @@ router.get('/conversations', (req, res) => {
186
186
 
187
187
  router.post('/conversations/search', (req, res) => {
188
188
  const mm = req.app.locals.memoryManager;
189
- const results = mm.searchConversations(req.session.userId, req.body.query);
189
+ const results = mm.searchConversations(req.session.userId, req.body.query, {
190
+ sessions: parseInt(req.body.limit) || 8
191
+ });
190
192
  res.json(results);
191
193
  });
192
194
 
@@ -0,0 +1,28 @@
1
+ const express = require('express');
2
+ const router = express.Router();
3
+ const { requireAuth } = require('../middleware/auth');
4
+ const { sanitizeError } = require('../utils/security');
5
+ const { getHealthSyncStatus, ingestHealthSync } = require('../services/health/ingestion');
6
+
7
+ router.use(requireAuth);
8
+
9
+ router.get('/status', (req, res) => {
10
+ try {
11
+ res.json(getHealthSyncStatus(req.session.userId));
12
+ } catch (err) {
13
+ res.status(500).json({ error: sanitizeError(err) });
14
+ }
15
+ });
16
+
17
+ router.post('/sync', (req, res) => {
18
+ try {
19
+ const result = ingestHealthSync(req.session.userId, req.body);
20
+ res.status(201).json({ success: true, ...result });
21
+ } catch (err) {
22
+ const message = sanitizeError(err);
23
+ const status = /payload|Missing user/i.test(message) ? 400 : 500;
24
+ res.status(status).json({ error: message });
25
+ }
26
+ });
27
+
28
+ module.exports = router;
@@ -5,6 +5,7 @@ const db = require('../db/database');
5
5
  const { requireAuth } = require('../middleware/auth');
6
6
  const { normalizeWhatsAppWhitelist } = require('../utils/whatsapp');
7
7
  const { UPDATE_STATUS_FILE, APP_DIR } = require('../../runtime/paths');
8
+ const { ensureDefaultAiSettings, DEFAULT_AI_SETTINGS } = require('../services/ai/settings');
8
9
 
9
10
  router.use(requireAuth);
10
11
 
@@ -35,8 +36,9 @@ router.get('/meta/models', (req, res) => {
35
36
 
36
37
  // Get all settings
37
38
  router.get('/', (req, res) => {
39
+ ensureDefaultAiSettings(req.session.userId);
38
40
  const rows = db.prepare('SELECT key, value FROM user_settings WHERE user_id = ?').all(req.session.userId);
39
- const settings = {};
41
+ const settings = { ...DEFAULT_AI_SETTINGS };
40
42
  for (const row of rows) {
41
43
  try {
42
44
  settings[row.key] = JSON.parse(row.value);
@@ -123,6 +125,31 @@ router.get('/token-usage/summary', (req, res) => {
123
125
  return acc;
124
126
  }, { tokens: 0, runs: 0 });
125
127
 
128
+ const promptMetricRows = db.prepare(`
129
+ SELECT prompt_metrics
130
+ FROM agent_runs
131
+ WHERE user_id = ? AND prompt_metrics IS NOT NULL
132
+ ORDER BY created_at DESC
133
+ LIMIT 20
134
+ `).all(userId);
135
+
136
+ const parsedMetrics = promptMetricRows
137
+ .map((row) => {
138
+ try { return JSON.parse(row.prompt_metrics); } catch { return null; }
139
+ })
140
+ .filter(Boolean);
141
+
142
+ const metricTotals = parsedMetrics.reduce((acc, item) => {
143
+ const last = item.lastEstimate || {};
144
+ acc.count += 1;
145
+ acc.system += Number(last.systemPromptTokens || 0);
146
+ acc.tools += Number(last.toolSchemaTokens || 0);
147
+ acc.history += Number(last.historyTokens || 0);
148
+ acc.recall += Number(last.recalledMemoryTokens || 0);
149
+ acc.replay += Number(last.toolReplayTokens || 0);
150
+ return acc;
151
+ }, { count: 0, system: 0, tools: 0, history: 0, recall: 0, replay: 0 });
152
+
126
153
  res.json({
127
154
  totals: {
128
155
  totalTokens: Number(totals?.totalTokens || 0),
@@ -131,7 +158,18 @@ router.get('/token-usage/summary', (req, res) => {
131
158
  last7DaysTokens: last7Totals.tokens,
132
159
  last7DaysRuns: last7Totals.runs
133
160
  },
134
- last7Days
161
+ last7Days,
162
+ promptMetrics: {
163
+ sampleCount: metricTotals.count,
164
+ average: metricTotals.count === 0 ? null : {
165
+ systemPromptTokens: Math.round(metricTotals.system / metricTotals.count),
166
+ toolSchemaTokens: Math.round(metricTotals.tools / metricTotals.count),
167
+ historyTokens: Math.round(metricTotals.history / metricTotals.count),
168
+ recalledMemoryTokens: Math.round(metricTotals.recall / metricTotals.count),
169
+ toolReplayTokens: Math.round(metricTotals.replay / metricTotals.count)
170
+ },
171
+ latest: parsedMetrics[0] || null
172
+ }
135
173
  });
136
174
  });
137
175
 
@@ -1,108 +1,147 @@
1
1
  const express = require('express');
2
2
  const router = express.Router();
3
- const fs = require('fs');
4
- const path = require('path');
5
3
  const { requireAuth } = require('../middleware/auth');
6
- const { AGENT_DATA_DIR } = require('../../runtime/paths');
7
-
8
- const SKILLS_DIR = path.join(AGENT_DATA_DIR, 'skills');
9
-
10
- /**
11
- * Resolve a client-supplied filename to an absolute path inside SKILLS_DIR.
12
- * Returns null if the resolved path escapes the directory (path traversal attempt).
13
- */
14
- function safeSkillPath(filename) {
15
- if (!filename || typeof filename !== 'string') return null;
16
- // Strip any directory components – only the basename is allowed
17
- const base = path.basename(filename);
18
- if (!base || base === '.' || base === '..') return null;
19
- const resolved = path.resolve(SKILLS_DIR, base);
20
- // Ensure the resolved path stays inside SKILLS_DIR
21
- if (!resolved.startsWith(SKILLS_DIR + path.sep) && resolved !== SKILLS_DIR) return null;
22
- return resolved;
23
- }
4
+ const { sanitizeError } = require('../utils/security');
5
+ const { SkillRunner } = require('../services/ai/toolRunner');
24
6
 
25
7
  router.use(requireAuth);
26
8
 
27
- // List all skills
28
- router.get('/', (req, res) => {
29
- if (!fs.existsSync(SKILLS_DIR)) fs.mkdirSync(SKILLS_DIR, { recursive: true });
9
+ async function getSkillRunner(app) {
10
+ if (app.locals?.skillRunner) return app.locals.skillRunner;
11
+ const runner = new SkillRunner();
12
+ await runner.loadSkills();
13
+ return runner;
14
+ }
30
15
 
31
- const files = fs.readdirSync(SKILLS_DIR).filter(f => f.endsWith('.md'));
32
- const skills = files.map(f => {
33
- const content = fs.readFileSync(path.join(SKILLS_DIR, f), 'utf-8');
34
- const meta = parseSkillMeta(content);
16
+ function parseSkillDocument(content) {
17
+ const match = String(content || '').match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
18
+ if (!match) {
35
19
  return {
36
- filename: f,
37
- name: meta.name || f.replace('.md', ''),
38
- description: meta.description || '',
39
- trigger: meta.trigger || '',
40
- enabled: meta.enabled !== false,
41
- category: meta.category || 'general'
20
+ error: 'Skill files must start with frontmatter delimited by ---'
42
21
  };
43
- });
44
-
45
- res.json(skills);
46
- });
47
-
48
- // Get a specific skill
49
- router.get('/:filename', (req, res) => {
50
- const fp = safeSkillPath(req.params.filename);
51
- if (!fp || !fs.existsSync(fp)) return res.status(404).json({ error: 'Skill not found' });
52
-
53
- const content = fs.readFileSync(fp, 'utf-8');
54
- res.json({ filename: req.params.filename, content, meta: parseSkillMeta(content) });
55
- });
22
+ }
56
23
 
57
- // Create a new skill
58
- router.post('/', (req, res) => {
59
- const { filename, content } = req.body;
60
- if (!filename || !content) return res.status(400).json({ error: 'filename and content required' });
24
+ const frontmatter = {};
25
+ for (const line of match[1].split('\n')) {
26
+ const colon = line.indexOf(':');
27
+ if (colon === -1) continue;
28
+ const key = line.slice(0, colon).trim();
29
+ let value = line.slice(colon + 1).trim();
30
+ if (value === 'true') value = true;
31
+ else if (value === 'false') value = false;
32
+ else if ((value.startsWith('{') && value.endsWith('}')) || (value.startsWith('[') && value.endsWith(']'))) {
33
+ try { value = JSON.parse(value); } catch { /* keep raw */ }
34
+ }
35
+ frontmatter[key] = value;
36
+ }
61
37
 
62
- const baseName = filename.replace(/\.md$/i, '').replace(/[^a-zA-Z0-9_.-]/g, '');
63
- const safeName = baseName + '.md';
64
- const fp = path.join(SKILLS_DIR, safeName);
38
+ if (!frontmatter.name) {
39
+ return { error: 'Skill frontmatter must include name' };
40
+ }
65
41
 
66
- if (!fs.existsSync(SKILLS_DIR)) fs.mkdirSync(SKILLS_DIR, { recursive: true });
42
+ return {
43
+ name: String(frontmatter.name),
44
+ description: String(frontmatter.description || ''),
45
+ instructions: match[2].trim(),
46
+ metadata: Object.fromEntries(
47
+ Object.entries(frontmatter).filter(([key]) => !['name', 'description'].includes(key))
48
+ )
49
+ };
50
+ }
67
51
 
68
- fs.writeFileSync(fp, content, 'utf-8');
69
- res.status(201).json({ filename: safeName, meta: parseSkillMeta(content) });
52
+ router.get('/', async (req, res) => {
53
+ try {
54
+ const runner = await getSkillRunner(req.app);
55
+ const skills = runner.getAll().map((skill) => ({
56
+ name: skill.name,
57
+ description: skill.description,
58
+ enabled: skill.enabled,
59
+ draft: skill.metadata?.draft === true,
60
+ category: skill.metadata?.category || 'general',
61
+ trigger: skill.metadata?.trigger || '',
62
+ source: skill.metadata?.source || 'local',
63
+ autoCreated: skill.metadata?.auto_created === true,
64
+ filePath: skill.filePath
65
+ }));
66
+ res.json(skills.sort((a, b) => {
67
+ if (a.draft !== b.draft) return a.draft ? -1 : 1;
68
+ return a.name.localeCompare(b.name);
69
+ }));
70
+ } catch (err) {
71
+ res.status(500).json({ error: sanitizeError(err) });
72
+ }
70
73
  });
71
74
 
72
- // Update a skill
73
- router.put('/:filename', (req, res) => {
74
- const fp = safeSkillPath(req.params.filename);
75
- if (!fp || !fs.existsSync(fp)) return res.status(404).json({ error: 'Skill not found' });
76
-
77
- fs.writeFileSync(fp, req.body.content, 'utf-8');
78
- res.json({ filename: path.basename(fp), meta: parseSkillMeta(req.body.content) });
75
+ router.get('/:name', async (req, res) => {
76
+ try {
77
+ const runner = await getSkillRunner(req.app);
78
+ const skill = runner.getSkill(req.params.name);
79
+ if (!skill) return res.status(404).json({ error: 'Skill not found' });
80
+ const fs = require('fs');
81
+ const content = fs.readFileSync(skill.filePath, 'utf-8');
82
+ res.json({
83
+ name: skill.name,
84
+ content,
85
+ meta: skill.metadata,
86
+ enabled: skill.metadata?.enabled !== false
87
+ });
88
+ } catch (err) {
89
+ res.status(500).json({ error: sanitizeError(err) });
90
+ }
79
91
  });
80
92
 
81
- // Delete a skill
82
- router.delete('/:filename', (req, res) => {
83
- const fp = safeSkillPath(req.params.filename);
84
- if (!fp || !fs.existsSync(fp)) return res.status(404).json({ error: 'Skill not found' });
85
-
86
- fs.unlinkSync(fp);
87
- res.json({ success: true });
93
+ router.post('/', async (req, res) => {
94
+ try {
95
+ const runner = await getSkillRunner(req.app);
96
+ const parsed = parseSkillDocument(req.body.content);
97
+ if (parsed.error) return res.status(400).json({ error: parsed.error });
98
+
99
+ const result = runner.createSkill(
100
+ req.body.filename || parsed.name,
101
+ parsed.description,
102
+ parsed.instructions,
103
+ parsed.metadata
104
+ );
105
+
106
+ if (result.error) return res.status(400).json(result);
107
+ res.status(201).json(result);
108
+ } catch (err) {
109
+ res.status(500).json({ error: sanitizeError(err) });
110
+ }
88
111
  });
89
112
 
90
- function parseSkillMeta(content) {
91
- const meta = {};
92
- const match = content.match(/^---\n([\s\S]*?)\n---/);
93
- if (!match) return meta;
113
+ router.put('/:name', async (req, res) => {
114
+ try {
115
+ const runner = await getSkillRunner(req.app);
116
+ if (typeof req.body.enabled === 'boolean' && !req.body.content) {
117
+ const result = runner.setSkillEnabled(req.params.name, req.body.enabled);
118
+ if (result.error) return res.status(404).json(result);
119
+ return res.json(result);
120
+ }
121
+
122
+ const parsed = parseSkillDocument(req.body.content);
123
+ if (parsed.error) return res.status(400).json({ error: parsed.error });
124
+ const result = runner.updateSkill(req.params.name, {
125
+ description: parsed.description,
126
+ instructions: parsed.instructions,
127
+ metadata: parsed.metadata
128
+ });
129
+ if (result.error) return res.status(404).json(result);
130
+ res.json(result);
131
+ } catch (err) {
132
+ res.status(500).json({ error: sanitizeError(err) });
133
+ }
134
+ });
94
135
 
95
- const lines = match[1].split('\n');
96
- for (const line of lines) {
97
- const colon = line.indexOf(':');
98
- if (colon === -1) continue;
99
- const key = line.slice(0, colon).trim();
100
- let val = line.slice(colon + 1).trim();
101
- if (val === 'true') val = true;
102
- else if (val === 'false') val = false;
103
- meta[key] = val;
136
+ router.delete('/:name', async (req, res) => {
137
+ try {
138
+ const runner = await getSkillRunner(req.app);
139
+ const result = runner.deleteSkill(req.params.name);
140
+ if (result.error) return res.status(404).json(result);
141
+ res.json(result);
142
+ } catch (err) {
143
+ res.status(500).json({ error: sanitizeError(err) });
104
144
  }
105
- return meta;
106
- }
145
+ });
107
146
 
108
147
  module.exports = router;
@@ -317,6 +317,46 @@ Run in the user's specified directory (default: current working directory):
317
317
  Present as a structured git dashboard. Note any uncommitted changes or detached HEAD.`
318
318
  },
319
319
 
320
+ {
321
+ id: 'pdf-toolkit',
322
+ name: 'PDF Toolkit',
323
+ description: 'Inspect, extract, split, merge, compress, and rearrange PDF files.',
324
+ category: 'productivity',
325
+ icon: '📄',
326
+ content: `---
327
+ name: pdf-toolkit
328
+ description: Inspect, extract, split, merge, compress, and rearrange PDF files
329
+ category: productivity
330
+ icon: 📄
331
+ enabled: true
332
+ ---
333
+
334
+ Work with PDF files using whatever is available on the machine. Prefer tools in this order when relevant:
335
+ - \`pdfinfo\`, \`pdftotext\`, \`pdftoppm\`, \`pdftocairo\` from Poppler for inspection, text extraction, and page rendering
336
+ - \`qpdf\` for splitting, merging, rotating, decrypting, and page selection
337
+ - \`pdftk\` for merge/split/stamp workflows if available
338
+ - \`mutool\` for extracting text, objects, and cleaning PDFs
339
+ - \`gs\` (Ghostscript) for compression or PDF regeneration
340
+
341
+ Workflow:
342
+ 1. Verify the input file exists.
343
+ 2. Check which PDF utilities are installed with \`which\`.
344
+ 3. Choose the safest tool for the requested operation.
345
+ 4. Write outputs next to the source file unless the user specifies a destination.
346
+ 5. Report the output path, page counts, and any limitations clearly.
347
+
348
+ Common tasks:
349
+ - Inspect a PDF with \`pdfinfo <file.pdf>\`
350
+ - Extract text with \`pdftotext <file.pdf> -\` or \`pdftotext -layout <file.pdf> -\`
351
+ - Merge PDFs with \`qpdf --empty --pages a.pdf b.pdf -- out.pdf\`
352
+ - Split pages with \`qpdf in.pdf --pages in.pdf 1-5 -- out.pdf\`
353
+ - Reorder or remove pages with \`qpdf in.pdf --pages in.pdf 1,3,5-7 -- out.pdf\`
354
+ - Compress via Ghostscript:
355
+ \`gs -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 -dPDFSETTINGS=/ebook -dNOPAUSE -dQUIET -dBATCH -sOutputFile=out.pdf in.pdf\`
356
+
357
+ Preserve the original file unless the user explicitly asks to overwrite it.`
358
+ },
359
+
320
360
  {
321
361
  id: 'docker-status',
322
362
  name: 'Docker Status',
@@ -1047,6 +1087,66 @@ ruby <file>.rb # Ruby
1047
1087
  - No unused imports or dead code left behind`
1048
1088
  },
1049
1089
 
1090
+ {
1091
+ id: 'csv-toolkit',
1092
+ name: 'CSV Toolkit',
1093
+ description: 'Inspect, clean, summarize, filter, and transform CSV and TSV files.',
1094
+ category: 'productivity',
1095
+ icon: '📊',
1096
+ content: `---
1097
+ name: csv-toolkit
1098
+ description: Inspect, clean, summarize, filter, and transform CSV and TSV files
1099
+ category: productivity
1100
+ icon: 📊
1101
+ enabled: true
1102
+ ---
1103
+
1104
+ Work with local CSV or TSV data using lightweight shell tools.
1105
+
1106
+ Preferred tools:
1107
+ - \`python3\` with the standard \`csv\` module for reliable parsing
1108
+ - \`mlr\` (Miller) if installed for fast tabular summaries
1109
+ - \`csvkit\` commands such as \`csvlook\`, \`csvcut\`, \`csvstat\`, \`csvgrep\` if installed
1110
+
1111
+ Workflow:
1112
+ 1. Detect delimiter and header structure from the file.
1113
+ 2. For quick inspection, show row count, column count, header names, and a 5-row preview.
1114
+ 3. For analysis requests, compute exactly what the user asked for: filtering, grouping, aggregations, sorting, or conversion to JSON or Markdown.
1115
+ 4. Save transformed output to a sibling file unless the user asked for inline output only.
1116
+
1117
+ Avoid naive comma-splitting because quoted fields can break it. If the file is large, sample first and then run the full transform once the operation is clear.`
1118
+ },
1119
+
1120
+ {
1121
+ id: 'markdown-workbench',
1122
+ name: 'Markdown Workbench',
1123
+ description: 'Format, lint, outline, and convert Markdown notes and docs.',
1124
+ category: 'productivity',
1125
+ icon: '📝',
1126
+ content: `---
1127
+ name: markdown-workbench
1128
+ description: Format, lint, outline, and convert Markdown notes and docs
1129
+ category: productivity
1130
+ icon: 📝
1131
+ enabled: true
1132
+ ---
1133
+
1134
+ Help turn rough notes into usable docs quickly.
1135
+
1136
+ Workflow:
1137
+ 1. Read the source Markdown or note file.
1138
+ 2. Decide whether the user needs cleanup and formatting, heading structure, summary or outline, checklist extraction, or conversion to HTML or PDF.
1139
+ 3. Preserve meaning while improving structure and readability.
1140
+
1141
+ Common tasks:
1142
+ - Normalize headings, lists, code fences, and spacing
1143
+ - Pull out tasks into a checklist grouped by topic or urgency
1144
+ - Build a short linked outline from headings
1145
+ - If available, use \`pandoc\` to convert Markdown to HTML, DOCX, or PDF
1146
+
1147
+ Prefer preserving the user's tone unless they ask for a rewrite.`
1148
+ },
1149
+
1050
1150
  // ── MAKER ────────────────────────────────────────────────────────────────────
1051
1151
  {
1052
1152
  id: 'psa-car-controller',
@@ -2,50 +2,38 @@ async function compact(messages, provider, model) {
2
2
  const systemMsg = messages.find(m => m.role === 'system');
3
3
  const nonSystem = messages.filter(m => m.role !== 'system');
4
4
 
5
- // Only compact once history is clearly old enough to avoid touching recent context.
6
5
  if (nonSystem.length < 12) return messages;
7
6
 
8
- const keepRecent = 6;
7
+ const keepRecent = 8;
9
8
  const toCompact = nonSystem.slice(0, -keepRecent);
10
9
  const recent = nonSystem.slice(-keepRecent);
11
10
 
12
- let compactionText = '';
13
- for (const msg of toCompact) {
14
- if (msg.role === 'user') {
15
- compactionText += `User: ${(msg.content || '').slice(0, 500)}\n`;
16
- } else if (msg.role === 'assistant') {
17
- const content = (msg.content || '').slice(0, 500);
18
- if (msg.tool_calls) {
19
- const tools = msg.tool_calls.map(tc => tc.function.name).join(', ');
20
- compactionText += `Assistant: [used tools: ${tools}] ${content}\n`;
21
- } else {
22
- compactionText += `Assistant: ${content}\n`;
23
- }
24
- } else if (msg.role === 'tool') {
25
- const result = (msg.content || '').slice(0, 200);
26
- compactionText += `Tool result: ${result}\n`;
11
+ const compactionText = toCompact.map((msg) => {
12
+ if (msg.role === 'assistant' && msg.tool_calls) {
13
+ const tools = msg.tool_calls.map((tc) => tc.function.name).join(', ');
14
+ return `assistant(tools:${tools}) ${(msg.content || '').slice(0, 320)}`;
27
15
  }
28
- }
16
+ if (msg.role === 'tool') {
17
+ return `tool:${msg.name || 'tool'} ${(msg.content || '').slice(0, 220)}`;
18
+ }
19
+ return `${msg.role}: ${(msg.content || '').slice(0, 360)}`;
20
+ }).join('\n');
29
21
 
30
22
  const summaryPrompt = [
31
- { role: 'system', content: 'Summarize this conversation history concisely. Preserve: key decisions, facts learned, user preferences, task outcomes, and any errors or important results. Be thorough but compact.' },
23
+ { role: 'system', content: 'Compress conversation context. Preserve goals, constraints, tool outcomes, errors, and unresolved work. Keep it compact.' },
32
24
  { role: 'user', content: `Summarize this conversation:\n\n${compactionText}` }
33
25
  ];
34
26
 
35
27
  try {
36
- const response = await provider.chat(summaryPrompt, [], { model, maxTokens: 1000 });
28
+ const response = await provider.chat(summaryPrompt, [], { model, maxTokens: 320 });
37
29
  const summary = response.content || 'Previous conversation context (summary unavailable).';
38
30
 
39
31
  const compactedMessages = [];
40
32
  if (systemMsg) compactedMessages.push(systemMsg);
41
33
  compactedMessages.push({
42
- role: 'user',
34
+ role: 'system',
43
35
  content: `[Previous conversation summary]\n${summary}`
44
36
  });
45
- compactedMessages.push({
46
- role: 'assistant',
47
- content: 'Understood. I have the context from our previous conversation. Continuing.'
48
- });
49
37
  compactedMessages.push(...recent);
50
38
 
51
39
  return compactedMessages;
@@ -54,13 +42,9 @@ async function compact(messages, provider, model) {
54
42
  const trimmed = [];
55
43
  if (systemMsg) trimmed.push(systemMsg);
56
44
  trimmed.push({
57
- role: 'user',
45
+ role: 'system',
58
46
  content: '[Earlier conversation context was trimmed due to length]'
59
47
  });
60
- trimmed.push({
61
- role: 'assistant',
62
- content: 'Understood. Some earlier context was trimmed. Continuing with recent messages.'
63
- });
64
48
  trimmed.push(...recent);
65
49
  return trimmed;
66
50
  }