@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.11.6",
3
+ "version": "0.11.7",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "author": "dotrelle",
@@ -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
- ].join('\n');
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 = toolsForDonnaTurn(state.input, allTools, iterations);
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, {
@@ -1,6 +1,9 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { createAgentGraph } from './graph.js';
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 only the minimal read-only tool set for plain discussion, never an empty array', async () => {
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.deepEqual(seenTools, ['shell__read_command']);
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 binds read-only tools for config questions without plan tools', async () => {
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.equal(seenTools.includes('wiki__plan_set'), false);
179
- assert.equal(seenTools.includes('production__production_start_job'), false);
180
- assert.equal(seenTools.includes('shell__run_command'), false);
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 () => {
@@ -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 { output: output || `Agents ${verb}ed.` };
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
- return { output: await listServices(context.session) };
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 { output };
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 { output };
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
- return { output: await serviceLogs(context.session, service, { tail }) };
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 { output };
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.6';
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
+ }
@@ -46,7 +46,7 @@ function readSkillFile(filePath, fallbackName, scope) {
46
46
  };
47
47
  }
48
48
 
49
- function readOptionalText(filePath) {
49
+ export function readOptionalText(filePath) {
50
50
  if (!existsSync(filePath)) return '';
51
51
  return readFileSync(filePath, 'utf8').trim();
52
52
  }
@@ -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(54, Math.min(86, props.width - 4)));
96
- const panelHeight = createMemo(() => Math.max(24, Math.min(32, props.height - 2)));
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 height={4} flexDirection="column" border={['left']} borderStyle="heavy" borderColor="#8BD5CA" paddingX={1}>
255
- <text height={1} fg="#EAF2F8" content={fit('DONNA', innerWidth() - 2)} />
256
- <text height={1} fg="#8BD5CA" content={fit('Living knowledge for AI agents', innerWidth() - 2)} />
257
- <text height={1} fg="#7F8C8D" content={fit('Local workspaces · MCP agents · traceable runs', innerWidth() - 2)} />
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: input },
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: input });
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 (result.agentTrigger && agent) {
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 if (runtime?.url && !session.chatMode && !line.trim().startsWith('/')) {
1576
- conversationMessages(session).push({ role: 'user', content: line });
1577
- const outcome = await submitRuntimeRun(line, { runtime, session });
1578
- if (outcome.kind === 'accepted') {
1579
- activityLines = [...activityLines, 'runtime: run accepted'].slice(-LOWER_DETAIL_ROWS);
1580
- } else if (outcome.kind === 'queued') {
1581
- conversationMessages(session).push({ role: 'command', content: 'Runtime is busy — request added to the control queue, it will start automatically.' });
1582
- activityLines = [...activityLines, 'runtime: control queued'].slice(-LOWER_DETAIL_ROWS);
1583
- } else if (outcome.kind === 'observe' || outcome.kind === 'converse' || outcome.kind === 'mutate') {
1584
- const explanation = outcome.result?.explanation ?? 'Runtime control message accepted.';
1585
- conversationMessages(session).push({ role: 'command', content: explanation });
1586
- activityLines = [...activityLines, `runtime: ${outcome.kind}`].slice(-LOWER_DETAIL_ROWS);
1587
- } else if (outcome.kind === 'ambiguous') {
1588
- conversationMessages(session).push({ role: 'command', content: 'Runtime could not classify that message. Use /queue for a future run, or ask/status more explicitly.' });
1589
- activityLines = [...activityLines, 'runtime: ambiguous control'].slice(-LOWER_DETAIL_ROWS);
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
- conversationMessages(session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
1592
- activityLines = [...activityLines, `runtime error: ${outcome.message}`].slice(-LOWER_DETAIL_ROWS);
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;
@@ -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;