total-recall-brain 3.5.0 → 3.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.
@@ -0,0 +1,62 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { listMemory, listResearch, fetchChatThreads } from '../api'
3
+ import type { MemoryNode, ResearchItem } from '../types'
4
+ import type { ChatThread } from '../api'
5
+ import Graph3D from '../components/Graph3D'
6
+
7
+ export default function GraphPage({ activeBrainId }: { activeBrainId?: string }) {
8
+ const [memoryNodes, setMemoryNodes] = useState<MemoryNode[]>([])
9
+ const [researchItems, setResearchItems] = useState<ResearchItem[]>([])
10
+ const [threads, setThreads] = useState<ChatThread[]>([])
11
+ const [loading, setLoading] = useState(true)
12
+ const [selectedGroundingNodes, setSelectedGroundingNodes] = useState<string[]>([])
13
+
14
+ useEffect(() => {
15
+ async function loadData() {
16
+ setLoading(true)
17
+ try {
18
+ const [nodes, researchRes, threadsRes] = await Promise.all([
19
+ listMemory(activeBrainId),
20
+ listResearch(activeBrainId).catch(() => ({ items: [] })),
21
+ fetchChatThreads().catch(() => [])
22
+ ])
23
+ setMemoryNodes(nodes)
24
+ setResearchItems(researchRes.items || [])
25
+ setThreads(threadsRes)
26
+ } catch (err) {
27
+ console.error('Failed to load graph data', err)
28
+ } finally {
29
+ setLoading(false)
30
+ }
31
+ }
32
+ loadData()
33
+ }, [activeBrainId])
34
+
35
+ return (
36
+ <div className="page" style={{ padding: 0, height: 'calc(100vh - 1px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
37
+ <div style={{ padding: '16px 24px', background: 'rgba(18, 18, 26, 0.45)', backdropFilter: 'blur(16px)', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
38
+ <h1 style={{ fontSize: '18px', fontWeight: 700 }}>Sovereign Memory Network</h1>
39
+ <p style={{ fontSize: '12px', color: 'var(--text-secondary)', marginTop: '2px' }}>Interactive 3D constellation layout of your knowledge vault, sessions, and active thoughts.</p>
40
+ </div>
41
+
42
+ <div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
43
+ {loading ? (
44
+ <div className="skeleton" style={{ width: '100%', height: '100%' }} />
45
+ ) : (
46
+ <Graph3D
47
+ threads={threads}
48
+ memoryNodes={memoryNodes}
49
+ researchItems={researchItems}
50
+ onOpenThread={() => {}}
51
+ onGroundMemoryNode={(slug) => {
52
+ setSelectedGroundingNodes(prev =>
53
+ prev.includes(slug) ? prev.filter(s => s !== slug) : [...prev, slug]
54
+ )
55
+ }}
56
+ selectedGroundingNodes={selectedGroundingNodes}
57
+ />
58
+ )}
59
+ </div>
60
+ </div>
61
+ )
62
+ }
@@ -50,8 +50,13 @@ export default function HealthPage() {
50
50
  }
51
51
  }
52
52
  checkVersion()
53
+ const updateInterval = setInterval(checkVersion, 3600000)
53
54
 
54
- return () => { active = false; clearInterval(interval) }
55
+ return () => {
56
+ active = false;
57
+ clearInterval(interval);
58
+ clearInterval(updateInterval);
59
+ }
55
60
  }, [])
56
61
 
57
62
  const handleStartUpdate = async () => {
@@ -0,0 +1,141 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { fetchHelpTopics, fetchHelpContent } from '../api'
3
+ import type { HelpTopic } from '../api'
4
+
5
+ function renderMarkdown(md: string) {
6
+ if (!md) return ''
7
+ let html = md
8
+ .replace(/&/g, '&amp;')
9
+ .replace(/</g, '&lt;')
10
+ .replace(/>/g, '&gt;')
11
+
12
+ // Headings
13
+ html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>')
14
+ html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>')
15
+ html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>')
16
+
17
+ // Bold & Italic
18
+ html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
19
+ html = html.replace(/\*(.*?)\*/g, '<em>$1</em>')
20
+
21
+ // Code blocks
22
+ html = html.replace(
23
+ /```([\s\S]*?)```/g,
24
+ '<pre style="background:rgba(0,0,0,0.3);padding:12px;border-radius:6px;border:1px solid rgba(255,255,255,0.06);font-family:monospace;overflow-x:auto;margin:12px 0;"><code>$1</code></pre>'
25
+ )
26
+ html = html.replace(
27
+ /`(.*?)`/g,
28
+ '<code style="background:rgba(255,255,255,0.06);padding:2px 6px;border-radius:4px;font-family:monospace;">$1</code>'
29
+ )
30
+
31
+ // Blockquotes
32
+ html = html.replace(
33
+ /^> (.*$)/gim,
34
+ '<blockquote style="border-left:4px solid var(--purple-primary);padding-left:14px;color:var(--text-secondary);margin:16px 0;font-style:italic;">$1</blockquote>'
35
+ )
36
+
37
+ // Lists
38
+ html = html.replace(/^- (.*$)/gim, '<li style="margin-left:20px;list-style-type:disc;">$1</li>')
39
+
40
+ // Linebreaks
41
+ html = html.replace(/\n/g, '<br />')
42
+
43
+ return <div dangerouslySetInnerHTML={{ __html: html }} className="markdown-preview-block" />
44
+ }
45
+
46
+ export default function HelpPage() {
47
+ const [topics, setTopics] = useState<HelpTopic[]>([])
48
+ const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
49
+ const [content, setContent] = useState<string>('')
50
+ const [loading, setLoading] = useState<boolean>(false)
51
+ const [error, setError] = useState<string>('')
52
+
53
+ useEffect(() => {
54
+ async function loadTopics() {
55
+ try {
56
+ const data = await fetchHelpTopics()
57
+ setTopics(data.topics)
58
+ if (data.topics.length > 0) {
59
+ setSelectedTopic(data.topics[0].id)
60
+ }
61
+ } catch (err: unknown) {
62
+ setError((err as Error).message || 'Failed to load help topics')
63
+ }
64
+ }
65
+ loadTopics()
66
+ }, [])
67
+
68
+ useEffect(() => {
69
+ if (!selectedTopic) return
70
+ async function loadContent() {
71
+ setLoading(true)
72
+ try {
73
+ const data = await fetchHelpContent(selectedTopic!)
74
+ setContent(data.content)
75
+ } catch (err: unknown) {
76
+ setError((err as Error).message || 'Failed to load help details')
77
+ } finally {
78
+ setLoading(false)
79
+ }
80
+ }
81
+ loadContent()
82
+ }, [selectedTopic])
83
+
84
+ return (
85
+ <div className="page" style={{ padding: '24px', maxWidth: '1100px', margin: '0 auto' }}>
86
+ <div className="page-header" style={{ marginBottom: '24px' }}>
87
+ <h1>Sovereign Help System</h1>
88
+ <p>Offline interactive documentation, CLI command references, and SSSS VFS specifications.</p>
89
+ </div>
90
+
91
+ {error && <div className="badge badge-error" style={{ marginBottom: '16px' }}>⚠️ {error}</div>}
92
+
93
+ <div style={{ display: 'grid', gridTemplateColumns: '260px 1fr', gap: '30px', minHeight: '500px' }}>
94
+ {/* Sidebar */}
95
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
96
+ {topics.map((t) => (
97
+ <button
98
+ key={t.id}
99
+ onClick={() => setSelectedTopic(t.id)}
100
+ style={{
101
+ textAlign: 'left',
102
+ padding: '12px 16px',
103
+ borderRadius: '8px',
104
+ background: selectedTopic === t.id ? 'var(--purple-primary)' : 'rgba(255, 255, 255, 0.03)',
105
+ color: '#fff',
106
+ border: selectedTopic === t.id ? '1px solid var(--purple-border)' : '1px solid transparent',
107
+ cursor: 'pointer',
108
+ transition: 'all 0.2s',
109
+ }}
110
+ >
111
+ <div style={{ fontWeight: 'bold', fontSize: '14px' }}>{t.title}</div>
112
+ <div style={{ fontSize: '11px', color: selectedTopic === t.id ? 'rgba(255, 255, 255, 0.8)' : 'var(--text-secondary)', marginTop: '4px' }}>
113
+ {t.description}
114
+ </div>
115
+ </button>
116
+ ))}
117
+ </div>
118
+
119
+ {/* Content Pane */}
120
+ <div
121
+ style={{
122
+ background: 'var(--surface-bg)',
123
+ border: '1px solid var(--border)',
124
+ borderRadius: '12px',
125
+ padding: '30px',
126
+ overflowY: 'auto',
127
+ maxHeight: '75vh',
128
+ }}
129
+ >
130
+ {loading ? (
131
+ <div className="skeleton" style={{ height: '400px' }} />
132
+ ) : (
133
+ <div className="markdown-body" style={{ color: 'var(--text-primary)', lineHeight: '1.7', fontSize: '14px' }}>
134
+ {renderMarkdown(content)}
135
+ </div>
136
+ )}
137
+ </div>
138
+ </div>
139
+ </div>
140
+ )
141
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
4
4
  "description": "Sovereign AI system — database-free, SSSS-powered personal intelligence engine deployable to any POSIX host.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -65,7 +65,8 @@
65
65
  "jsonwebtoken": "^9.0.3",
66
66
  "natural": "^8.1.1",
67
67
  "yaml": "^2.9.0",
68
- "zod": "^4.4.3"
68
+ "zod": "^4.4.3",
69
+ "ws": "^8.18.0"
69
70
  },
70
71
  "devDependencies": {
71
72
  "skills": "^1.5.7",
@@ -0,0 +1,49 @@
1
+ /**
2
+ * total-recall collab
3
+ *
4
+ * Start the collaboration backend server and frontend development portal.
5
+ *
6
+ * Usage:
7
+ * npx total-recall collab
8
+ */
9
+
10
+ import { spawn } from 'node:child_process';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+ const COLLAB_DIR = path.join(__dirname, '..', '..', 'collab');
16
+
17
+ export default function collab(args) {
18
+ if (args.includes('--help') || args.includes('-h')) {
19
+ console.log(`
20
+ total-recall collab — Start collaboration workspace
21
+
22
+ Usage: total-recall collab [options]
23
+
24
+ Options:
25
+ --help Show this help
26
+
27
+ Starts the Express/WebSockets backend server and Vite frontend portal.
28
+ `);
29
+ return;
30
+ }
31
+
32
+ console.log(` ⚡ Starting Total Recall collaboration workspace...`);
33
+ console.log(` Press Ctrl+C to stop.\n`);
34
+
35
+ const child = spawn('npm', ['run', 'dev'], {
36
+ cwd: COLLAB_DIR,
37
+ stdio: 'inherit',
38
+ shell: true,
39
+ });
40
+
41
+ process.on('SIGINT', () => {
42
+ child.kill('SIGINT');
43
+ process.exit(0);
44
+ });
45
+
46
+ child.on('exit', (code) => {
47
+ process.exit(code ?? 0);
48
+ });
49
+ }
package/src/cli/help.mjs CHANGED
@@ -148,6 +148,6 @@ function printGeneralHelp() {
148
148
  \x1b[1mAll Commands Available:\x1b[0m
149
149
  \x1b[33minit\x1b[0m, \x1b[33msetup\x1b[0m, \x1b[33mstart\x1b[0m, \x1b[33mdeploy\x1b[0m, \x1b[33mdoctor\x1b[0m, \x1b[33mdream\x1b[0m, \x1b[33mlint\x1b[0m, \x1b[33mdaemon\x1b[0m, \x1b[33mbackup\x1b[0m, \x1b[33mrestore\x1b[0m, \x1b[33msync\x1b[0m, \x1b[33mstatus\x1b[0m,
150
150
  \x1b[33mgenerate-pat\x1b[0m, \x1b[33mhash-password\x1b[0m, \x1b[33mreset-password\x1b[0m, \x1b[33mcompile\x1b[0m, \x1b[33mrebuild\x1b[0m, \x1b[33msnapshot\x1b[0m, \x1b[33mmigrate\x1b[0m,
151
- \x1b[33mimport\x1b[0m, \x1b[33mingest\x1b[0m, \x1b[33mconnect\x1b[0m, \x1b[33mrelay\x1b[0m, \x1b[33muninstall\x1b[0m, \x1b[33mchat\x1b[0m, \x1b[33mremember\x1b[0m, \x1b[33mrecall\x1b[0m, \x1b[33mresearch\x1b[0m, \x1b[33mmap\x1b[0m, \x1b[33mbrain\x1b[0m, \x1b[33mskill\x1b[0m
151
+ \x1b[33mimport\x1b[0m, \x1b[33mingest\x1b[0m, \x1b[33mconnect\x1b[0m, \x1b[33mrelay\x1b[0m, \x1b[33muninstall\x1b[0m, \x1b[33mchat\x1b[0m, \x1b[33mremember\x1b[0m, \x1b[33mrecall\x1b[0m, \x1b[33mresearch\x1b[0m, \x1b[33mmap\x1b[0m, \x1b[33mbrain\x1b[0m, \x1b[33mskill\x1b[0m, \x1b[33mcollab\x1b[0m
152
152
  `);
153
153
  }
@@ -137,6 +137,47 @@ export function autoPruneStorage(brainDir, vaultDir, conflictsDir) {
137
137
  pruneDir(inboxDir, draftMaxAgeMs); // Keeps the local dev inbox fully clean of automatic research noise
138
138
  if (conflictsDir) pruneDir(conflictsDir, draftMaxAgeMs);
139
139
 
140
+ // Prune scheduler tasks queue: move completed/failed ones to archive, delete archives older than logMaxAgeMs
141
+ const tasksQueueDir = path.join(brainDir, 'scheduler', 'queue');
142
+ const tasksArchiveDir = path.join(tasksQueueDir, 'archive');
143
+ if (fs.existsSync(tasksQueueDir)) {
144
+ try {
145
+ if (!fs.existsSync(tasksArchiveDir)) {
146
+ fs.mkdirSync(tasksArchiveDir, { recursive: true });
147
+ }
148
+ const files = fs.readdirSync(tasksQueueDir);
149
+ for (const file of files) {
150
+ if (!file.endsWith('.md')) continue;
151
+ const fullPath = path.join(tasksQueueDir, file);
152
+ try {
153
+ const raw = fs.readFileSync(fullPath, 'utf8');
154
+ const { data } = matter(raw);
155
+ if (data.status === 'completed' || data.status === 'failed') {
156
+ fs.renameSync(fullPath, path.join(tasksArchiveDir, file));
157
+ }
158
+ } catch (e) {
159
+ // ignore parsing/permission errors for individual files
160
+ }
161
+ }
162
+
163
+ const archiveFiles = fs.readdirSync(tasksArchiveDir);
164
+ for (const file of archiveFiles) {
165
+ if (!file.endsWith('.md')) continue;
166
+ const fullPath = path.join(tasksArchiveDir, file);
167
+ try {
168
+ const stat = fs.statSync(fullPath);
169
+ if (now - stat.mtimeMs > logMaxAgeMs) {
170
+ fs.unlinkSync(fullPath);
171
+ }
172
+ } catch (e) {
173
+ // ignore
174
+ }
175
+ }
176
+ } catch (err) {
177
+ logger.warn('dream', `Failed to prune scheduler tasks: ${err.message}`);
178
+ }
179
+ }
180
+
140
181
  // Automatically prune transient planning files in every active/historical conversation folder,
141
182
  // while permanently preserving the conversations/threads themselves (.system_generated)
142
183
  try {
@@ -1,7 +1,15 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import yaml from 'yaml';
4
- import { brainDir } from './config.mjs';
4
+ import {
5
+ brainDir,
6
+ braveApiKey as defaultBraveKey,
7
+ braveSearchApiKey as defaultBraveSearchKey,
8
+ exaApiKey as defaultExaKey,
9
+ githubToken as defaultGithubToken,
10
+ serperApiKey as defaultSerperKey,
11
+ tavilyApiKey as defaultTavilyKey
12
+ } from './config.mjs';
5
13
 
6
14
  /**
7
15
  * Research Source Adapters — Real Service Integrations
@@ -42,12 +50,12 @@ export function loadResearchConfig(configPath = DEFAULT_CONFIG_PATH) {
42
50
 
43
51
  return {
44
52
  // ── Paid search providers (fallback chain: Brave → Tavily → Exa → Serper) ──
45
- braveApiKey: process.env.BRAVE_SEARCH_API_KEY || process.env.BRAVE_API_KEY || secrets.brave_api_key || fileConfig.brave_api_key || null,
46
- tavilyApiKey: process.env.TAVILY_API_KEY || secrets.tavily_api_key || fileConfig.tavily_api_key || null,
47
- exaApiKey: process.env.EXA_API_KEY || secrets.exa_api_key || fileConfig.exa_api_key || null,
48
- serperApiKey: process.env.SERPER_API_KEY || secrets.serper_api_key || fileConfig.serper_api_key || null,
53
+ braveApiKey: process.env.BRAVE_SEARCH_API_KEY || process.env.BRAVE_API_KEY || secrets.brave_api_key || fileConfig.brave_api_key || defaultBraveKey || defaultBraveSearchKey || null,
54
+ tavilyApiKey: process.env.TAVILY_API_KEY || secrets.tavily_api_key || fileConfig.tavily_api_key || defaultTavilyKey || null,
55
+ exaApiKey: process.env.EXA_API_KEY || secrets.exa_api_key || fileConfig.exa_api_key || defaultExaKey || null,
56
+ serperApiKey: process.env.SERPER_API_KEY || secrets.serper_api_key || fileConfig.serper_api_key || defaultSerperKey || null,
49
57
  // GitHub: personal access token for higher rate limits (60→5000 req/hr)
50
- githubToken: process.env.GITHUB_TOKEN || secrets.github_token || fileConfig.github_token || null,
58
+ githubToken: process.env.GITHUB_TOKEN || secrets.github_token || fileConfig.github_token || defaultGithubToken || null,
51
59
  fetchTimeoutMs: fileConfig.fetch_timeout_ms || 10000,
52
60
  maxResultsPerSource: fileConfig.max_results_per_source || 5,
53
61
  userAgent: fileConfig.user_agent || 'TotalRecall/1.0 (knowledge-acquisition; +https://github.com/gregiteen/total-recall)',
@@ -198,8 +198,12 @@ try {
198
198
  app.use('/api', apiRateLimiter());
199
199
  app.use(restRouter);
200
200
  logger.info('server', 'REST API mounted (/api/*, /v1/models, /.well-known/total-recall.json)');
201
+
202
+ const { collabRouter } = await import('./routes/collab.mjs');
203
+ app.use(collabRouter);
204
+ logger.info('server', 'Collaboration API routes mounted (/api/collab/*)');
201
205
  } catch (err) {
202
- logger.error('server', `REST API failed to load: ${err.message}`);
206
+ logger.error('server', `REST API or Collab routes failed to load: ${err.message}`);
203
207
  }
204
208
 
205
209
  // ─── API Routes (/v1/chat/completions) ──────────────────────────────────────────
@@ -573,7 +577,23 @@ const HOST = nodeEnv === 'production' && publicBindRequested && serverSecurityCo
573
577
  ? '127.0.0.1'
574
578
  : configuredHost;
575
579
 
580
+ import { WebSocketServer } from 'ws';
581
+ const collabWss = new WebSocketServer({ noServer: true });
582
+
576
583
  const server = app.listen(PORT, HOST, () => {
584
+ server.on('upgrade', async (request, socket, head) => {
585
+ const url = new URL(request.url, `http://${request.headers.host || 'localhost'}`);
586
+ if (url.pathname === '/collab-ws') {
587
+ try {
588
+ const { handleCollabUpgrade } = await import('./routes/collab.mjs');
589
+ handleCollabUpgrade(request, socket, head, collabWss);
590
+ } catch (err) {
591
+ socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
592
+ socket.destroy();
593
+ }
594
+ }
595
+ });
596
+
577
597
  if (HOST !== configuredHost) {
578
598
  logger.error("server", `Refusing public bind '${configuredHost}' in production. Bound to ${HOST}.`);
579
599
  }
@@ -944,12 +944,19 @@ router.get('/api/extension/status', requireAuth, requireScope('config:read'), as
944
944
  try {
945
945
  const extDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../extension');
946
946
  const available = fs.existsSync(path.join(extDir, 'manifest.json'));
947
+ let version = '0.0.0';
948
+ if (available) {
949
+ try {
950
+ const manifest = JSON.parse(fs.readFileSync(path.join(extDir, 'manifest.json'), 'utf8'));
951
+ version = manifest.version || '0.0.0';
952
+ } catch {}
953
+ }
947
954
 
948
955
  // Check if extension has ever connected by looking for the marker file
949
956
  const markerPath = path.join(BRAIN_DIR, 'config', '.extension-connected');
950
957
  const connected = fs.existsSync(markerPath);
951
958
 
952
- res.json({ available, connected });
959
+ res.json({ available, connected, version });
953
960
  } catch (err) { serverError(res, err); }
954
961
  });
955
962
 
@@ -2067,4 +2074,45 @@ router.post('/api/update/run', requireAuth, async (req, res) => {
2067
2074
  }
2068
2075
  });
2069
2076
 
2077
+ router.get('/api/help', requireAuth, (req, res) => {
2078
+ const { topic } = req.query;
2079
+ const docsDir = path.join(ROOT, 'docs');
2080
+ const referenceDir = path.join(docsDir, 'reference');
2081
+
2082
+ if (!topic) {
2083
+ return res.json({
2084
+ topics: [
2085
+ { id: 'cli-reference', title: 'CLI Reference Guide', description: 'npx total-recall command catalog and flags' },
2086
+ { id: 'ssss', title: 'SSSS Specifications', description: 'Structured Semantic Syntax System guide' },
2087
+ { id: 'architecture', title: 'System Architecture', description: 'System topology and VFS structures' },
2088
+ { id: 'collab', title: 'Collaboration Guide', description: 'Collaborative workspaces and team annotations' }
2089
+ ]
2090
+ });
2091
+ }
2092
+
2093
+ try {
2094
+ let filePath = '';
2095
+ if (topic === 'cli-reference') {
2096
+ filePath = path.join(referenceDir, 'cli-reference.md');
2097
+ } else if (topic === 'ssss') {
2098
+ filePath = path.join(docsDir, 'SSSS.md');
2099
+ } else if (topic === 'architecture') {
2100
+ filePath = path.join(docsDir, 'ARCHITECTURE.md');
2101
+ } else if (topic === 'collab') {
2102
+ filePath = path.join(referenceDir, 'collab.md');
2103
+ } else {
2104
+ return res.status(404).json({ error: 'Help topic not found' });
2105
+ }
2106
+
2107
+ if (!fs.existsSync(filePath)) {
2108
+ return res.status(404).json({ error: 'Help topic document not found on server' });
2109
+ }
2110
+
2111
+ const content = fs.readFileSync(filePath, 'utf8');
2112
+ res.json({ topic, content });
2113
+ } catch (err) {
2114
+ serverError(res, err);
2115
+ }
2116
+ });
2117
+
2070
2118
  export { router as restRouter };
@@ -103,6 +103,10 @@
103
103
  "method": "GET",
104
104
  "path": "/api/graph"
105
105
  },
106
+ {
107
+ "method": "GET",
108
+ "path": "/api/help"
109
+ },
106
110
  {
107
111
  "method": "GET",
108
112
  "path": "/api/import/rules"