@dotdrelle/wiki-manager 0.11.6 → 0.11.7
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/package.json +1 -1
- package/src/agent/graph.js +50 -33
- package/src/agent/graph.test.js +87 -11
- package/src/commands/slash.js +36 -11
- package/src/core/mcp.js +1 -1
- package/src/core/profile.js +111 -0
- package/src/core/skills.js +1 -1
- package/src/shell/StartupScreen.tsx +21 -6
- package/src/shell/repl.js +38 -30
- package/src/shell/repl.test.js +26 -0
package/package.json
CHANGED
package/src/agent/graph.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
1
2
|
import { Annotation, END, START, StateGraph } from '@langchain/langgraph';
|
|
2
3
|
import {
|
|
3
4
|
buildLlmTools,
|
|
@@ -6,45 +7,17 @@ import {
|
|
|
6
7
|
formatMcpToolsForAgent,
|
|
7
8
|
parseToolCallName,
|
|
8
9
|
} from '../core/mcp.js';
|
|
9
|
-
import { formatSkillsForAgent } from '../core/skills.js';
|
|
10
|
+
import { formatSkillsForAgent, readOptionalText } from '../core/skills.js';
|
|
10
11
|
import { handleSlashCommand } from '../commands/slash.js';
|
|
11
12
|
import { extractActivity, formatActivitySummary, parseJsonText } from '../core/activity.js';
|
|
12
13
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
13
14
|
import { enqueueProductionJob, ensureJobQueue, formatQueue, productionLockBusy } from '../core/jobQueue.js';
|
|
15
|
+
import { updateWorkspaceProfilePreference } from '../core/profile.js';
|
|
14
16
|
|
|
15
17
|
const MAX_TOOL_ITERATIONS = 80;
|
|
16
18
|
const MAX_SPINNER_ARG_LENGTH = 96;
|
|
19
|
+
const MAX_PROFILE_CHARS = 4000;
|
|
17
20
|
|
|
18
|
-
// Deterministic guard: on the first turn of a fresh /agent input, only bind
|
|
19
|
-
// job-starting/mutating MCP tools when the raw text actually looks like an
|
|
20
|
-
// action request. Plain chat (greetings, thanks, small talk) never sees those
|
|
21
|
-
// tools bound, so the LLM cannot start a production job/plan from a "salut" —
|
|
22
|
-
// prompting alone was not reliable enough (see plan-directeur history).
|
|
23
|
-
const ACTION_INTENT_RE = /\b(lance|lancer|lancez|d[ée]marre|d[ée]marrer|d[ée]marrez|ex[ée]cute|ex[ée]cuter|exporte|exporter|export|importe|importer|import|g[ée]n[èe]re|g[ée]n[ée]rer|g[ée]n[ée]ration|cr[ée]e|cr[ée]er|construis|build|run|start|launch|deploy|d[ée]ploie|d[ée]ployer|d[ée]ploiement|publie|publier|publish|publication|synchronise|synchroniser|sync|convertis|convertir|convert|envoie|envoyer|send|configure|configurer|ajoute|ajouter|add|planifie|planifier|schedule|ingest|ing[èe]re|ing[èe]rer|ingestion|polish|pipeline|skill|job|t[âa]che|workflow|refais|relance|retente|retry)\b/i;
|
|
24
|
-
const READ_ONLY_TOOL_NAME_RE = /(^|_)(list|status|get|read|describe|show|search|find)(_|$)/i;
|
|
25
|
-
|
|
26
|
-
function isReadOnlyToolName(name) {
|
|
27
|
-
return READ_ONLY_TOOL_NAME_RE.test(String(name ?? ''));
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function filterToolsForChatOnlyTurn(tools) {
|
|
31
|
-
return [
|
|
32
|
-
SHELL_READ_COMMAND_TOOL,
|
|
33
|
-
...tools.filter((tool) => isReadOnlyToolName(tool?.function?.name ?? '')),
|
|
34
|
-
];
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function toolsForDonnaTurn(input, tools, iterations) {
|
|
38
|
-
if (iterations !== 0) return tools;
|
|
39
|
-
const text = String(input ?? '');
|
|
40
|
-
if (ACTION_INTENT_RE.test(text)) return tools;
|
|
41
|
-
// Plain chat (no action, no read intent) still gets the minimal read-only
|
|
42
|
-
// set, never an empty array: the system prompt unconditionally describes
|
|
43
|
-
// the connected MCP tools regardless of what's actually bound this turn,
|
|
44
|
-
// and sending zero tools while the prompt talks about them confuses some
|
|
45
|
-
// models into an empty/malformed completion instead of a plain reply.
|
|
46
|
-
return filterToolsForChatOnlyTurn(tools);
|
|
47
|
-
}
|
|
48
21
|
const AGENT_SLASH_COMMANDS = new Set([
|
|
49
22
|
'help',
|
|
50
23
|
'version',
|
|
@@ -105,6 +78,28 @@ const SHELL_READ_COMMAND_TOOL = {
|
|
|
105
78
|
},
|
|
106
79
|
};
|
|
107
80
|
|
|
81
|
+
const SHELL_PROFILE_UPDATE_TOOL = {
|
|
82
|
+
type: 'function',
|
|
83
|
+
function: {
|
|
84
|
+
name: 'shell__profile_update',
|
|
85
|
+
description: [
|
|
86
|
+
'Append one explicit durable user preference to the current workspace .wiki/profile.md.',
|
|
87
|
+
'Use when the user explicitly asks to remember, persist, note, or update profile information and wiki__profile_update is not available.',
|
|
88
|
+
].join(' '),
|
|
89
|
+
parameters: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
additionalProperties: false,
|
|
92
|
+
properties: {
|
|
93
|
+
preference: {
|
|
94
|
+
type: 'string',
|
|
95
|
+
description: 'The durable preference to append, without Markdown bullet syntax.',
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
required: ['preference'],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
108
103
|
const WIKI_PLAN_SET_TOOL = {
|
|
109
104
|
type: 'function',
|
|
110
105
|
function: {
|
|
@@ -510,6 +505,18 @@ function selectExecutorForStep(description, session) {
|
|
|
510
505
|
return fallback;
|
|
511
506
|
}
|
|
512
507
|
|
|
508
|
+
// The manager runs on the same host filesystem as the workspace directory
|
|
509
|
+
// (this is the same local file wiki__profile_update writes to via its
|
|
510
|
+
// volume-mounted container), so read it fresh on every turn instead of
|
|
511
|
+
// relying on the model proactively calling wiki__profile_read — profile
|
|
512
|
+
// content (tutoiement, formatting preferences, etc.) is meant to shape every
|
|
513
|
+
// reply, not just ones where the model happens to think to check it.
|
|
514
|
+
function loadWorkspaceProfile(workspacePath) {
|
|
515
|
+
if (!workspacePath) return null;
|
|
516
|
+
const content = readOptionalText(join(workspacePath, '.wiki', 'profile.md'));
|
|
517
|
+
return content ? content.slice(0, MAX_PROFILE_CHARS) : null;
|
|
518
|
+
}
|
|
519
|
+
|
|
513
520
|
export function buildAgentSystemPrompt(state) {
|
|
514
521
|
const workspace = state.session.workspace ?? 'no workspace selected';
|
|
515
522
|
const wikirc = state.session.wikirc?.profile ?? 'no profile loaded';
|
|
@@ -517,6 +524,7 @@ export function buildAgentSystemPrompt(state) {
|
|
|
517
524
|
const mcpTools = formatMcpToolsForAgent(state.session.mcp);
|
|
518
525
|
const skills = formatSkillsForAgent(state.session);
|
|
519
526
|
const customPrompt = state.session.systemPrompt ?? null;
|
|
527
|
+
const workspaceProfile = loadWorkspaceProfile(state.session.workspacePath);
|
|
520
528
|
|
|
521
529
|
const agentContext = [
|
|
522
530
|
'You are Donna, the terminal orchestrator agent for llm-wiki-manager.',
|
|
@@ -576,7 +584,11 @@ export function buildAgentSystemPrompt(state) {
|
|
|
576
584
|
'If production_start_job is returned as queued/waiting by the manager, report that it is waiting in the local queue and return control. Do not continue as if the production job has started.',
|
|
577
585
|
'For diagnostics, use /wiki run doctor when the user asks for doctor. Use /workspace init <name> [path] for low-level non-interactive workspace creation. In the interactive TUI, /new <name> opens the setup wizard. Use /wiki for index, or /wiki run index through the explicit backup hatch. Use /wiki run init only for explicit current-workspace llm-wiki init.',
|
|
578
586
|
'If an action requires tools or skills not available yet, explain the limitation and name the expected primitive.',
|
|
579
|
-
|
|
587
|
+
workspaceProfile
|
|
588
|
+
? `Workspace profile (.wiki/profile.md) — durable user preferences, apply these to every reply (tone, tutoiement/vouvoiement, formatting, etc.):\n${workspaceProfile}`
|
|
589
|
+
: null,
|
|
590
|
+
'When the user explicitly asks you to remember, persist, or update durable preference/profile information, call wiki__profile_update when it is available; otherwise call shell__profile_update. Do not just acknowledge in text without calling a profile update tool.',
|
|
591
|
+
].filter(Boolean).join('\n');
|
|
580
592
|
|
|
581
593
|
return customPrompt ? `${customPrompt}\n\n${agentContext}` : agentContext;
|
|
582
594
|
}
|
|
@@ -630,11 +642,13 @@ export function createAgentGraph(options = {}) {
|
|
|
630
642
|
|
|
631
643
|
const allTools = [
|
|
632
644
|
SHELL_RUN_COMMAND_TOOL,
|
|
645
|
+
SHELL_READ_COMMAND_TOOL,
|
|
646
|
+
SHELL_PROFILE_UPDATE_TOOL,
|
|
633
647
|
WIKI_PLAN_SET_TOOL,
|
|
634
648
|
WIKI_PLAN_DONE_TOOL,
|
|
635
649
|
...buildLlmTools(state.session.mcp),
|
|
636
650
|
];
|
|
637
|
-
const tools =
|
|
651
|
+
const tools = allTools;
|
|
638
652
|
const system = buildAgentSystemPrompt(state);
|
|
639
653
|
|
|
640
654
|
// On iteration 0: prior history is in state.messages, user input must be appended.
|
|
@@ -759,6 +773,9 @@ export function createAgentGraph(options = {}) {
|
|
|
759
773
|
resultText = await runShellCommandTool(state.session, args.command);
|
|
760
774
|
} else if (server === 'shell' && tool === 'read_command') {
|
|
761
775
|
resultText = await runShellReadCommandTool(state.session, args.command);
|
|
776
|
+
} else if (server === 'shell' && tool === 'profile_update') {
|
|
777
|
+
const result = await updateWorkspaceProfilePreference(state.session, args.preference);
|
|
778
|
+
resultText = JSON.stringify(result, null, 2);
|
|
762
779
|
} else if (server !== 'shell') {
|
|
763
780
|
await awaitRunApproval(state.session, { runId, tool: toolName });
|
|
764
781
|
await awaitToolApproval(state.session, {
|
package/src/agent/graph.test.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import {
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { buildAgentSystemPrompt, createAgentGraph } from './graph.js';
|
|
4
7
|
|
|
5
8
|
function sessionBase(overrides = {}) {
|
|
6
9
|
return {
|
|
@@ -107,7 +110,7 @@ test('agent graph reports LLM unavailable without Donna active boilerplate', asy
|
|
|
107
110
|
assert.doesNotMatch(result.response, /Donna is active/);
|
|
108
111
|
});
|
|
109
112
|
|
|
110
|
-
test('agent graph binds
|
|
113
|
+
test('agent graph binds the full toolset and lets Donna decide whether to call tools', async () => {
|
|
111
114
|
const seenTools = [];
|
|
112
115
|
const session = sessionBase({
|
|
113
116
|
llm: {
|
|
@@ -126,17 +129,17 @@ test('agent graph binds only the minimal read-only tool set for plain discussion
|
|
|
126
129
|
const result = await agent.invoke({ input: 'salut', session });
|
|
127
130
|
|
|
128
131
|
assert.equal(result.response, 'Salut, je suis là.');
|
|
129
|
-
// Never literally empty: the system prompt unconditionally describes the
|
|
130
|
-
// connected MCP tools, and sending zero tools while the prompt talks about
|
|
131
|
-
// them risks an empty/malformed completion from some models instead of a
|
|
132
|
-
// plain reply — this is what actually broke plain "salut" discussion.
|
|
133
132
|
assert.ok(seenTools.length > 0);
|
|
134
|
-
assert.
|
|
133
|
+
assert.ok(seenTools.includes('shell__read_command'));
|
|
134
|
+
assert.ok(seenTools.includes('shell__run_command'));
|
|
135
|
+
assert.ok(seenTools.includes('shell__profile_update'));
|
|
136
|
+
assert.ok(seenTools.includes('wiki__plan_set'));
|
|
137
|
+
assert.ok(seenTools.includes('wiki__plan_done'));
|
|
135
138
|
assert.equal(session.headlessPlan ?? null, null);
|
|
136
139
|
assert.equal(Object.keys(session.activities ?? {}).length, 0);
|
|
137
140
|
});
|
|
138
141
|
|
|
139
|
-
test('agent graph
|
|
142
|
+
test('agent graph does not pre-filter mutating MCP tools for config questions', async () => {
|
|
140
143
|
const seenTools = [];
|
|
141
144
|
const session = sessionBase({
|
|
142
145
|
mcp: {
|
|
@@ -175,9 +178,82 @@ test('agent graph binds read-only tools for config questions without plan tools'
|
|
|
175
178
|
assert.equal(result.response, 'Le profil actif est docs.');
|
|
176
179
|
assert.ok(seenTools.includes('shell__read_command'));
|
|
177
180
|
assert.ok(seenTools.includes('production__production_job_status'));
|
|
178
|
-
assert.
|
|
179
|
-
assert.
|
|
180
|
-
assert.
|
|
181
|
+
assert.ok(seenTools.includes('wiki__plan_set'));
|
|
182
|
+
assert.ok(seenTools.includes('production__production_start_job'));
|
|
183
|
+
assert.ok(seenTools.includes('shell__run_command'));
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('agent graph binds the full toolset for a "remember my preference" request, not just read-only tools', async () => {
|
|
187
|
+
const seenTools = [];
|
|
188
|
+
const session = sessionBase({
|
|
189
|
+
mcp: {
|
|
190
|
+
wiki: {
|
|
191
|
+
status: 'connected',
|
|
192
|
+
url: 'http://127.0.0.1:3001/mcp/',
|
|
193
|
+
tools: [
|
|
194
|
+
{
|
|
195
|
+
name: 'profile_read',
|
|
196
|
+
description: 'Read the workspace profile from .wiki/profile.md.',
|
|
197
|
+
inputSchema: { type: 'object', properties: {} },
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: 'profile_update',
|
|
201
|
+
description: 'Write the workspace profile to .wiki/profile.md.',
|
|
202
|
+
inputSchema: { type: 'object', properties: { content: { type: 'string' } } },
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
llm: {
|
|
208
|
+
async completeWithTools({ tools }) {
|
|
209
|
+
seenTools.push(...tools.map((tool) => tool.function.name));
|
|
210
|
+
return {
|
|
211
|
+
content: 'Noté, je retiens cette préférence.',
|
|
212
|
+
message: { role: 'assistant', content: 'Noté, je retiens cette préférence.' },
|
|
213
|
+
tool_calls: null,
|
|
214
|
+
};
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const agent = createAgentGraph();
|
|
220
|
+
const result = await agent.invoke({ input: 'retiens que je préfère des réponses courtes', session });
|
|
221
|
+
|
|
222
|
+
assert.equal(result.response, 'Noté, je retiens cette préférence.');
|
|
223
|
+
// profile_update is a write tool (doesn't match the read-only name pattern)
|
|
224
|
+
// and "retiens" doesn't appear in the config/status read-only phrasing —
|
|
225
|
+
// without action-intent coverage for remember/save/update requests, this
|
|
226
|
+
// tool would silently never be offered to the LLM at all.
|
|
227
|
+
assert.ok(seenTools.includes('wiki__profile_update'));
|
|
228
|
+
assert.ok(seenTools.includes('wiki__profile_read'));
|
|
229
|
+
assert.ok(seenTools.includes('shell__profile_update'));
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('buildAgentSystemPrompt includes .wiki/profile.md content so preferences apply without a tool call', () => {
|
|
233
|
+
const workspacePath = mkdtempSync(join(tmpdir(), 'donna-profile-'));
|
|
234
|
+
mkdirSync(join(workspacePath, '.wiki'), { recursive: true });
|
|
235
|
+
writeFileSync(
|
|
236
|
+
join(workspacePath, '.wiki', 'profile.md'),
|
|
237
|
+
'# Workspace Profile\n\n## User Preferences\n\n- Tutoiement : me tutoyer\n',
|
|
238
|
+
);
|
|
239
|
+
try {
|
|
240
|
+
const prompt = buildAgentSystemPrompt({
|
|
241
|
+
session: sessionBase({ workspacePath }),
|
|
242
|
+
});
|
|
243
|
+
assert.match(prompt, /Tutoiement : me tutoyer/);
|
|
244
|
+
} finally {
|
|
245
|
+
rmSync(workspacePath, { recursive: true, force: true });
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('buildAgentSystemPrompt omits the profile section when profile.md is missing or empty', () => {
|
|
250
|
+
const workspacePath = mkdtempSync(join(tmpdir(), 'donna-profile-empty-'));
|
|
251
|
+
try {
|
|
252
|
+
const prompt = buildAgentSystemPrompt({ session: sessionBase({ workspacePath }) });
|
|
253
|
+
assert.doesNotMatch(prompt, /Workspace profile \(\.wiki\/profile\.md\)/);
|
|
254
|
+
} finally {
|
|
255
|
+
rmSync(workspacePath, { recursive: true, force: true });
|
|
256
|
+
}
|
|
181
257
|
});
|
|
182
258
|
|
|
183
259
|
test('agent graph waits for tool-level approval configured on endpoint', async () => {
|
package/src/commands/slash.js
CHANGED
|
@@ -655,15 +655,38 @@ export function printHelp(packageJson) {
|
|
|
655
655
|
console.log(helpText(packageJson));
|
|
656
656
|
}
|
|
657
657
|
|
|
658
|
+
function rawCommandAgentPrompt(command, output) {
|
|
659
|
+
return [
|
|
660
|
+
`L'utilisateur a lancé la commande shell ${command}.`,
|
|
661
|
+
'Voici la sortie brute collectée par la commande déterministe. Ne relance pas la commande et ne modifie pas les données.',
|
|
662
|
+
'Réponds à l’utilisateur à partir de ces faits, en appliquant le profil workspace et les préférences de présentation déjà chargés dans ton prompt système.',
|
|
663
|
+
'',
|
|
664
|
+
'Sortie brute:',
|
|
665
|
+
'```text',
|
|
666
|
+
output || '(empty)',
|
|
667
|
+
'```',
|
|
668
|
+
].join('\n');
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function rawCommandResult(command, output) {
|
|
672
|
+
return {
|
|
673
|
+
output,
|
|
674
|
+
rawOutput: true,
|
|
675
|
+
agentTrigger: rawCommandAgentPrompt(command, output),
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
658
679
|
export async function handleSlashCommand(line, context) {
|
|
659
680
|
const args = line.slice(1).trim().split(/\s+/).filter(Boolean);
|
|
660
681
|
const [command] = args;
|
|
661
682
|
const step = context.onStep ?? (() => {});
|
|
662
|
-
const runAgentCommand = async (fn, verb) => {
|
|
683
|
+
const runAgentCommand = async (fn, verb, commandLabel) => {
|
|
663
684
|
try {
|
|
664
685
|
step(`Agents: ${verb}ing external agents…`);
|
|
665
686
|
const output = await fn();
|
|
666
|
-
return
|
|
687
|
+
return output
|
|
688
|
+
? rawCommandResult(commandLabel, output)
|
|
689
|
+
: { output: `Agents ${verb}ed.` };
|
|
667
690
|
} catch (err) {
|
|
668
691
|
step(formatActivityError('agents', verb, err));
|
|
669
692
|
return { output: err instanceof Error ? err.message : String(err) };
|
|
@@ -808,7 +831,8 @@ export async function handleSlashCommand(line, context) {
|
|
|
808
831
|
try {
|
|
809
832
|
step('Services: reading compose state…');
|
|
810
833
|
await refreshMcpRuntimeStatus(context.session);
|
|
811
|
-
|
|
834
|
+
const output = await listServices(context.session);
|
|
835
|
+
return rawCommandResult('/services', output);
|
|
812
836
|
} catch (err) {
|
|
813
837
|
const message = err instanceof Error ? err.message : String(err);
|
|
814
838
|
step(formatActivityError('services', 'list', err));
|
|
@@ -821,13 +845,13 @@ export async function handleSlashCommand(line, context) {
|
|
|
821
845
|
// do not remap it to undefined, that bypasses any custom "all" target list and always
|
|
822
846
|
// falls back to the hardcoded COMPOSE_SERVICES constant instead.
|
|
823
847
|
const service = args[1];
|
|
824
|
-
if (service === 'agents') return runAgentCommand(startAgents, 'start');
|
|
848
|
+
if (service === 'agents') return runAgentCommand(startAgents, 'start', '/start agents');
|
|
825
849
|
try {
|
|
826
850
|
step(`Services: starting ${service ?? 'workspace services'}…`);
|
|
827
851
|
const output = await startService(context.session, service);
|
|
828
852
|
step('Services: refreshing MCP runtime…');
|
|
829
853
|
await refreshMcpRuntimeStatus(context.session);
|
|
830
|
-
return {
|
|
854
|
+
return rawCommandResult(`/start${service ? ` ${service}` : ''}`, output);
|
|
831
855
|
} catch (err) {
|
|
832
856
|
const message = err instanceof Error ? err.message : String(err);
|
|
833
857
|
step(formatActivityError('services', 'stop', err));
|
|
@@ -836,13 +860,13 @@ export async function handleSlashCommand(line, context) {
|
|
|
836
860
|
}
|
|
837
861
|
case 'stop': {
|
|
838
862
|
const service = args[1];
|
|
839
|
-
if (service === 'agents') return runAgentCommand(stopAgents, 'stop');
|
|
863
|
+
if (service === 'agents') return runAgentCommand(stopAgents, 'stop', '/stop agents');
|
|
840
864
|
try {
|
|
841
865
|
step(`Services: stopping ${service ?? 'workspace services'}…`);
|
|
842
866
|
const output = await stopService(context.session, service);
|
|
843
867
|
step('Services: refreshing MCP runtime…');
|
|
844
868
|
await refreshMcpRuntimeStatus(context.session);
|
|
845
|
-
return {
|
|
869
|
+
return rawCommandResult(`/stop${service ? ` ${service}` : ''}`, output);
|
|
846
870
|
} catch (err) {
|
|
847
871
|
const message = err instanceof Error ? err.message : String(err);
|
|
848
872
|
step(formatActivityError('services', 'logs', err));
|
|
@@ -854,7 +878,8 @@ export async function handleSlashCommand(line, context) {
|
|
|
854
878
|
const tail = args[2] ? Number(args[2]) : 120;
|
|
855
879
|
try {
|
|
856
880
|
step(`Services: reading logs for ${service ?? 'service'}…`);
|
|
857
|
-
|
|
881
|
+
const output = await serviceLogs(context.session, service, { tail });
|
|
882
|
+
return rawCommandResult(`/logs ${[service, args[2]].filter(Boolean).join(' ')}`.trim(), output);
|
|
858
883
|
} catch (err) {
|
|
859
884
|
const message = err instanceof Error ? err.message : String(err);
|
|
860
885
|
return { output: message };
|
|
@@ -903,7 +928,7 @@ export async function handleSlashCommand(line, context) {
|
|
|
903
928
|
}
|
|
904
929
|
const activity = formatMcpCallActivity(serverName, toolName, output);
|
|
905
930
|
if (activity) step(activity);
|
|
906
|
-
return { output
|
|
931
|
+
return rawCommandResult(`/mcp call ${serverName} ${toolName}`, output);
|
|
907
932
|
} catch (err) {
|
|
908
933
|
const message = err instanceof Error ? err.message : String(err);
|
|
909
934
|
step(formatActivityError(serverName, toolName, err));
|
|
@@ -1056,7 +1081,7 @@ export async function handleSlashCommand(line, context) {
|
|
|
1056
1081
|
});
|
|
1057
1082
|
const activity = formatActivitySummary('wiki', 'index', output);
|
|
1058
1083
|
if (activity) step(activity);
|
|
1059
|
-
return
|
|
1084
|
+
return rawCommandResult('/wiki', output);
|
|
1060
1085
|
} catch (err) {
|
|
1061
1086
|
const message = err instanceof Error ? err.message : String(err);
|
|
1062
1087
|
step(formatActivityError('wiki', 'index', err));
|
|
@@ -1073,7 +1098,7 @@ export async function handleSlashCommand(line, context) {
|
|
|
1073
1098
|
});
|
|
1074
1099
|
const activity = formatActivitySummary('wiki', wikiArgs[0] ?? 'run', output);
|
|
1075
1100
|
if (activity) step(activity);
|
|
1076
|
-
return { output
|
|
1101
|
+
return rawCommandResult(`/wiki run ${wikiArgs.join(' ')}`, output);
|
|
1077
1102
|
}
|
|
1078
1103
|
return {
|
|
1079
1104
|
output: [
|
package/src/core/mcp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
|
|
3
3
|
|
|
4
|
-
const WIKI_MANAGER_VERSION = '0.11.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.11.7';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_PROFILE = `# Workspace Profile
|
|
5
|
+
|
|
6
|
+
## Summary
|
|
7
|
+
|
|
8
|
+
No profile summary yet.
|
|
9
|
+
|
|
10
|
+
## User Preferences
|
|
11
|
+
|
|
12
|
+
## Working Style
|
|
13
|
+
|
|
14
|
+
## Project Context
|
|
15
|
+
|
|
16
|
+
## Maintenance Notes
|
|
17
|
+
|
|
18
|
+
Keep this file concise. Do not store secrets, tokens, passwords, API keys, or temporary information.
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
function profilePathForWorkspace(workspacePath) {
|
|
22
|
+
return join(workspacePath, '.wiki', 'profile.md');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatPreference(preference) {
|
|
26
|
+
const clean = String(preference ?? '').trim();
|
|
27
|
+
if (!clean) return '';
|
|
28
|
+
return clean.charAt(0).toUpperCase() + clean.slice(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeForCompare(value) {
|
|
32
|
+
return String(value ?? '')
|
|
33
|
+
.normalize('NFD')
|
|
34
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
37
|
+
.trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function insertPreference(content, preference) {
|
|
41
|
+
const line = `- ${formatPreference(preference)}`;
|
|
42
|
+
const normalizedLine = normalizeForCompare(line);
|
|
43
|
+
const hasDuplicate = content
|
|
44
|
+
.split('\n')
|
|
45
|
+
.some((existing) => normalizeForCompare(existing) === normalizedLine);
|
|
46
|
+
if (hasDuplicate) {
|
|
47
|
+
return { content, changed: false, line };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const heading = '## User Preferences';
|
|
51
|
+
const index = content.indexOf(heading);
|
|
52
|
+
if (index === -1) {
|
|
53
|
+
const next = content.trimEnd();
|
|
54
|
+
return {
|
|
55
|
+
content: `${next}${next ? '\n\n' : ''}${heading}\n\n${line}\n`,
|
|
56
|
+
changed: true,
|
|
57
|
+
line,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const afterHeading = index + heading.length;
|
|
62
|
+
const nextHeading = content.slice(afterHeading).search(/\n##\s+/);
|
|
63
|
+
if (nextHeading === -1) {
|
|
64
|
+
const prefix = content.slice(0, afterHeading).trimEnd();
|
|
65
|
+
const suffix = content.slice(afterHeading).trim();
|
|
66
|
+
return {
|
|
67
|
+
content: `${prefix}\n\n${suffix ? `${suffix}\n` : ''}${line}\n`,
|
|
68
|
+
changed: true,
|
|
69
|
+
line,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const insertAt = afterHeading + nextHeading;
|
|
74
|
+
const before = content.slice(0, insertAt).trimEnd();
|
|
75
|
+
const after = content.slice(insertAt).replace(/^\n+/, '\n');
|
|
76
|
+
return {
|
|
77
|
+
content: `${before}\n${line}\n${after}`,
|
|
78
|
+
changed: true,
|
|
79
|
+
line,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function updateWorkspaceProfilePreference(session, preference) {
|
|
84
|
+
const workspacePath = session?.workspacePath;
|
|
85
|
+
if (!workspacePath) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
message: 'Profil non modifié : aucun workspace chargé. Utilise /use <workspace>.',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const profilePath = profilePathForWorkspace(workspacePath);
|
|
92
|
+
await mkdir(join(workspacePath, '.wiki'), { recursive: true });
|
|
93
|
+
let before = DEFAULT_PROFILE;
|
|
94
|
+
try {
|
|
95
|
+
before = await readFile(profilePath, 'utf8');
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err?.code !== 'ENOENT') throw err;
|
|
98
|
+
}
|
|
99
|
+
const inserted = insertPreference(before, preference);
|
|
100
|
+
if (inserted.changed) {
|
|
101
|
+
await writeFile(profilePath, inserted.content, 'utf8');
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
changed: inserted.changed,
|
|
106
|
+
preference: inserted.line.replace(/^- /, ''),
|
|
107
|
+
message: inserted.changed
|
|
108
|
+
? `Profil mis à jour : ${inserted.line.replace(/^- /, '')}`
|
|
109
|
+
: `Profil déjà à jour : ${inserted.line.replace(/^- /, '')}`,
|
|
110
|
+
};
|
|
111
|
+
}
|
package/src/core/skills.js
CHANGED
|
@@ -14,6 +14,15 @@ type StartupItem = {
|
|
|
14
14
|
detail: string;
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
+
const DONNA_LOGO = `
|
|
18
|
+
██████╗ ██████╗ ███╗ ██╗███╗ ██╗ █████╗
|
|
19
|
+
██╔══██╗██╔═══██╗████╗ ██║████╗ ██║██╔══██╗
|
|
20
|
+
██║ ██║██║ ██║██╔██╗ ██║██╔██╗ ██║███████║
|
|
21
|
+
██║ ██║██║ ██║██║╚██╗██║██║╚██╗██║██╔══██║
|
|
22
|
+
██████╔╝╚██████╔╝██║ ╚████║██║ ╚████║██║ ██║
|
|
23
|
+
╚═════╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═══╝╚═╝ ╚═╝
|
|
24
|
+
`.trim();
|
|
25
|
+
|
|
17
26
|
function statusDot(ready: boolean) {
|
|
18
27
|
return ready ? '●' : '○';
|
|
19
28
|
}
|
|
@@ -92,8 +101,8 @@ export function StartupScreen(props: {
|
|
|
92
101
|
const [mode, setMode] = createSignal<'home' | 'workspace-select'>('home');
|
|
93
102
|
const [selected, setSelected] = createSignal(0);
|
|
94
103
|
let lastKeyboardEventId = 0;
|
|
95
|
-
const panelWidth = createMemo(() => Math.max(
|
|
96
|
-
const panelHeight = createMemo(() => Math.max(
|
|
104
|
+
const panelWidth = createMemo(() => Math.max(70, Math.min(96, props.width - 4)));
|
|
105
|
+
const panelHeight = createMemo(() => Math.max(27, Math.min(35, props.height - 2)));
|
|
97
106
|
const left = createMemo(() => Math.max(1, Math.floor((props.width - panelWidth()) / 2)));
|
|
98
107
|
const top = createMemo(() => Math.max(1, Math.floor((props.height - panelHeight()) / 2)));
|
|
99
108
|
|
|
@@ -251,10 +260,16 @@ export function StartupScreen(props: {
|
|
|
251
260
|
</box>
|
|
252
261
|
<text height={1} fg="#7F8C8D" content={fit(subtitle(), innerWidth())} />
|
|
253
262
|
<text height={1}>{''}</text>
|
|
254
|
-
<box
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
263
|
+
<box
|
|
264
|
+
height={8}
|
|
265
|
+
flexDirection="column"
|
|
266
|
+
justifyContent="center"
|
|
267
|
+
alignItems="center"
|
|
268
|
+
gap={1}
|
|
269
|
+
overflow="hidden"
|
|
270
|
+
>
|
|
271
|
+
<text fg="#d6a85f">{DONNA_LOGO}</text>
|
|
272
|
+
<text fg="#888888">Intelligent workspace</text>
|
|
258
273
|
</box>
|
|
259
274
|
<text height={1}>{''}</text>
|
|
260
275
|
<text height={1} fg="#7F8C8D" content={menuTitle()} />
|
package/src/shell/repl.js
CHANGED
|
@@ -961,22 +961,22 @@ function renderScreen({ packageJson, session, messages, inputBuffer, busy = fals
|
|
|
961
961
|
output.write(buf);
|
|
962
962
|
}
|
|
963
963
|
|
|
964
|
-
async function runAgentTurn(input, { agent, session, onUpdate, onStep }) {
|
|
964
|
+
async function runAgentTurn(input, { agent, session, onUpdate, onStep, displayInput = input }) {
|
|
965
965
|
const messages = conversationMessages(session);
|
|
966
966
|
const history = toConversationHistory(messages);
|
|
967
967
|
session._onStep = onStep ?? null;
|
|
968
968
|
session.packageJson = session.packageJson ?? {};
|
|
969
969
|
dispatchAgentEvent(session, createAgentEvent('run_started', {
|
|
970
970
|
origin: 'user',
|
|
971
|
-
payload: { input },
|
|
971
|
+
payload: { input: displayInput },
|
|
972
972
|
}));
|
|
973
973
|
dispatchAgentEvent(session, createAgentEvent('user_message', {
|
|
974
974
|
origin: 'user',
|
|
975
|
-
payload: { content:
|
|
975
|
+
payload: { content: displayInput },
|
|
976
976
|
}));
|
|
977
977
|
|
|
978
978
|
// Show the user's input in the conversation (mirrors runDirectChatTurn).
|
|
979
|
-
messages.push({ role: 'user', content:
|
|
979
|
+
messages.push({ role: 'user', content: displayInput });
|
|
980
980
|
onUpdate?.();
|
|
981
981
|
|
|
982
982
|
// Create the donna bubble immediately so "Thinking…" is visible during TTFT.
|
|
@@ -1182,6 +1182,7 @@ export async function runLine(line, { agent, packageJson, session, onUpdate, onS
|
|
|
1182
1182
|
onStep?.(`Shell: ${trimmed}`);
|
|
1183
1183
|
const result = await handleSlashCommand(trimmed, { packageJson, session, onStep });
|
|
1184
1184
|
const messages = conversationMessages(session);
|
|
1185
|
+
const handoffRawToAgent = Boolean(result.rawOutput && result.agentTrigger && agent);
|
|
1185
1186
|
if (result.output) {
|
|
1186
1187
|
const parts = trimmed.split(/\s+/);
|
|
1187
1188
|
if (parts[0] === '/mcp' && parts[1] === 'call' && parts[2]) {
|
|
@@ -1196,10 +1197,15 @@ export async function runLine(line, { agent, packageJson, session, onUpdate, onS
|
|
|
1196
1197
|
rememberProductionActivity(session, payload);
|
|
1197
1198
|
}
|
|
1198
1199
|
}
|
|
1200
|
+
}
|
|
1201
|
+
if (result.output && !handoffRawToAgent) {
|
|
1199
1202
|
messages.push({ role: 'command', content: result.output });
|
|
1200
1203
|
onUpdate?.();
|
|
1201
1204
|
}
|
|
1202
|
-
if (
|
|
1205
|
+
if (handoffRawToAgent) {
|
|
1206
|
+
const agentResult = await runAgentTurn(result.agentTrigger, { agent, session, onUpdate, onStep, displayInput: trimmed });
|
|
1207
|
+
if (agentResult.aborted) return { exit: false, aborted: true };
|
|
1208
|
+
} else if (result.agentTrigger && agent) {
|
|
1203
1209
|
const agentResult = await runAgentTurn(result.agentTrigger, { agent, session, onUpdate, onStep });
|
|
1204
1210
|
if (agentResult.aborted) return { exit: false, aborted: true };
|
|
1205
1211
|
}
|
|
@@ -1572,33 +1578,35 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
|
|
|
1572
1578
|
conversationMessages(session).push({ role: 'command', content: `Approval ${result.approved ? 'accepted' : 'not found'}: ${id}` });
|
|
1573
1579
|
syncRuntimeState();
|
|
1574
1580
|
}
|
|
1575
|
-
} else
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1581
|
+
} else {
|
|
1582
|
+
if (runtime?.url && !session.chatMode && !line.trim().startsWith('/')) {
|
|
1583
|
+
conversationMessages(session).push({ role: 'user', content: line });
|
|
1584
|
+
const outcome = await submitRuntimeRun(line, { runtime, session });
|
|
1585
|
+
if (outcome.kind === 'accepted') {
|
|
1586
|
+
activityLines = [...activityLines, 'runtime: run accepted'].slice(-LOWER_DETAIL_ROWS);
|
|
1587
|
+
} else if (outcome.kind === 'queued') {
|
|
1588
|
+
conversationMessages(session).push({ role: 'command', content: 'Runtime is busy — request added to the control queue, it will start automatically.' });
|
|
1589
|
+
activityLines = [...activityLines, 'runtime: control queued'].slice(-LOWER_DETAIL_ROWS);
|
|
1590
|
+
} else if (outcome.kind === 'observe' || outcome.kind === 'converse' || outcome.kind === 'mutate') {
|
|
1591
|
+
const explanation = outcome.result?.explanation ?? 'Runtime control message accepted.';
|
|
1592
|
+
conversationMessages(session).push({ role: 'command', content: explanation });
|
|
1593
|
+
activityLines = [...activityLines, `runtime: ${outcome.kind}`].slice(-LOWER_DETAIL_ROWS);
|
|
1594
|
+
} else if (outcome.kind === 'ambiguous') {
|
|
1595
|
+
conversationMessages(session).push({ role: 'command', content: 'Runtime could not classify that message. Use /queue for a future run, or ask/status more explicitly.' });
|
|
1596
|
+
activityLines = [...activityLines, 'runtime: ambiguous control'].slice(-LOWER_DETAIL_ROWS);
|
|
1597
|
+
} else {
|
|
1598
|
+
conversationMessages(session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
|
|
1599
|
+
activityLines = [...activityLines, `runtime error: ${outcome.message}`].slice(-LOWER_DETAIL_ROWS);
|
|
1600
|
+
}
|
|
1601
|
+
syncRuntimeState();
|
|
1602
|
+
} else if (!session.chatMode && !line.trim().startsWith('/')) {
|
|
1603
|
+
const message = recordRuntimeUnavailableAgentInput(session, line, runtime);
|
|
1604
|
+
activityLines = [...activityLines, message ?? 'runtime: disconnected'].slice(-LOWER_DETAIL_ROWS);
|
|
1590
1605
|
} else {
|
|
1591
|
-
|
|
1592
|
-
|
|
1606
|
+
const result = await runLine(line, { agent, packageJson, session, onUpdate: rerender, onStep });
|
|
1607
|
+
done = result.exit;
|
|
1608
|
+
aborted = result.aborted ?? false;
|
|
1593
1609
|
}
|
|
1594
|
-
syncRuntimeState();
|
|
1595
|
-
} else if (!session.chatMode && !line.trim().startsWith('/')) {
|
|
1596
|
-
const message = recordRuntimeUnavailableAgentInput(session, line, runtime);
|
|
1597
|
-
activityLines = [...activityLines, message ?? 'runtime: disconnected'].slice(-LOWER_DETAIL_ROWS);
|
|
1598
|
-
} else {
|
|
1599
|
-
const result = await runLine(line, { agent, packageJson, session, onUpdate: rerender, onStep });
|
|
1600
|
-
done = result.exit;
|
|
1601
|
-
aborted = result.aborted ?? false;
|
|
1602
1610
|
}
|
|
1603
1611
|
} catch (err) {
|
|
1604
1612
|
if (err.name !== 'AbortError') throw err;
|
package/src/shell/repl.test.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
3
6
|
import {
|
|
4
7
|
applyRuntimeStateToShellSession,
|
|
5
8
|
createSession,
|
|
6
9
|
conversationMessages,
|
|
7
10
|
recordRuntimeUnavailableAgentInput,
|
|
11
|
+
runLine,
|
|
8
12
|
runtimeStatusLine,
|
|
9
13
|
runtimeUnavailableAgentMessage,
|
|
10
14
|
submitRuntimeRun,
|
|
@@ -121,6 +125,28 @@ test('submitRuntimeRun reports a non-409 error without throwing or calling /cont
|
|
|
121
125
|
}
|
|
122
126
|
});
|
|
123
127
|
|
|
128
|
+
test('runLine does not update workspace profile before Donna handles the request', async () => {
|
|
129
|
+
const workspacePath = mkdtempSync(join(tmpdir(), 'donna-profile-shell-'));
|
|
130
|
+
mkdirSync(join(workspacePath, '.wiki'), { recursive: true });
|
|
131
|
+
try {
|
|
132
|
+
const session = createSession();
|
|
133
|
+
session.workspace = 'docs';
|
|
134
|
+
session.workspacePath = workspacePath;
|
|
135
|
+
const result = await runLine('ajoute a mon profil que les statuts Docker sont rendus en tableau', {
|
|
136
|
+
agent: null,
|
|
137
|
+
packageJson: { version: 'test' },
|
|
138
|
+
session,
|
|
139
|
+
chatMode: true,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
assert.equal(result.exit, false);
|
|
143
|
+
assert.equal(existsSync(join(workspacePath, '.wiki', 'profile.md')), false);
|
|
144
|
+
assert.notDeepEqual(conversationMessages(session).map((message) => message.role), ['user', 'donna']);
|
|
145
|
+
} finally {
|
|
146
|
+
rmSync(workspacePath, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
124
150
|
test('agent mode without runtime records a visible error instead of falling back locally', () => {
|
|
125
151
|
const session = createSession();
|
|
126
152
|
session.chatMode = false;
|