sanook-cli 0.5.0 → 0.5.2
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.
- package/.env.example +161 -3
- package/CHANGELOG.md +83 -5
- package/README.md +240 -23
- package/README.th.md +87 -6
- package/dist/approval.js +6 -0
- package/dist/bin.js +3045 -210
- package/dist/brain-context.js +223 -0
- package/dist/brain-doctor.js +318 -0
- package/dist/brain-eval.js +186 -0
- package/dist/brain-final.js +371 -0
- package/dist/brain-review.js +382 -0
- package/dist/brain.js +12 -1
- package/dist/brand.js +1 -1
- package/dist/cli-args.js +152 -0
- package/dist/cli-option-values.js +16 -0
- package/dist/commands.js +172 -13
- package/dist/compaction.js +96 -11
- package/dist/config.js +118 -28
- package/dist/context-compression.js +191 -0
- package/dist/cost.js +49 -15
- package/dist/first-run.js +21 -0
- package/dist/gateway/auth.js +37 -8
- package/dist/gateway/bluebubbles.js +205 -0
- package/dist/gateway/config.js +929 -0
- package/dist/gateway/deliver.js +357 -0
- package/dist/gateway/discord.js +124 -0
- package/dist/gateway/email.js +472 -0
- package/dist/gateway/googlechat.js +207 -0
- package/dist/gateway/homeassistant.js +256 -0
- package/dist/gateway/ledger.js +18 -0
- package/dist/gateway/line.js +171 -0
- package/dist/gateway/lock.js +3 -1
- package/dist/gateway/matrix.js +366 -0
- package/dist/gateway/mattermost.js +322 -0
- package/dist/gateway/ntfy.js +218 -0
- package/dist/gateway/schedule.js +31 -4
- package/dist/gateway/serve.js +267 -7
- package/dist/gateway/server.js +253 -19
- package/dist/gateway/service.js +224 -0
- package/dist/gateway/session.js +343 -0
- package/dist/gateway/signal.js +351 -0
- package/dist/gateway/slack.js +124 -0
- package/dist/gateway/sms.js +169 -0
- package/dist/gateway/targets.js +576 -0
- package/dist/gateway/teams.js +106 -0
- package/dist/gateway/telegram.js +38 -15
- package/dist/gateway/webhooks.js +220 -0
- package/dist/gateway/whatsapp.js +230 -0
- package/dist/hooks.js +13 -2
- package/dist/insights-args.js +35 -0
- package/dist/insights.js +86 -0
- package/dist/loop.js +123 -24
- package/dist/lsp/index.js +23 -5
- package/dist/mcp-registry.js +350 -0
- package/dist/mcp-server.js +1 -1
- package/dist/mcp.js +44 -6
- package/dist/memory.js +100 -33
- package/dist/orchestrate.js +49 -19
- package/dist/personality.js +58 -0
- package/dist/providers/codex.js +86 -38
- package/dist/providers/keys.js +1 -1
- package/dist/providers/models.js +22 -6
- package/dist/providers/registry.js +38 -49
- package/dist/search/chunk.js +7 -8
- package/dist/search/cli.js +75 -0
- package/dist/search/embed-store.js +3 -0
- package/dist/search/indexer.js +44 -1
- package/dist/search/store.js +23 -1
- package/dist/session.js +93 -7
- package/dist/skill-install.js +29 -12
- package/dist/support-dump.js +175 -0
- package/dist/tools/edit.js +45 -15
- package/dist/tools/git.js +10 -5
- package/dist/tools/homeassistant.js +106 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/list.js +19 -6
- package/dist/tools/permission.js +923 -9
- package/dist/tools/read.js +16 -4
- package/dist/tools/schedule.js +19 -3
- package/dist/tools/search.js +217 -13
- package/dist/tools/task.js +18 -7
- package/dist/tools/timeout.js +21 -3
- package/dist/trust.js +11 -1
- package/dist/ui/app.js +57 -11
- package/dist/ui/brain-wizard.js +2 -2
- package/dist/ui/history.js +37 -5
- package/dist/ui/mentions.js +3 -2
- package/dist/ui/render.js +55 -15
- package/dist/ui/setup.js +107 -10
- package/dist/update.js +24 -11
- package/dist/worktree.js +175 -4
- package/package.json +4 -4
- package/second-brain/AGENTS.md +6 -4
- package/second-brain/CLAUDE.md +7 -1
- package/second-brain/Evals/_Index.md +10 -2
- package/second-brain/Evals/quality-ledger.md +9 -1
- package/second-brain/Evals/second-brain-benchmarks.md +62 -0
- package/second-brain/GEMINI.md +5 -4
- package/second-brain/Home.md +1 -1
- package/second-brain/Projects/_Index.md +3 -1
- package/second-brain/Projects/sanook-cli/_Index.md +26 -0
- package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +156 -0
- package/second-brain/README.md +1 -1
- package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
- package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
- package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
- package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
- package/second-brain/Research/_Index.md +6 -1
- package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
- package/second-brain/Reviews/_Index.md +1 -1
- package/second-brain/Runbooks/_Index.md +6 -1
- package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
- package/second-brain/SANOOK.md +45 -0
- package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
- package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
- package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
- package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
- package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
- package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
- package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
- package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
- package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
- package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
- package/second-brain/Sessions/_Index.md +15 -1
- package/second-brain/Shared/AI-Context-Index.md +22 -0
- package/second-brain/Shared/Context-Packs/_Index.md +9 -1
- package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
- package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
- package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
- package/second-brain/Shared/Operating-State/current-state.md +22 -3
- package/second-brain/Shared/Scripts/_Index.md +3 -1
- package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
- package/second-brain/Shared/Tech-Standards/_Index.md +4 -1
- package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
- package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
- package/second-brain/Shared/User-Memory/_Index.md +4 -1
- package/second-brain/Shared/User-Memory/response-examples.md +98 -0
- package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
- package/second-brain/Templates/_Index.md +9 -0
- package/second-brain/Templates/final-lite.md +111 -0
- package/second-brain/Templates/final.md +231 -0
- package/second-brain/Vault Structure Map.md +2 -1
- package/skills/structured-output-llm/SKILL.md +1 -1
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { inlineValue, takeValue } from './cli-option-values.js';
|
|
2
|
+
export const MCP_REGISTRY_BASE_URL = 'https://registry.modelcontextprotocol.io/v0';
|
|
3
|
+
export const MCP_PRESETS = [
|
|
4
|
+
{
|
|
5
|
+
name: 'dev',
|
|
6
|
+
description: 'Repo/issues/releases, error debugging, and versioned docs.',
|
|
7
|
+
servers: ['com.gitlab/mcp', 'com.mcparmory/github', 'com.mcparmory/sentry', 'ai.smithery/renCosta2025-context7fork'],
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
name: 'research',
|
|
11
|
+
description: 'Web/doc fetching, search, and knowledge intake.',
|
|
12
|
+
servers: ['ai.smithery/smithery-ai-fetch', 'ai.groundroute/web-search', 'ai.smithery/arjunkmrm-brave-search-mcp-server', 'ai.smithery/sunub-obsidian-mcp-server'],
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: 'pm',
|
|
16
|
+
description: 'Issue tracking, planning, and team/workspace context.',
|
|
17
|
+
servers: ['app.linear/linear', 'ai.waystation/jira', 'ai.waystation/slack', 'com.mcparmory/notion'],
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'ops',
|
|
21
|
+
description: 'Read-only data inspection, production errors, and infra helpers.',
|
|
22
|
+
servers: ['capital.hove/read-only-local-postgres-mcp-server', 'com.mcparmory/sentry', 'io.github.CSOAI-ORG/docker-helper-ai-mcp'],
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
export function parseKeyValueList(values) {
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const value of values) {
|
|
28
|
+
const idx = value.indexOf('=');
|
|
29
|
+
if (idx <= 0)
|
|
30
|
+
throw new Error(`ต้องใช้รูปแบบ KEY=value: ${value}`);
|
|
31
|
+
const key = value.slice(0, idx).trim();
|
|
32
|
+
if (!key)
|
|
33
|
+
throw new Error(`ต้องใช้รูปแบบ KEY=value: ${value}`);
|
|
34
|
+
out[key] = value.slice(idx + 1);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
function parseRegistrySearchLimit(raw) {
|
|
39
|
+
if (!raw || !/^[1-9]\d*$/.test(raw))
|
|
40
|
+
return undefined;
|
|
41
|
+
const n = Number(raw);
|
|
42
|
+
return Number.isSafeInteger(n) && n <= 50 ? n : undefined;
|
|
43
|
+
}
|
|
44
|
+
export function parseMcpRegistrySearchArgs(args) {
|
|
45
|
+
const query = [];
|
|
46
|
+
let limit = 10;
|
|
47
|
+
let cursor;
|
|
48
|
+
for (let i = 0; i < args.length; i++) {
|
|
49
|
+
const a = args[i];
|
|
50
|
+
if (a === '--') {
|
|
51
|
+
query.push(...args.slice(i + 1));
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
if (a === '--limit' || a.startsWith('--limit=')) {
|
|
55
|
+
const next = a === '--limit' ? takeValue(args, i) : undefined;
|
|
56
|
+
const raw = next ? next.value : inlineValue('--limit', a);
|
|
57
|
+
if (next)
|
|
58
|
+
i = next.nextIndex;
|
|
59
|
+
const parsed = parseRegistrySearchLimit(raw);
|
|
60
|
+
if (parsed === undefined)
|
|
61
|
+
return { ok: false, message: '--limit ต้องเป็นจำนวนเต็ม 1-50' };
|
|
62
|
+
limit = parsed;
|
|
63
|
+
}
|
|
64
|
+
else if (a === '--cursor' || a.startsWith('--cursor=')) {
|
|
65
|
+
const next = a === '--cursor' ? takeValue(args, i) : undefined;
|
|
66
|
+
const raw = next ? next.value : inlineValue('--cursor', a);
|
|
67
|
+
if (next)
|
|
68
|
+
i = next.nextIndex;
|
|
69
|
+
const parsed = raw?.trim();
|
|
70
|
+
if (!parsed)
|
|
71
|
+
return { ok: false, message: '--cursor ต้องระบุค่า' };
|
|
72
|
+
cursor = parsed;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
query.push(a);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { ok: true, value: { query: query.join(' ').trim(), limit, cursor } };
|
|
79
|
+
}
|
|
80
|
+
export function aliasFromRegistryName(name) {
|
|
81
|
+
const [scope = '', rawLeaf = name] = name.split('/');
|
|
82
|
+
const leaf = rawLeaf
|
|
83
|
+
.replace(/^mcp[-_]?/i, '')
|
|
84
|
+
.replace(/[-_]?mcp[-_]?server$/i, '')
|
|
85
|
+
.replace(/[-_]?server$/i, '')
|
|
86
|
+
.replace(/^smithery[-_]?ai[-_]?/i, '');
|
|
87
|
+
const scopeParts = scope.split('.').filter(Boolean);
|
|
88
|
+
const fallback = scopeParts.length > 1 ? scopeParts[1] : scopeParts[0] || name;
|
|
89
|
+
const candidate = leaf && leaf.toLowerCase() !== 'mcp' ? leaf : fallback;
|
|
90
|
+
const alias = candidate.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64);
|
|
91
|
+
return alias || 'mcp-server';
|
|
92
|
+
}
|
|
93
|
+
export async function searchMcpRegistry(query, options = {}) {
|
|
94
|
+
const params = new URLSearchParams();
|
|
95
|
+
if (query.trim())
|
|
96
|
+
params.set('search', query.trim());
|
|
97
|
+
params.set('limit', String(options.limit ?? 10));
|
|
98
|
+
if (options.cursor)
|
|
99
|
+
params.set('cursor', options.cursor);
|
|
100
|
+
const json = await fetchRegistryJson(`${options.baseUrl ?? MCP_REGISTRY_BASE_URL}/servers?${params}`, options.fetchImpl);
|
|
101
|
+
const entries = Array.isArray(json.servers) ? json.servers : [];
|
|
102
|
+
return {
|
|
103
|
+
servers: latestOnly(entries.map(normalizeRegistryEntry).filter((item) => !!item)),
|
|
104
|
+
nextCursor: typeof json.metadata?.nextCursor === 'string' ? json.metadata.nextCursor : undefined,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export async function getMcpRegistryServer(name, options = {}) {
|
|
108
|
+
const encoded = encodeURIComponent(name);
|
|
109
|
+
const base = options.baseUrl ?? MCP_REGISTRY_BASE_URL;
|
|
110
|
+
if (options.version) {
|
|
111
|
+
const raw = await fetchRegistryJson(`${base}/servers/${encoded}/versions/${encodeURIComponent(options.version)}`, options.fetchImpl);
|
|
112
|
+
return normalizeRegistryEntry(raw);
|
|
113
|
+
}
|
|
114
|
+
const json = await fetchRegistryJson(`${base}/servers/${encoded}/versions`, options.fetchImpl);
|
|
115
|
+
const entries = Array.isArray(json.servers) ? json.servers : [];
|
|
116
|
+
const servers = entries.map(normalizeRegistryEntry).filter((item) => !!item);
|
|
117
|
+
return servers.find((server) => server.isLatest) ?? servers.at(-1);
|
|
118
|
+
}
|
|
119
|
+
export function buildMcpInstallPlan(server, options = {}) {
|
|
120
|
+
const alias = options.alias ?? aliasFromRegistryName(server.name);
|
|
121
|
+
const warnings = [];
|
|
122
|
+
const requirements = [];
|
|
123
|
+
const preferred = options.transport ?? 'auto';
|
|
124
|
+
if (preferred !== 'stdio') {
|
|
125
|
+
const remote = server.remotes.find((item) => item.type === 'streamable-http' && item.url) ?? server.remotes.find((item) => item.url);
|
|
126
|
+
if (remote?.url) {
|
|
127
|
+
const headers = resolveHeaders(remote.headers, options.headers ?? {});
|
|
128
|
+
requirements.push(...headers.requirements);
|
|
129
|
+
if (headers.missing.length)
|
|
130
|
+
return { ok: false, alias, missing: headers.missing, warnings, requirements };
|
|
131
|
+
if (remote.type && remote.type !== 'streamable-http')
|
|
132
|
+
warnings.push(`remote transport เป็น ${remote.type}; Sanook รองรับ Streamable HTTP เป็นหลัก`);
|
|
133
|
+
return { ok: true, alias, config: { url: remote.url, ...(Object.keys(headers.values).length ? { headers: headers.values } : {}) }, source: 'remote', warnings, requirements };
|
|
134
|
+
}
|
|
135
|
+
if (preferred === 'remote') {
|
|
136
|
+
warnings.push('server นี้ไม่มี remote URL ที่ install ได้');
|
|
137
|
+
return { ok: false, alias, missing: [], warnings, requirements };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const pkg = choosePackage(server.packages);
|
|
141
|
+
if (!pkg)
|
|
142
|
+
return { ok: false, alias, missing: [], warnings: [...warnings, 'ไม่พบ package/remote ที่ Sanook install อัตโนมัติได้'], requirements };
|
|
143
|
+
const env = resolveEnv(pkg.environmentVariables ?? [], options.env ?? {});
|
|
144
|
+
requirements.push(...env.requirements);
|
|
145
|
+
if (env.missing.length)
|
|
146
|
+
return { ok: false, alias, missing: env.missing, warnings, requirements };
|
|
147
|
+
const commandArgs = packageCommand(pkg);
|
|
148
|
+
if (!commandArgs)
|
|
149
|
+
return { ok: false, alias, missing: [], warnings: [...warnings, `ยังไม่รองรับ package runtime: ${pkg.runtimeHint ?? pkg.registryType ?? '(unknown)'}`], requirements };
|
|
150
|
+
return {
|
|
151
|
+
ok: true,
|
|
152
|
+
alias,
|
|
153
|
+
config: { command: commandArgs.command, args: commandArgs.args, ...(Object.keys(env.values).length ? { env: env.values } : {}) },
|
|
154
|
+
source: 'package',
|
|
155
|
+
warnings,
|
|
156
|
+
requirements,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
export function formatRegistrySearch(result) {
|
|
160
|
+
const lines = ['MCP registry search'];
|
|
161
|
+
if (!result.servers.length)
|
|
162
|
+
return `${lines[0]}\n(no matches)`;
|
|
163
|
+
for (const server of result.servers) {
|
|
164
|
+
lines.push(`${server.name}${server.version ? `@${server.version}` : ''} — ${server.description ?? '(no description)'}`);
|
|
165
|
+
lines.push(` transport: ${transportSummary(server)}${server.repositoryUrl ? ` · repo: ${server.repositoryUrl}` : ''}`);
|
|
166
|
+
}
|
|
167
|
+
if (result.nextCursor)
|
|
168
|
+
lines.push(`next: --cursor ${result.nextCursor}`);
|
|
169
|
+
return lines.join('\n');
|
|
170
|
+
}
|
|
171
|
+
export function formatRegistryInfo(server) {
|
|
172
|
+
const lines = [`${server.name}${server.version ? `@${server.version}` : ''}`, server.description ?? '(no description)'];
|
|
173
|
+
if (server.repositoryUrl)
|
|
174
|
+
lines.push(`repo: ${server.repositoryUrl}`);
|
|
175
|
+
if (server.websiteUrl)
|
|
176
|
+
lines.push(`website: ${server.websiteUrl}`);
|
|
177
|
+
lines.push(`transport: ${transportSummary(server)}`);
|
|
178
|
+
if (server.remotes.length) {
|
|
179
|
+
lines.push('remotes:');
|
|
180
|
+
for (const remote of server.remotes) {
|
|
181
|
+
lines.push(` - ${remote.type ?? 'remote'} ${remote.url ?? '(missing url)'}`);
|
|
182
|
+
for (const req of inputSummaries(inputArray(remote.headers)))
|
|
183
|
+
lines.push(` ${req}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (server.packages.length) {
|
|
187
|
+
lines.push('packages:');
|
|
188
|
+
for (const pkg of server.packages) {
|
|
189
|
+
lines.push(` - ${pkg.registryType ?? 'pkg'} ${pkg.identifier ?? '(missing identifier)'}${pkg.version ? `@${pkg.version}` : ''}${pkg.runtimeHint ? ` via ${pkg.runtimeHint}` : ''}`);
|
|
190
|
+
for (const req of inputSummaries(pkg.environmentVariables ?? []))
|
|
191
|
+
lines.push(` ${req}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
lines.push(`install: sanook mcp install ${server.name} --name ${aliasFromRegistryName(server.name)}`);
|
|
195
|
+
return lines.join('\n');
|
|
196
|
+
}
|
|
197
|
+
export function formatPreset(name) {
|
|
198
|
+
if (!name) {
|
|
199
|
+
return ['MCP presets', ...MCP_PRESETS.map((preset) => ` ${preset.name.padEnd(8)} ${preset.description}`)].join('\n');
|
|
200
|
+
}
|
|
201
|
+
const preset = MCP_PRESETS.find((item) => item.name === name);
|
|
202
|
+
if (!preset)
|
|
203
|
+
return `ไม่เจอ preset: ${name}\nมีให้เลือก: ${MCP_PRESETS.map((item) => item.name).join(', ')}`;
|
|
204
|
+
return [
|
|
205
|
+
`MCP preset: ${preset.name}`,
|
|
206
|
+
preset.description,
|
|
207
|
+
'',
|
|
208
|
+
...preset.servers.map((server) => `- ${server}\n sanook mcp info ${server}\n sanook mcp install ${server} --name ${aliasFromRegistryName(server)}`),
|
|
209
|
+
].join('\n');
|
|
210
|
+
}
|
|
211
|
+
function normalizeRegistryEntry(raw) {
|
|
212
|
+
const server = raw.server;
|
|
213
|
+
if (!server?.name)
|
|
214
|
+
return undefined;
|
|
215
|
+
return {
|
|
216
|
+
name: server.name,
|
|
217
|
+
title: server.title,
|
|
218
|
+
description: server.description,
|
|
219
|
+
version: server.version,
|
|
220
|
+
repositoryUrl: server.repository?.url,
|
|
221
|
+
websiteUrl: server.websiteUrl,
|
|
222
|
+
isLatest: raw._meta?.['io.modelcontextprotocol.registry/official']?.isLatest !== false,
|
|
223
|
+
remotes: Array.isArray(server.remotes) ? server.remotes : [],
|
|
224
|
+
packages: Array.isArray(server.packages) ? server.packages : [],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function latestOnly(servers) {
|
|
228
|
+
const out = new Map();
|
|
229
|
+
for (const server of servers) {
|
|
230
|
+
const current = out.get(server.name);
|
|
231
|
+
if (!current || server.isLatest)
|
|
232
|
+
out.set(server.name, server);
|
|
233
|
+
}
|
|
234
|
+
return [...out.values()];
|
|
235
|
+
}
|
|
236
|
+
async function fetchRegistryJson(url, fetchImpl = fetch) {
|
|
237
|
+
const res = await fetchImpl(url, { headers: { accept: 'application/json' } });
|
|
238
|
+
if (!res.ok)
|
|
239
|
+
throw new Error(`registry ${res.status} ${res.statusText}`);
|
|
240
|
+
return (await res.json());
|
|
241
|
+
}
|
|
242
|
+
function transportSummary(server) {
|
|
243
|
+
const transports = [
|
|
244
|
+
...server.remotes.map((remote) => `remote:${remote.type ?? 'unknown'}`),
|
|
245
|
+
...server.packages.map((pkg) => `${pkg.registryType ?? 'pkg'}:${pkg.runtimeHint ?? 'package'}`),
|
|
246
|
+
];
|
|
247
|
+
return transports.length ? transports.join(', ') : 'none listed';
|
|
248
|
+
}
|
|
249
|
+
function inputArray(value) {
|
|
250
|
+
if (!value)
|
|
251
|
+
return [];
|
|
252
|
+
if (Array.isArray(value))
|
|
253
|
+
return value;
|
|
254
|
+
return Object.entries(value).map(([name, input]) => ({ name, value: String(input) }));
|
|
255
|
+
}
|
|
256
|
+
function inputSummaries(inputs) {
|
|
257
|
+
return inputs.map((input) => {
|
|
258
|
+
const name = input.name ?? '(positional)';
|
|
259
|
+
const flags = [input.isRequired ? 'required' : undefined, input.isSecret ? 'secret' : undefined].filter(Boolean).join(', ');
|
|
260
|
+
return `${name}${flags ? ` (${flags})` : ''}${input.description ? ` — ${input.description}` : ''}`;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
function resolveHeaders(raw, provided) {
|
|
264
|
+
const values = {};
|
|
265
|
+
const missing = [];
|
|
266
|
+
const requirements = [];
|
|
267
|
+
for (const input of inputArray(raw)) {
|
|
268
|
+
const name = input.name;
|
|
269
|
+
if (!name)
|
|
270
|
+
continue;
|
|
271
|
+
requirements.push(`header ${name}${input.isSecret ? ' (secret)' : ''}`);
|
|
272
|
+
const explicit = provided[name];
|
|
273
|
+
if (explicit != null)
|
|
274
|
+
values[name] = explicit;
|
|
275
|
+
else if (input.value && !/\{[^}]+\}/.test(input.value))
|
|
276
|
+
values[name] = input.value;
|
|
277
|
+
else if (input.default != null)
|
|
278
|
+
values[name] = input.default;
|
|
279
|
+
else if (input.isRequired || input.value)
|
|
280
|
+
missing.push(`header:${name}`);
|
|
281
|
+
}
|
|
282
|
+
for (const [name, value] of Object.entries(provided))
|
|
283
|
+
values[name] = value;
|
|
284
|
+
return { values, missing, requirements };
|
|
285
|
+
}
|
|
286
|
+
function resolveEnv(inputs, provided) {
|
|
287
|
+
const values = {};
|
|
288
|
+
const missing = [];
|
|
289
|
+
const requirements = [];
|
|
290
|
+
for (const input of inputs) {
|
|
291
|
+
const name = input.name;
|
|
292
|
+
if (!name)
|
|
293
|
+
continue;
|
|
294
|
+
requirements.push(`env ${name}${input.isSecret ? ' (secret)' : ''}`);
|
|
295
|
+
const explicit = provided[name];
|
|
296
|
+
if (explicit != null)
|
|
297
|
+
values[name] = explicit;
|
|
298
|
+
else if (input.value && !/\{[^}]+\}/.test(input.value))
|
|
299
|
+
values[name] = input.value;
|
|
300
|
+
else if (input.default != null)
|
|
301
|
+
values[name] = input.default;
|
|
302
|
+
else if (input.isRequired)
|
|
303
|
+
missing.push(`env:${name}`);
|
|
304
|
+
}
|
|
305
|
+
for (const [name, value] of Object.entries(provided))
|
|
306
|
+
values[name] = value;
|
|
307
|
+
return { values, missing, requirements };
|
|
308
|
+
}
|
|
309
|
+
function choosePackage(packages) {
|
|
310
|
+
return (packages.find((pkg) => pkg.transport?.type === 'stdio' && (pkg.runtimeHint === 'npx' || pkg.registryType === 'npm')) ??
|
|
311
|
+
packages.find((pkg) => pkg.transport?.type === 'stdio' && pkg.runtimeHint === 'uvx') ??
|
|
312
|
+
packages.find((pkg) => pkg.transport?.type === 'stdio' && pkg.runtimeHint === 'docker') ??
|
|
313
|
+
packages.find((pkg) => pkg.transport?.type === 'stdio'));
|
|
314
|
+
}
|
|
315
|
+
function packageCommand(pkg) {
|
|
316
|
+
const identifier = pkg.identifier;
|
|
317
|
+
if (!identifier)
|
|
318
|
+
return undefined;
|
|
319
|
+
const runtime = pkg.runtimeHint ?? (pkg.registryType === 'npm' ? 'npx' : pkg.registryType === 'pypi' ? 'uvx' : undefined);
|
|
320
|
+
const pkgId = packageIdentifierWithVersion(identifier, pkg.version);
|
|
321
|
+
const runtimeArgs = materializeArgs(pkg.runtimeArguments ?? []);
|
|
322
|
+
const packageArgs = materializeArgs(pkg.packageArguments ?? []);
|
|
323
|
+
if (runtime === 'npx') {
|
|
324
|
+
const args = runtimeArgs.length ? runtimeArgs : ['-y'];
|
|
325
|
+
return { command: 'npx', args: [...args, pkgId, ...packageArgs] };
|
|
326
|
+
}
|
|
327
|
+
if (runtime === 'uvx')
|
|
328
|
+
return { command: 'uvx', args: [...runtimeArgs, pkgId, ...packageArgs] };
|
|
329
|
+
if (runtime === 'docker')
|
|
330
|
+
return { command: 'docker', args: ['run', '-i', '--rm', ...runtimeArgs, pkgId, ...packageArgs] };
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
function packageIdentifierWithVersion(identifier, version) {
|
|
334
|
+
if (!version)
|
|
335
|
+
return identifier;
|
|
336
|
+
const scopedPackageNameEnd = identifier.startsWith('@') ? identifier.indexOf('/', 1) + 1 : 0;
|
|
337
|
+
return identifier.indexOf('@', scopedPackageNameEnd) === -1 ? `${identifier}@${version}` : identifier;
|
|
338
|
+
}
|
|
339
|
+
function materializeArgs(args) {
|
|
340
|
+
const out = [];
|
|
341
|
+
for (const arg of args) {
|
|
342
|
+
const value = arg.value ?? arg.default;
|
|
343
|
+
if (!value || /\{[^}]+\}/.test(value))
|
|
344
|
+
continue;
|
|
345
|
+
if (arg.name)
|
|
346
|
+
out.push(arg.name);
|
|
347
|
+
out.push(value);
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
package/dist/mcp-server.js
CHANGED
|
@@ -130,7 +130,7 @@ async function callTool(name, args) {
|
|
|
130
130
|
const r = await reindex();
|
|
131
131
|
resetSearchCaches();
|
|
132
132
|
return (`indexed: +${r.added} ~${r.updated} -${r.removed} (skipped ${r.skipped}) · ` +
|
|
133
|
-
`memory=${r.memory} sessions=${r.sessions} skills=${r.skills} · vault=${r.vaultPath ?? '(none)'}`);
|
|
133
|
+
`memory=${r.memory} sessions=${r.sessions} skills=${r.skills} vectors=${r.vectors} · vault=${r.vaultPath ?? '(none)'}`);
|
|
134
134
|
}
|
|
135
135
|
case 'sanook_stats': {
|
|
136
136
|
const { index } = await loadIndex();
|
package/dist/mcp.js
CHANGED
|
@@ -14,6 +14,7 @@ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.u
|
|
|
14
14
|
export const PROTOCOL_VERSION = '2024-11-05'; // shared by the MCP client (here) and server (mcp-server.ts)
|
|
15
15
|
const MAX_BUF = 16 * 1024 * 1024; // กัน server ส่ง byte ยาวไม่มี newline → memory โต unbounded
|
|
16
16
|
const REQUEST_TIMEOUT = 20_000;
|
|
17
|
+
export const MAX_MCP_TOOL_OUTPUT_CHARS = 200_000;
|
|
17
18
|
// env ปลอดภัยที่ส่งให้ MCP child (ไม่มี secret) — server ที่ต้อง token ให้ตั้งใน cfg.env เอง
|
|
18
19
|
const SAFE_ENV_KEYS = ['PATH', 'HOME', 'TMPDIR', 'TEMP', 'LANG', 'LC_ALL', 'USER', 'SHELL', 'TERM', 'NODE_PATH', 'NVM_DIR', 'APPDATA'];
|
|
19
20
|
function safeEnv() {
|
|
@@ -29,6 +30,12 @@ export function isValidMcpServerName(name) {
|
|
|
29
30
|
return (/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name) &&
|
|
30
31
|
!['__proto__', 'prototype', 'constructor'].includes(name));
|
|
31
32
|
}
|
|
33
|
+
export function capMcpToolOutput(text, max = MAX_MCP_TOOL_OUTPUT_CHARS) {
|
|
34
|
+
if (text.length <= max)
|
|
35
|
+
return text;
|
|
36
|
+
const omitted = text.length - max;
|
|
37
|
+
return `${text.slice(0, max)}\n\n[MCP output truncated: ${omitted} chars omitted]`;
|
|
38
|
+
}
|
|
32
39
|
/** stdio transport — JSON-RPC 2.0, newline-delimited ผ่าน child process stdin/stdout */
|
|
33
40
|
class StdioTransport {
|
|
34
41
|
proc;
|
|
@@ -36,6 +43,7 @@ class StdioTransport {
|
|
|
36
43
|
nextId = 1;
|
|
37
44
|
pending = new Map();
|
|
38
45
|
dead = false;
|
|
46
|
+
stderrTail = '';
|
|
39
47
|
constructor(cfg) {
|
|
40
48
|
this.proc = spawn(cfg.command, cfg.args ?? [], {
|
|
41
49
|
// minimal env เท่านั้น (PATH/HOME/locale) + cfg.env ที่ user ตั้งเอง — ไม่ส่ง secret
|
|
@@ -47,8 +55,13 @@ class StdioTransport {
|
|
|
47
55
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
48
56
|
});
|
|
49
57
|
this.proc.stdout?.on('data', (d) => this.onData(d.toString()));
|
|
58
|
+
// ต้อง drain stderr — piped ไว้แต่ไม่อ่าน = OS pipe buffer (~64KB) เต็ม → server บล็อกตอนเขียน log = แฮงค์
|
|
59
|
+
// เก็บหางไว้ ~2KB ช่วย debug ว่า server ตายเพราะอะไร
|
|
60
|
+
this.proc.stderr?.on('data', (d) => {
|
|
61
|
+
this.stderrTail = (this.stderrTail + d.toString()).slice(-2000);
|
|
62
|
+
});
|
|
50
63
|
this.proc.on('error', () => this.fail('spawn error'));
|
|
51
|
-
this.proc.on('exit', () => this.fail('server exited'));
|
|
64
|
+
this.proc.on('exit', () => this.fail(this.stderrTail.trim() ? `server exited — ${this.stderrTail.trim().split('\n').pop()}` : 'server exited'));
|
|
52
65
|
this.proc.stdin?.on('error', () => { }); // กัน EPIPE
|
|
53
66
|
}
|
|
54
67
|
fail(reason) {
|
|
@@ -206,16 +219,16 @@ class McpClient {
|
|
|
206
219
|
constructor(cfg) {
|
|
207
220
|
this.transport = cfg.url ? new HttpTransport(cfg.url, cfg.headers) : new StdioTransport(cfg);
|
|
208
221
|
}
|
|
209
|
-
async initialize() {
|
|
222
|
+
async initialize(timeoutMs = REQUEST_TIMEOUT) {
|
|
210
223
|
await this.transport.request('initialize', {
|
|
211
224
|
protocolVersion: PROTOCOL_VERSION,
|
|
212
225
|
capabilities: {},
|
|
213
226
|
clientInfo: { name: BRAND.mcpClientName, version: VERSION },
|
|
214
|
-
});
|
|
227
|
+
}, timeoutMs);
|
|
215
228
|
this.transport.notify('notifications/initialized');
|
|
216
229
|
}
|
|
217
|
-
async listTools() {
|
|
218
|
-
const r = (await this.transport.request('tools/list'));
|
|
230
|
+
async listTools(timeoutMs = REQUEST_TIMEOUT) {
|
|
231
|
+
const r = (await this.transport.request('tools/list', undefined, timeoutMs));
|
|
219
232
|
return r?.tools ?? [];
|
|
220
233
|
}
|
|
221
234
|
async callTool(name, args) {
|
|
@@ -224,12 +237,37 @@ class McpClient {
|
|
|
224
237
|
.filter((c) => c.type === 'text')
|
|
225
238
|
.map((c) => c.text ?? '')
|
|
226
239
|
.join('\n');
|
|
227
|
-
|
|
240
|
+
const capped = capMcpToolOutput(text);
|
|
241
|
+
return r?.isError ? `MCP error: ${capped}` : capped || '(no output)';
|
|
228
242
|
}
|
|
229
243
|
close() {
|
|
230
244
|
this.transport.close();
|
|
231
245
|
}
|
|
232
246
|
}
|
|
247
|
+
export async function probeMcpServer(cfg, timeoutMs = REQUEST_TIMEOUT) {
|
|
248
|
+
const transport = cfg.url ? 'http' : 'stdio';
|
|
249
|
+
if (!cfg.url && !cfg.command)
|
|
250
|
+
return { ok: false, transport, tools: [], error: 'ต้องมี command หรือ url' };
|
|
251
|
+
const client = new McpClient(cfg);
|
|
252
|
+
const deadline = Date.now() + Math.max(1, timeoutMs);
|
|
253
|
+
const remaining = (method) => {
|
|
254
|
+
const ms = deadline - Date.now();
|
|
255
|
+
if (ms <= 0)
|
|
256
|
+
throw new Error(`mcp timeout: ${method}`);
|
|
257
|
+
return ms;
|
|
258
|
+
};
|
|
259
|
+
try {
|
|
260
|
+
await client.initialize(remaining('initialize'));
|
|
261
|
+
const tools = await client.listTools(remaining('tools/list'));
|
|
262
|
+
return { ok: true, transport, tools };
|
|
263
|
+
}
|
|
264
|
+
catch (e) {
|
|
265
|
+
return { ok: false, transport, tools: [], error: e.message };
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
client.close();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
233
271
|
function stringRecord(value) {
|
|
234
272
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
235
273
|
return undefined;
|
package/dist/memory.js
CHANGED
|
@@ -81,54 +81,121 @@ export async function loadBrainContext() {
|
|
|
81
81
|
const brainPath = await getBrainPath();
|
|
82
82
|
return brainPath ? buildBrainContext(brainPath) : '';
|
|
83
83
|
}
|
|
84
|
+
/** ประกอบ source parts ชุดเดียวกับที่ inject เข้า prompt จริง — ให้ CLI inspect ได้โดยไม่ drift */
|
|
85
|
+
export async function buildBrainContextParts(brainPath) {
|
|
86
|
+
const idx = await readTrimmedPart({
|
|
87
|
+
id: 'ai-context-index',
|
|
88
|
+
label: 'AI Context Index',
|
|
89
|
+
brainPath,
|
|
90
|
+
relPath: 'Shared/AI-Context-Index.md',
|
|
91
|
+
maxChars: 3000,
|
|
92
|
+
});
|
|
93
|
+
const currentState = await readTrimmedPart({
|
|
94
|
+
id: 'current-state',
|
|
95
|
+
label: 'Current State',
|
|
96
|
+
brainPath,
|
|
97
|
+
relPath: 'Shared/Operating-State/current-state.md',
|
|
98
|
+
maxChars: 1500,
|
|
99
|
+
wrap: (content) => `## current-state\n${content}`,
|
|
100
|
+
});
|
|
101
|
+
const inbox = await readInboxPart(brainPath, 'Shared/Memory-Inbox/memory-inbox.md', 1200);
|
|
102
|
+
return [idx, currentState, inbox];
|
|
103
|
+
}
|
|
104
|
+
export function renderBrainContext(brainPath, parts) {
|
|
105
|
+
const content = parts.map((part) => part.content).filter(Boolean);
|
|
106
|
+
if (!content.length)
|
|
107
|
+
return '';
|
|
108
|
+
return `<brain_vault path="${brainPath}" note="second-brain ของ user — สิ่งที่จำไว้/state ปัจจุบันอยู่ใน block นี้; route โน้ตตาม Vault Structure Map; อ่าน/เขียนไฟล์ใน vault ด้วย absolute path ได้">\n${content.join('\n\n')}\n</brain_vault>`;
|
|
109
|
+
}
|
|
84
110
|
/** ประกอบ brain context จาก vault path (pure → testable) — entry + current-state + remembered facts */
|
|
85
111
|
export async function buildBrainContext(brainPath) {
|
|
86
|
-
|
|
87
|
-
// 1. entry/pointers (Vault Structure Map ฯลฯ)
|
|
88
|
-
const idx = await readTrimmed(join(brainPath, 'Shared', 'AI-Context-Index.md'), 3000);
|
|
89
|
-
if (idx)
|
|
90
|
-
parts.push(idx);
|
|
91
|
-
// 2. current-state — เนื้อจริง (live focus) ไม่ใช่แค่ pointer
|
|
92
|
-
const cs = await readTrimmed(join(brainPath, 'Shared', 'Operating-State', 'current-state.md'), 1500);
|
|
93
|
-
if (cs)
|
|
94
|
-
parts.push(`## current-state\n${cs}`);
|
|
95
|
-
// 3. ปิด loop: fact ที่ remember ไว้ (Memory-Inbox) กลับเข้า context — ไม่งั้น vault = write-only
|
|
96
|
-
const inbox = await inboxCandidates(join(brainPath, 'Shared', 'Memory-Inbox', 'memory-inbox.md'), 1200);
|
|
97
|
-
if (inbox)
|
|
98
|
-
parts.push(`## remembered (Memory-Inbox)\n${inbox}`);
|
|
99
|
-
if (!parts.length)
|
|
100
|
-
return '';
|
|
101
|
-
return `<brain_vault path="${brainPath}" note="second-brain ของ user — สิ่งที่จำไว้/state ปัจจุบันอยู่ใน block นี้; route โน้ตตาม Vault Structure Map; อ่าน/เขียนไฟล์ใน vault ด้วย absolute path ได้">\n${parts.join('\n\n')}\n</brain_vault>`;
|
|
112
|
+
return renderBrainContext(brainPath, await buildBrainContextParts(brainPath));
|
|
102
113
|
}
|
|
103
|
-
|
|
104
|
-
|
|
114
|
+
async function readTrimmedPart(input) {
|
|
115
|
+
const p = join(input.brainPath, input.relPath);
|
|
105
116
|
try {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return
|
|
117
|
+
const raw = (await readFile(p, 'utf8')).trim();
|
|
118
|
+
const trimmed = raw.length > input.maxChars ? `${raw.slice(0, input.maxChars)}\n…` : raw;
|
|
119
|
+
const content = trimmed ? input.wrap?.(trimmed) ?? trimmed : '';
|
|
120
|
+
return {
|
|
121
|
+
id: input.id,
|
|
122
|
+
label: input.label,
|
|
123
|
+
relPath: input.relPath,
|
|
124
|
+
path: p,
|
|
125
|
+
content,
|
|
126
|
+
chars: content.length,
|
|
127
|
+
maxChars: input.maxChars,
|
|
128
|
+
status: content ? 'present' : 'empty',
|
|
129
|
+
};
|
|
110
130
|
}
|
|
111
131
|
catch {
|
|
112
|
-
return
|
|
132
|
+
return {
|
|
133
|
+
id: input.id,
|
|
134
|
+
label: input.label,
|
|
135
|
+
relPath: input.relPath,
|
|
136
|
+
path: p,
|
|
137
|
+
content: '',
|
|
138
|
+
chars: 0,
|
|
139
|
+
maxChars: input.maxChars,
|
|
140
|
+
status: 'missing',
|
|
141
|
+
};
|
|
113
142
|
}
|
|
114
143
|
}
|
|
115
144
|
/** ดึงรายการ "- ..." ใต้ "## New Candidates" จาก memory-inbox (fact ที่ remember ไว้) */
|
|
116
145
|
async function inboxCandidates(p, max) {
|
|
117
146
|
try {
|
|
118
|
-
|
|
119
|
-
if (!after)
|
|
120
|
-
return '';
|
|
121
|
-
const lines = after
|
|
122
|
-
.split('\n')
|
|
123
|
-
.filter((l) => l.trim().startsWith('- ') && !l.includes('_('))
|
|
124
|
-
.map((l) => l.trim());
|
|
125
|
-
const text = lines.join('\n').trim();
|
|
126
|
-
return text.length > max ? `${text.slice(0, max)}\n…` : text;
|
|
147
|
+
return inboxCandidatesFromText(await readFile(p, 'utf8'), max);
|
|
127
148
|
}
|
|
128
149
|
catch {
|
|
129
150
|
return '';
|
|
130
151
|
}
|
|
131
152
|
}
|
|
153
|
+
function inboxCandidatesFromText(content, max) {
|
|
154
|
+
const lines = content.split('\n');
|
|
155
|
+
const markerIndex = lines.findIndex((line) => line.trim() === '## New Candidates');
|
|
156
|
+
if (markerIndex === -1)
|
|
157
|
+
return '';
|
|
158
|
+
const sectionLines = [];
|
|
159
|
+
for (const line of lines.slice(markerIndex + 1)) {
|
|
160
|
+
if (/^#{1,6}\s+/.test(line.trim()))
|
|
161
|
+
break;
|
|
162
|
+
sectionLines.push(line);
|
|
163
|
+
}
|
|
164
|
+
const candidates = sectionLines
|
|
165
|
+
.filter((l) => l.trim().startsWith('- ') && !l.includes('_('))
|
|
166
|
+
.map((l) => l.trim());
|
|
167
|
+
const text = candidates.join('\n').trim();
|
|
168
|
+
return text.length > max ? `${text.slice(0, max)}\n…` : text;
|
|
169
|
+
}
|
|
170
|
+
async function readInboxPart(brainPath, relPath, maxChars) {
|
|
171
|
+
const p = join(brainPath, relPath);
|
|
172
|
+
try {
|
|
173
|
+
const content = inboxCandidatesFromText(await readFile(p, 'utf8'), maxChars);
|
|
174
|
+
const wrapped = content ? `## remembered (Memory-Inbox)\n${content}` : '';
|
|
175
|
+
return {
|
|
176
|
+
id: 'memory-inbox',
|
|
177
|
+
label: 'Memory Inbox',
|
|
178
|
+
relPath,
|
|
179
|
+
path: p,
|
|
180
|
+
content: wrapped,
|
|
181
|
+
chars: wrapped.length,
|
|
182
|
+
maxChars,
|
|
183
|
+
status: wrapped ? 'present' : 'empty',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return {
|
|
188
|
+
id: 'memory-inbox',
|
|
189
|
+
label: 'Memory Inbox',
|
|
190
|
+
relPath,
|
|
191
|
+
path: p,
|
|
192
|
+
content: '',
|
|
193
|
+
chars: 0,
|
|
194
|
+
maxChars,
|
|
195
|
+
status: 'missing',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
132
199
|
/** path ของ second-brain vault จาก config (undefined = ไม่ได้ตั้ง) */
|
|
133
200
|
export async function getBrainPath() {
|
|
134
201
|
try {
|
|
@@ -165,7 +232,7 @@ export async function appendToVaultInbox(brainPath, fact) {
|
|
|
165
232
|
}
|
|
166
233
|
/** บันทึก worklog ย่อเข้า vault Sessions/ (รายวัน) — "second brain จำว่าวันนี้ทำอะไร" */
|
|
167
234
|
export async function appendBrainWorklog(brainPath, entry) {
|
|
168
|
-
if (!worklogEnabled())
|
|
235
|
+
if (!persistenceEnabled() || !worklogEnabled())
|
|
169
236
|
return false;
|
|
170
237
|
const dir = join(brainPath, 'Sessions');
|
|
171
238
|
if (!(await exists(dir)))
|