mouaif 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/mouaif.js +281 -0
  4. package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
  5. package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
  6. package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
  7. package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
  8. package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
  9. package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
  10. package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
  11. package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
  12. package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
  13. package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
  14. package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
  15. package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
  16. package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
  17. package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
  18. package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
  19. package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
  20. package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
  21. package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
  22. package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
  23. package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
  24. package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
  25. package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
  26. package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
  27. package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
  28. package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
  29. package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
  30. package/frontend/dist/assets/index-CANPYzQg.css +1 -0
  31. package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
  32. package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
  33. package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
  34. package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
  35. package/frontend/dist/icons/favicon-32.png +0 -0
  36. package/frontend/dist/icons/icon-180-apple.png +0 -0
  37. package/frontend/dist/icons/icon-192.png +0 -0
  38. package/frontend/dist/icons/icon-512.png +0 -0
  39. package/frontend/dist/icons/icon-maskable-512.png +0 -0
  40. package/frontend/dist/index.html +83 -0
  41. package/frontend/dist/manifest.webmanifest +33 -0
  42. package/frontend/dist/sw.js +482 -0
  43. package/package.json +98 -0
  44. package/scripts/patch-zimmerframe.js +58 -0
  45. package/src/access-auth.js +515 -0
  46. package/src/agentFeatures.js +294 -0
  47. package/src/agentFiles.js +164 -0
  48. package/src/agentSkills.js +147 -0
  49. package/src/agents.js +230 -0
  50. package/src/ai-chat.js +21 -0
  51. package/src/ai-endpoints.js +1880 -0
  52. package/src/ai-stream.js +2048 -0
  53. package/src/ai.js +68 -0
  54. package/src/auth.js +391 -0
  55. package/src/chatdb.js +816 -0
  56. package/src/chats.js +275 -0
  57. package/src/custom-actions.js +65 -0
  58. package/src/files.js +431 -0
  59. package/src/hideFileContent.js +327 -0
  60. package/src/http-server.js +535 -0
  61. package/src/index.js +15 -0
  62. package/src/inspector.js +731 -0
  63. package/src/inspectorProfiles.js +503 -0
  64. package/src/live-chat.js +107 -0
  65. package/src/mcp.js +1517 -0
  66. package/src/messages.js +238 -0
  67. package/src/modelList.js +137 -0
  68. package/src/notifications.js +52 -0
  69. package/src/oauth-anthropic.js +280 -0
  70. package/src/oauth-github-copilot.js +417 -0
  71. package/src/oauth-mcp.js +216 -0
  72. package/src/oauth-openrouter.js +285 -0
  73. package/src/package-version.js +20 -0
  74. package/src/projects.js +285 -0
  75. package/src/promptProfiles.js +256 -0
  76. package/src/prompts.js +384 -0
  77. package/src/providerShapes.js +44 -0
  78. package/src/providers/base.js +41 -0
  79. package/src/providers/index.js +25 -0
  80. package/src/push.js +315 -0
  81. package/src/qr.js +192 -0
  82. package/src/restart.js +47 -0
  83. package/src/server-handlers-access.js +306 -0
  84. package/src/server-handlers-actions.js +100 -0
  85. package/src/server-handlers-ai.js +248 -0
  86. package/src/server-handlers-auth.js +273 -0
  87. package/src/server-handlers-chats.js +1436 -0
  88. package/src/server-handlers-git.js +467 -0
  89. package/src/server-handlers-mcp-oauth.js +56 -0
  90. package/src/server-handlers-misc.js +783 -0
  91. package/src/server-handlers-projects.js +289 -0
  92. package/src/server-handlers-prompts.js +259 -0
  93. package/src/server-handlers-push.js +102 -0
  94. package/src/server-handlers-settings.js +406 -0
  95. package/src/server-handlers-tools.js +654 -0
  96. package/src/server-handlers-transcribe.js +399 -0
  97. package/src/server-shared.js +780 -0
  98. package/src/server-web-static.js +191 -0
  99. package/src/settings.js +898 -0
  100. package/src/statusBar.js +541 -0
  101. package/src/tags.js +414 -0
  102. package/src/toolFeedback.js +225 -0
  103. package/src/tools/ask.js +154 -0
  104. package/src/tools/authorization.js +932 -0
  105. package/src/tools/files.js +1150 -0
  106. package/src/tools/progress.js +71 -0
  107. package/src/tools/restart.js +32 -0
  108. package/src/tools/searchEngine.js +957 -0
  109. package/src/tools/shell.js +341 -0
  110. package/src/tools/subagent.js +47 -0
  111. package/src/tools/task.js +234 -0
  112. package/src/tools/webpreview.js +448 -0
  113. package/src/trace.js +103 -0
  114. package/src/transcribe.js +683 -0
  115. package/src/usage.js +389 -0
  116. package/src/util.js +151 -0
@@ -0,0 +1,294 @@
1
+ 'use strict';
2
+
3
+ // Agent features — dynamic system context that tells the model which
4
+ // mouaif features are enabled/disabled for the current project and
5
+ // chat session. The model receives a terse summary injected into the
6
+ // upstream message array. A companion tool (`list_features`) lets the
7
+ // model query the full structured feature state at any time, which is
8
+ // especially useful in `very-small` prompt mode.
9
+ //
10
+ // Public surface:
11
+ // buildFeatureSummary({ chat, projectDir, project, authz, mcpServers })
12
+ // -> string (system-message content) | null
13
+ // LIST_FEATURES_SPEC : tool spec for the list_features tool
14
+ // dispatchListFeatures(...) : execute the tool call
15
+
16
+ const path = require('path');
17
+
18
+ const TOOL_NAME = 'list_features';
19
+
20
+ const LIST_FEATURES_SPEC = {
21
+ type: 'function',
22
+ function: {
23
+ name: TOOL_NAME,
24
+ description: 'Describe every mouaif feature available in this project and their current on/off/ask state. Returns a JSON object with one key per feature family; each value contains the enable state, authorization mode, and a short description.',
25
+ parameters: {
26
+ type: 'object',
27
+ properties: {},
28
+ additionalProperties: false
29
+ }
30
+ }
31
+ };
32
+
33
+ // buildFeatureSummary({ chat, projectDir, project, authz, mcpServers })
34
+ //
35
+ // Compose a short system message describing which features are
36
+ // available, enabled, and authorized. Returns null when no features
37
+ // are relevant (to avoid injecting an empty message).
38
+ function buildFeatureSummary(opts) {
39
+ const { chat, projectDir, project, authz, mcpServers } = opts || {};
40
+ const lines = [];
41
+ const feat = [];
42
+
43
+ // --- Built-in tools ---
44
+ // shell
45
+ if (authz && authz.tools && authz.tools.shell) {
46
+ const s = authz.tools.shell;
47
+ if (s.mode !== 'off') {
48
+ feat.push('[shell](off|ask|allow) → ' + s.mode + (s.mode === 'allowlist' ? ' (' + (Array.isArray(s.allowlist) ? s.allowlist.length : 0) + ' patterns)' : ''));
49
+ // Let the model know which shell dialect runs its commands
50
+ // (cmd.exe vs a POSIX sh) so it picks the right syntax.
51
+ try {
52
+ const shellMod = require('./tools/shell.js');
53
+ if (typeof shellMod.shellDialectHint === 'function') {
54
+ feat.push('[shell] ' + shellMod.shellDialectHint());
55
+ }
56
+ } catch { /* label is best-effort */ }
57
+ }
58
+ }
59
+ // subagent
60
+ if (authz && authz.tools && authz.tools.subagent) {
61
+ const s = authz.tools.subagent;
62
+ if (s.mode !== 'off') {
63
+ feat.push('[subagent](off|ask|allow) → ' + s.mode);
64
+ }
65
+ }
66
+ // progress updates
67
+ if (authz && authz.tools && authz.tools.report_progress) {
68
+ const s = authz.tools.report_progress;
69
+ if (s.mode !== 'off') {
70
+ feat.push('[report_progress](off|ask|allow) → ' + s.mode);
71
+ }
72
+ }
73
+ // ask_user
74
+ if (authz && authz.tools && authz.tools.ask_user) {
75
+ const s = authz.tools.ask_user;
76
+ if (s.mode !== 'off') {
77
+ feat.push('[ask_user](off|ask) → ' + s.mode);
78
+ }
79
+ }
80
+ // task tool
81
+ if (authz && authz.tools && authz.tools.task) {
82
+ const s = authz.tools.task;
83
+ if (s.mode !== 'off') {
84
+ feat.push('[task](off|ask|allow) → ' + s.mode);
85
+ }
86
+ }
87
+ // file tools
88
+ if (authz && authz.tools && authz.tools.file) {
89
+ const s = authz.tools.file;
90
+ if (s.mode !== 'off') {
91
+ feat.push('[file tools](off|ask|allow) → ' + s.mode + (s.mode === 'allowlist' ? ' (' + (Array.isArray(s.allowlist) ? s.allowlist.length : 0) + ' patterns)' : ''));
92
+ }
93
+ }
94
+
95
+ // app restart
96
+ if (authz && authz.tools && authz.tools.restart_app) {
97
+ const s = authz.tools.restart_app;
98
+ if (s.mode !== 'off') feat.push('[restart app](off|ask|allow) → ' + s.mode + ' — graceful supervisor relaunch from chat');
99
+ }
100
+ // --- MCP servers ---
101
+ if (Array.isArray(mcpServers) && mcpServers.length) {
102
+
103
+ // NOTE: the MCP line deliberately carries ONLY the configured server
104
+ // count — never the live running/stopped status. The feature summary
105
+ // rides inside the cached system block of Anthropic prompt caching;
106
+ // a status flip (on-demand start, error, restart) mid-conversation
107
+ // would change that block and invalidate the entire warm cache for
108
+ // the rest of the chat. The model can query live status via
109
+ // list_features / the MCP tools if it actually needs it.
110
+ feat.push('[MCP] ' + mcpServers.length + ' configured');
111
+ }
112
+
113
+ // --- Agent files ---
114
+ // Resolve whether agent files are enabled (per-chat -> project -> profile default)
115
+ let agentFilesEnabled = false;
116
+ try {
117
+ const agentFiles = require('./agentFiles.js');
118
+ agentFilesEnabled = agentFiles.resolveEnabled({ chat, projectDir });
119
+ } catch { /* safe default */ }
120
+ if (agentFilesEnabled) {
121
+ feat.push('[agent files] enabled — AGENTS.md / CLAUDE.md / .github/copilot-instructions.md');
122
+ }
123
+
124
+ // --- Project agents (subagent delegation targets) ---
125
+ try {
126
+ const agents = require('./agents.js');
127
+ const list = agents.list(projectDir);
128
+ if (list.length) {
129
+ feat.push('[agents] ' + list.length + ' available — subagent delegation targets');
130
+ }
131
+ } catch { /* safe default */ }
132
+
133
+ // --- File tagging ---
134
+ // Tagged files are always injected when the project has tag entries.
135
+ // We check if any tags are configured.
136
+ let tagsExist = false;
137
+ try {
138
+ const tags = require('./tags.js');
139
+ const tagList = tags.getTags(projectDir);
140
+ tagsExist = tagList && typeof tagList === 'object' && Object.keys(tagList).length > 0;
141
+ } catch { /* safe default */ }
142
+ if (tagsExist) {
143
+ feat.push('[file tagging] active — tags found in project');
144
+ }
145
+
146
+ // --- Trace ---
147
+ const traceOn = chat && chat.trace === true;
148
+ if (traceOn) {
149
+ feat.push('[trace] on — events written to .mouaif/traces/');
150
+ }
151
+
152
+ // --- Prompt profile ---
153
+ let profileId = 'average';
154
+ try {
155
+ const promptProfiles = require('./promptProfiles.js');
156
+ const profile = promptProfiles.resolveProfile({ chat, projectDir });
157
+ if (profile && profile.id) profileId = profile.id;
158
+ } catch { /* safe default */ }
159
+ feat.push('[prompt profile] ' + profileId);
160
+
161
+ if (!feat.length) return null;
162
+
163
+ lines.push('This mouaif project has these features:');
164
+ lines.push('');
165
+ for (const f of feat) lines.push('- ' + f);
166
+ lines.push('');
167
+ lines.push('You also have a `list_features` tool available — call it to get the complete structured feature state with every detail.');
168
+
169
+ return lines.join('\n');
170
+ }
171
+
172
+ // dispatchListFeatures(args, opts)
173
+ //
174
+ // Handler for the `list_features` tool call. Returns the full structured
175
+ // feature state as a JSON object so the model can inspect details.
176
+ async function dispatchListFeatures(args, opts) {
177
+ const chatId = opts && opts.chatId;
178
+ const projectDir = opts && opts.projectDir;
179
+ // Load the chat record to inspect trace, agentFiles, promptSize, etc.
180
+ let chat = opts && opts.chat;
181
+ if (!chat && projectDir && chatId) {
182
+ try {
183
+ const chats = require('./chats.js');
184
+ chat = chats.getChat(projectDir, chatId);
185
+ } catch { /* safe default */ }
186
+ }
187
+ const result = { ok: true, content: '', result: null };
188
+
189
+ const state = {};
190
+
191
+ // Built-in tool authorization
192
+ try {
193
+ const authz = require('./tools/authorization.js');
194
+ const authState = authz.getAuthorization(projectDir);
195
+ const tools = (authState && authState.tools) || {};
196
+ state.tools = {};
197
+ for (const name of ['shell', 'subagent', 'file', 'ask_user', 'report_progress', 'task', 'webpreview', 'restart_app']) {
198
+ const cfg = tools[name] || { mode: 'ask' };
199
+ state.tools[name] = {
200
+ mode: cfg.mode || 'ask',
201
+ allowlist: cfg.allowlist || [],
202
+ defaultTimeoutMs: cfg.defaultTimeoutMs,
203
+ maxTimeoutMs: cfg.maxTimeoutMs
204
+ };
205
+ }
206
+ // Surface the interpreter the shell tool runs under, so the model
207
+ // can pick the right syntax from the structured state too.
208
+ try {
209
+ const shellMod = require('./tools/shell.js');
210
+ const hint = typeof shellMod.shellDialectHint === 'function' ? shellMod.shellDialectHint() : '';
211
+ if (hint) {
212
+ state.tools.shell = Object.assign({}, state.tools.shell, {
213
+ dialect: hint,
214
+ exe: process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : (process.env.SHELL || '/bin/sh')
215
+ });
216
+ }
217
+ } catch { /* shell module unavailable; keep auth state only */ }
218
+ } catch (e) {
219
+ state.tools = { _error: e.message };
220
+ }
221
+
222
+ // MCP servers
223
+ try {
224
+ const mcpMod = require('./mcp.js');
225
+ const serverList = typeof mcpMod.listServers === 'function' ? mcpMod.listServers(projectDir) : [];
226
+ if (Array.isArray(serverList)) {
227
+ state.mcp = [];
228
+ for (const s of serverList) {
229
+ const entry = { name: s.name, slug: s.slug, status: s.status, tools: s.tools || [] };
230
+ if (s.authorization) entry.authorization = s.authorization;
231
+ state.mcp.push(entry);
232
+ }
233
+ }
234
+ } catch (e) {
235
+ state.mcp = { _error: e.message };
236
+ }
237
+
238
+ // Agent files
239
+ try {
240
+ const agentFiles = require('./agentFiles.js');
241
+ state.agentFiles = {
242
+ enabled: agentFiles.resolveEnabled({ chat, projectDir }),
243
+ fileNames: agentFiles.resolveFileNames({ chat, projectDir }),
244
+ discovered: agentFiles.discover(projectDir).map(f => ({ name: f.name, size: f.size }))
245
+ };
246
+ } catch (e) {
247
+ state.agentFiles = { _error: e.message };
248
+ }
249
+
250
+ // Project agents (subagent delegation targets)
251
+ try {
252
+ const agents = require('./agents.js');
253
+ state.agents = {
254
+ discovered: agents.list(projectDir).map(a => ({ name: a.name }))
255
+ };
256
+ } catch (e) {
257
+ state.agents = { _error: e.message };
258
+ }
259
+
260
+ // File tagging
261
+ try {
262
+ const tags = require('./tags.js');
263
+ const tagList = tags.getTags(projectDir);
264
+ state.fileTagging = {
265
+ active: tagList && typeof tagList === 'object' && Object.keys(tagList).length > 0,
266
+ count: tagList && typeof tagList === 'object' ? Object.keys(tagList).length : 0
267
+ };
268
+ } catch (e) {
269
+ state.fileTagging = { _error: e.message };
270
+ }
271
+
272
+ // Trace
273
+ state.trace = chat && chat.trace === true;
274
+
275
+ // Prompt profile
276
+ try {
277
+ const promptProfiles = require('./promptProfiles.js');
278
+ const profile = promptProfiles.resolveProfile({ chat, projectDir });
279
+ state.promptProfile = profile ? { id: profile.id, label: profile.label } : null;
280
+ } catch (e) {
281
+ state.promptProfile = null;
282
+ }
283
+
284
+ result.content = JSON.stringify(state, null, 2);
285
+ result.result = state;
286
+ return result;
287
+ }
288
+
289
+ module.exports = {
290
+ TOOL_NAME,
291
+ LIST_FEATURES_SPEC,
292
+ buildFeatureSummary,
293
+ dispatchListFeatures
294
+ };
@@ -0,0 +1,164 @@
1
+ 'use strict';
2
+
3
+ // Agent files — well-known instruction files at the project root that
4
+ // coding assistants read (AGENTS.md, CLAUDE.md, .github/copilot-instructions.md).
5
+ //
6
+ // When enabled for a chat, the content of every agent file found at the
7
+ // project root is injected into the upstream system context, after the
8
+ // prompt-size profile and before the custom prompt. Each file rides as
9
+ // its own `system` message so the model can see the file boundary.
10
+ //
11
+ // Discovery is read-only: we never create, modify, or delete these files.
12
+ // Missing files are silently skipped. Files larger than MAX_BYTES are
13
+ // truncated so a huge AGENTS.md does not blow the context budget.
14
+ //
15
+ // Public surface:
16
+ // DEFAULT_AGENT_FILE_NAMES : ordered list of basenames we look for
17
+ // MAX_BYTES : per-file size cap (default 64 KiB)
18
+ // discover(projectDir) -> [{ name, absPath, size }]
19
+ // load(projectDir) -> [{ name, role, content }]
20
+ // isEnabledForChat(chat) -> boolean
21
+ // resolveEnabled({chat, projectDir}) -> boolean
22
+ // resolveFileNames({chat, projectDir}) -> string[]
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const settings = require('./settings.js');
27
+
28
+ // Basenames we look for, in priority order. First match wins per slot;
29
+ // all matches are injected (they are complementary, not exclusive).
30
+ // The project-level `agentFileNames` setting can override this list.
31
+ const DEFAULT_AGENT_FILE_NAMES = [
32
+ 'AGENTS.md',
33
+ 'CLAUDE.md',
34
+ '.github/copilot-instructions.md'
35
+ ];
36
+
37
+ // Per-file size cap. Files larger than this are truncated to the cap
38
+ // with a trailing note so the model knows it is not the whole file.
39
+ const MAX_BYTES = 64 * 1024;
40
+
41
+ // resolveFileNames({ chat, projectDir }) -> string[]
42
+ //
43
+ // Return the list of basenames to look for. Resolution order:
44
+ // 1. project.agentFileNames (array of strings) — explicit override
45
+ // 2. DEFAULT_AGENT_FILE_NAMES
46
+ // The chat record does not carry its own file list; the per-chat toggle
47
+ // is a boolean on/off, not a per-file picker.
48
+ function resolveFileNames({ chat, projectDir }) {
49
+ void chat; // reserved for future per-chat file lists
50
+ if (projectDir) {
51
+ try {
52
+ const project = settings.getProject(projectDir);
53
+ if (project && Array.isArray(project.agentFileNames) && project.agentFileNames.length) {
54
+ return project.agentFileNames.map(String).filter(Boolean);
55
+ }
56
+ } catch { /* fall through to default */ }
57
+ }
58
+ return DEFAULT_AGENT_FILE_NAMES.slice();
59
+ }
60
+
61
+ // discover(projectDir, names?) -> [{ name, absPath, size }]
62
+ //
63
+ // Return the agent files that exist at the project root. Missing files
64
+ // are skipped silently. The result is ordered by the names list so the
65
+ // most specific file (AGENTS.md) is first. If no names are given the
66
+ // project-level/default list is used.
67
+ function discover(projectDir, names) {
68
+ if (!projectDir || typeof projectDir !== 'string') return [];
69
+ const list = Array.isArray(names) && names.length ? names : resolveFileNames({ projectDir });
70
+ const out = [];
71
+ for (const name of list) {
72
+ const absPath = path.join(projectDir, name);
73
+ try {
74
+ const st = fs.statSync(absPath);
75
+ if (st.isFile()) out.push({ name, absPath, size: st.size });
76
+ } catch { /* missing or unreadable — skip */ }
77
+ }
78
+ return out;
79
+ }
80
+
81
+ // load(projectDir, names?) -> [{ name, role, content }]
82
+ //
83
+ // Read every discovered agent file and return it as a system-message
84
+ // payload. Files larger than MAX_BYTES are truncated. Unreadable files
85
+ // are skipped. The content is wrapped in a small header so the model
86
+ // knows which file it came from. If no names are given the
87
+ // project-level/default list is used.
88
+ function load(projectDir, names) {
89
+ const files = discover(projectDir, names);
90
+ const out = [];
91
+ for (const f of files) {
92
+ let text;
93
+ try {
94
+ const buf = fs.readFileSync(f.absPath);
95
+ if (buf.length > MAX_BYTES) {
96
+ text = buf.slice(0, MAX_BYTES).toString('utf8')
97
+ + '\n\n[... truncated at ' + MAX_BYTES + ' bytes ...]';
98
+ } else {
99
+ text = buf.toString('utf8');
100
+ }
101
+ } catch { continue; }
102
+ if (!text.trim()) continue;
103
+ out.push({
104
+ name: f.name,
105
+ role: 'system',
106
+ content: 'Project agent instructions from ' + f.name + ':\n\n' + text
107
+ });
108
+ }
109
+ return out;
110
+ }
111
+
112
+ // isEnabledForChat(chat) -> boolean
113
+ //
114
+ // Per-chat toggle. The chat record carries an `agentFiles` field:
115
+ // true -> inject agent files
116
+ // false -> do not inject
117
+ // undefined / null -> fall through to the profile default
118
+ // The profile default is: very-small = OFF, average/extensive = ON.
119
+ function isEnabledForChat(chat) {
120
+ if (!chat || typeof chat !== 'object') return true;
121
+ if (chat.agentFiles === false) return false;
122
+ if (chat.agentFiles === true) return true;
123
+ // No explicit per-chat choice: fall back to the prompt-size profile.
124
+ // very-small is meant to be the tightest context, so agent files are
125
+ // off there. average and extensive get them by default.
126
+ const size = chat.promptSize || 'average';
127
+ return size !== 'very-small';
128
+ }
129
+
130
+ // resolveEnabled({ chat, projectDir }) -> boolean
131
+ //
132
+ // Full resolution: project-level gate -> per-chat override -> profile default.
133
+ // The project-level setting is the master switch: if it is explicitly `false`
134
+ // agent files are disabled regardless of the per-chat toggle. The per-chat
135
+ // toggle can only *disable* (set to false) when the project has it on.
136
+ // If neither is explicitly set the profile default applies.
137
+ function resolveEnabled({ chat, projectDir }) {
138
+ // 1. Project-level master switch
139
+ if (projectDir) {
140
+ try {
141
+ const project = settings.getProject(projectDir);
142
+ if (project && typeof project.agentFiles === 'boolean') {
143
+ if (project.agentFiles === false) return false; // locked off
144
+ // project is true — per-chat can still override to false
145
+ if (chat && chat.agentFiles === false) return false;
146
+ return true;
147
+ }
148
+ } catch { /* fall through */ }
149
+ }
150
+ // 2. Per-chat explicit (no project-level setting)
151
+ if (chat && typeof chat.agentFiles === 'boolean') return chat.agentFiles;
152
+ // 3. Profile default
153
+ return isEnabledForChat(chat);
154
+ }
155
+
156
+ module.exports = {
157
+ DEFAULT_AGENT_FILE_NAMES,
158
+ MAX_BYTES,
159
+ discover,
160
+ load,
161
+ isEnabledForChat,
162
+ resolveEnabled,
163
+ resolveFileNames
164
+ };
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+
3
+ // Agent Skills-compatible discovery and progressive activation.
4
+ // Format: https://agentskills.io/specification
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const settings = require('./settings.js');
9
+ const MAX_BYTES = 512 * 1024;
10
+ const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
11
+
12
+ function parseScalar(raw) {
13
+ const s = String(raw || '').trim();
14
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) return s.slice(1, -1);
15
+ return s;
16
+ }
17
+
18
+ // Parse the spec's shallow frontmatter fields without adding a YAML runtime.
19
+ // metadata is retained as string pairs; malformed/unsupported YAML is diagnosed.
20
+ function parseSkillFile(text, directoryName) {
21
+ const diagnostics = [];
22
+ if (!text.startsWith('---\n') && !text.startsWith('---\r\n')) return { valid: false, diagnostics: ['missing YAML frontmatter'] };
23
+ const lines = text.split(/\r?\n/);
24
+ const close = lines.indexOf('---', 1);
25
+ if (close < 0) return { valid: false, diagnostics: ['unterminated YAML frontmatter'] };
26
+ const metadata = {};
27
+ let map = null;
28
+ for (const line of lines.slice(1, close)) {
29
+ if (!line.trim() || /^\s*#/.test(line)) continue;
30
+ const nested = line.match(/^\s+([A-Za-z0-9_.-]+):\s*(.*)$/);
31
+ if (nested && map) { map[nested[1]] = parseScalar(nested[2]); continue; }
32
+ const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
33
+ if (!m) { diagnostics.push('unsupported YAML line: ' + line.trim()); continue; }
34
+ if (m[1] === 'metadata' && !m[2]) { metadata.metadata = {}; map = metadata.metadata; continue; }
35
+ map = null;
36
+ metadata[m[1]] = parseScalar(m[2]);
37
+ }
38
+ const name = metadata.name || '';
39
+ const description = metadata.description || '';
40
+ if (!name) diagnostics.push('name is required');
41
+ if (!description) diagnostics.push('description is required');
42
+ if (name && (!NAME_RE.test(name) || name.length > 64 || name !== directoryName)) diagnostics.push('name must match its directory and use 1-64 lowercase letters, numbers, or single hyphens');
43
+ if (description.length > 1024) diagnostics.push('description exceeds 1024 characters');
44
+ if (metadata.compatibility && metadata.compatibility.length > 500) diagnostics.push('compatibility exceeds 500 characters');
45
+ const validName = !!name && NAME_RE.test(name) && name.length <= 64 && name === directoryName;
46
+ const validDescription = !!description && description.length <= 1024;
47
+ const validCompatibility = !metadata.compatibility || metadata.compatibility.length <= 500;
48
+ return { valid: validName && validDescription && validCompatibility, name, description, metadata, body: lines.slice(close + 1).join('\n').trim(), diagnostics };
49
+ }
50
+
51
+ function safeSkillFile(projectDir, root, entry) {
52
+ const file = path.join(root, entry.name, 'SKILL.md');
53
+ try {
54
+ const realRoot = fs.realpathSync(projectDir);
55
+ const realFile = fs.realpathSync(file);
56
+ if (realFile !== realRoot && !realFile.startsWith(realRoot + path.sep)) return null;
57
+ const stat = fs.statSync(realFile);
58
+ if (!stat.isFile() || stat.size > MAX_BYTES) return null;
59
+ const text = fs.readFileSync(realFile, 'utf8');
60
+ const parsed = parseSkillFile(text, entry.name);
61
+ if (!parsed.valid) return null;
62
+ return Object.assign({ id: parsed.name, path: path.relative(projectDir, realFile).replace(/\\/g, '/'), absPath: realFile, root: path.dirname(realFile), size: stat.size }, parsed);
63
+ } catch { return null; }
64
+ }
65
+
66
+ function discover(projectDir) {
67
+ if (!projectDir || typeof projectDir !== 'string') return [];
68
+ const root = path.join(projectDir, '.agents', 'skills');
69
+ let entries;
70
+ try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return []; }
71
+ return entries.filter((e) => e.isDirectory()).map((e) => safeSkillFile(projectDir, root, e)).filter(Boolean).sort((a, b) => a.name.localeCompare(b.name));
72
+ }
73
+
74
+ // normalizeDisabledSkills(raw) -> Set<string>
75
+ //
76
+ // Both the project and the chat carry a `disabledSkills` list of skill
77
+ // ids/names. Anything that is not a string list is treated as empty so a
78
+ // hand-edited `.mouaif.json` (or a legacy row) can never take discovery down.
79
+ function normalizeDisabledSkills(raw) {
80
+ if (!Array.isArray(raw)) return new Set();
81
+ return new Set(raw.map((n) => String(n)).filter(Boolean));
82
+ }
83
+
84
+ // resolve({ chat, projectDir }) -> {
85
+ // enabled, projectEnabled, disabled, projectDisabled, chatDisabled, skills
86
+ // }
87
+ //
88
+ // `disabled` is the union used for filtering (catalog + activate_skill).
89
+ // The two halves are kept separate so the chat UI can tell a skill the
90
+ // project switched off (locked, with a reason) from one the user
91
+ // unchecked in this chat (a plain per-chat opt-out).
92
+ function resolve({ chat, projectDir }) {
93
+ let project = {};
94
+ try { project = settings.getProject(projectDir) || {}; } catch { /* defaults */ }
95
+ const projectEnabled = project.skills !== false;
96
+ const enabled = projectEnabled && !(chat && chat.skills === false);
97
+ const projectDisabled = normalizeDisabledSkills(project.disabledSkills);
98
+ const chatDisabled = normalizeDisabledSkills(chat && chat.disabledSkills);
99
+ const disabled = new Set([...projectDisabled, ...chatDisabled]);
100
+ return { enabled, projectEnabled, disabled, projectDisabled, chatDisabled, skills: discover(projectDir) };
101
+ }
102
+
103
+ function available(projectDir, chat) {
104
+ const state = resolve({ chat, projectDir });
105
+ return state.enabled ? state.skills.filter((s) => !state.disabled.has(s.name)) : [];
106
+ }
107
+
108
+ function catalogMessage(projectDir, chat) {
109
+ const skills = available(projectDir, chat);
110
+ if (!skills.length) return '';
111
+ return 'Available Agent Skills (metadata only):\n' + skills.map((s) => '- ' + s.name + ': ' + s.description).join('\n')
112
+ + '\nWhen a task matches a skill, call activate_skill with its name before proceeding. Load referenced resources only as needed, relative to the returned skill directory.';
113
+ }
114
+
115
+ function buildSpec(projectDir, chat) {
116
+ const names = available(projectDir, chat).map((s) => s.name);
117
+ if (!names.length) return null;
118
+ return { type: 'function', function: { name: 'activate_skill', description: 'Load one available Agent Skill when its description matches the task.', parameters: { type: 'object', properties: { name: { type: 'string', enum: names, description: 'Skill name from the available skills catalog.' } }, required: ['name'], additionalProperties: false } } };
119
+ }
120
+
121
+ function listResources(skill) {
122
+ const out = [];
123
+ function walk(dir, depth) {
124
+ if (depth > 3 || out.length >= 200) return;
125
+ let entries;
126
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
127
+ for (const entry of entries) {
128
+ if (out.length >= 200) break;
129
+ const abs = path.join(dir, entry.name);
130
+ if (entry.isDirectory()) walk(abs, depth + 1);
131
+ else if (entry.isFile() && abs !== skill.absPath) out.push(path.relative(skill.root, abs).replace(/\\/g, '/'));
132
+ }
133
+ }
134
+ walk(skill.root, 0);
135
+ return out;
136
+ }
137
+
138
+ function activate(projectDir, chat, name) {
139
+ const skill = available(projectDir, chat).find((s) => s.name === name);
140
+ if (!skill) { const e = new Error('skill is unavailable: ' + name); e.code = 'ENO_SKILL'; throw e; }
141
+ const resources = listResources(skill);
142
+ const content = '<skill_content name="' + skill.name + '">\n' + skill.body + '\n\nSkill directory: ' + skill.root
143
+ + '\nRelative paths are relative to this directory.' + (resources.length ? '\nResources (load only when needed):\n' + resources.map((r) => '- ' + r).join('\n') : '') + '\n</skill_content>';
144
+ return { ok: true, content, result: { name: skill.name, description: skill.description, directory: skill.root, resources, content } };
145
+ }
146
+
147
+ module.exports = { MAX_BYTES, NAME_RE, parseSkillFile, discover, resolve, available, catalogMessage, buildSpec, activate };