fix: skills/MCP/completion system overhaul — separate skill kinds, fix MCP remote servers, deduplicate completion processing
Skills system:
- Replace `CommandBadge.isSkill: boolean` with `kind: SkillKind` (agent_skill | slash_command | sdk_command | codepilot_command)
- handleSubmit dispatches by kind: agent_skill retains skill name in prompt, slash/sdk commands pass through to SDK, codepilot commands expand via COMMAND_PROMPTS
- Popover groups commands into Commands / Slash Commands / Agent Skills
- API `/api/skills` returns `kind` field; `displayOverride` persisted through full chain (frontend → API → DB)
- Forward SDK `system:init` slash_commands/skills metadata to frontend via SSE
MCP management:
- Fix remote server (SSE/HTTP) creation: validate `url` instead of `command`
- Fix dual-config read/write asymmetry: PUT routes servers by `_source` to correct file (~/.claude.json vs ~/.claude/settings.json)
- DELETE checks both config files; POST checks both for name collision
- McpManager preserves `_source` tags on edit/rename; JSON editor only manages settings.json servers
Completion processing:
- Extract core logic into `onboarding-processor.ts` and `checkin-processor.ts` (shared by route handlers and server-side detection)
- Remove frontend `detectAssistantCompletion` from ChatView — server-side path in `finally` block is now the single source of truth
- Replace hard-coded `fetch("http://localhost:PORT/...")` with direct function imports
- Both processors are internally idempotent (check state before processing)
Other:
- `settingSources` always includes 'user' so SDK loads ~/.claude/skills/
- Add 24 unit tests for SkillKind dispatch logic
- Add onboarding-completion extraction tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
789b453e92
commit
281e7f9502
24 files changed
+1972
-465
No files matched your search
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Unit tests for onboarding completion extraction and robust JSON parsing.
|
||||
*
|
||||
* Run with: npx tsx --test src/__tests__/unit/onboarding-completion.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
extractCompletionFence,
|
||||
parseCompletionPayload,
|
||||
extractCompletion,
|
||||
} from '../../lib/onboarding-completion';
|
||||
|
||||
// ─── Fence Extraction ────────────────────────────────────────────
|
||||
|
||||
describe('extractCompletionFence', () => {
|
||||
it('should extract onboarding-complete fence with LF', () => {
|
||||
const content = 'Some text\n```onboarding-complete\n{"q1":"hello"}\n```\nMore text';
|
||||
const result = extractCompletionFence(content);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.type, 'onboarding');
|
||||
assert.equal(result!.rawPayload, '{"q1":"hello"}');
|
||||
});
|
||||
|
||||
it('should extract fence with CRLF', () => {
|
||||
const content = '```onboarding-complete\r\n{"q1":"hello"}\r\n```';
|
||||
const result = extractCompletionFence(content);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.rawPayload, '{"q1":"hello"}');
|
||||
});
|
||||
|
||||
it('should extract fence with trailing spaces after tag', () => {
|
||||
const content = '```onboarding-complete \n{"q1":"hello"}\n```';
|
||||
const result = extractCompletionFence(content);
|
||||
assert.ok(result);
|
||||
});
|
||||
|
||||
it('should extract fence with leading whitespace before closing fence', () => {
|
||||
const content = '```onboarding-complete\n{"q1":"hello"}\n ```';
|
||||
const result = extractCompletionFence(content);
|
||||
assert.ok(result);
|
||||
});
|
||||
|
||||
it('should extract fence with triple+ backticks', () => {
|
||||
const content = '````onboarding-complete\n{"q1":"hello"}\n````';
|
||||
const result = extractCompletionFence(content);
|
||||
assert.ok(result);
|
||||
});
|
||||
|
||||
it('should extract checkin-complete fence', () => {
|
||||
const content = '```checkin-complete\n{"q1":"did coding"}\n```';
|
||||
const result = extractCompletionFence(content);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.type, 'checkin');
|
||||
});
|
||||
|
||||
it('should return null when no fence present', () => {
|
||||
const content = 'Just a normal message with no fence';
|
||||
assert.equal(extractCompletionFence(content), null);
|
||||
});
|
||||
|
||||
it('should handle fence embedded in larger markdown content', () => {
|
||||
const content = `Great, let me summarize your answers:
|
||||
|
||||
Here is your profile:
|
||||
|
||||
\`\`\`onboarding-complete
|
||||
{"q1":"Alice","q2":"Claude","q3":"concise","q4":"minimal","q5":"no spam, no ads, no tracking","q6":"learn rust, ship product, read more","q7":"lists","q8":"preferences and goals","q9":"passwords","q10":"read README, check issues, review structure","q11":"project","q12":"Inbox","q13":"move to archive folder"}
|
||||
\`\`\`
|
||||
|
||||
Your workspace is being initialized!`;
|
||||
const result = extractCompletionFence(content);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.type, 'onboarding');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Robust JSON Parsing ─────────────────────────────────────────
|
||||
|
||||
describe('parseCompletionPayload', () => {
|
||||
it('should parse valid JSON directly', () => {
|
||||
const payload = '{"q1":"hello","q2":"world"}';
|
||||
const result = parseCompletionPayload(payload);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.q1, 'hello');
|
||||
assert.equal(result!.q2, 'world');
|
||||
});
|
||||
|
||||
it('should handle JSON with whitespace padding', () => {
|
||||
const payload = ' {"q1":"hello","q2":"world"} ';
|
||||
const result = parseCompletionPayload(payload);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.q1, 'hello');
|
||||
});
|
||||
|
||||
it('should handle values containing double quotes (escaped)', () => {
|
||||
const payload = '{"q1":"he said \\"hello\\"","q2":"world"}';
|
||||
const result = parseCompletionPayload(payload);
|
||||
assert.ok(result);
|
||||
assert.ok(result!.q1.includes('hello'));
|
||||
});
|
||||
|
||||
it('should handle values containing unescaped newlines', () => {
|
||||
// AI might produce a value with literal newlines
|
||||
const payload = '{"q1":"line1\nline2","q2":"world"}';
|
||||
const result = parseCompletionPayload(payload);
|
||||
assert.ok(result);
|
||||
assert.ok(result!.q1.includes('line1'));
|
||||
});
|
||||
|
||||
it('should handle trailing comma', () => {
|
||||
const payload = '{"q1":"hello","q2":"world",}';
|
||||
const result = parseCompletionPayload(payload);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.q1, 'hello');
|
||||
});
|
||||
|
||||
it('should handle markdown bold in values', () => {
|
||||
const payload = '{"q1":"**Alice**","q2":"world"}';
|
||||
const result = parseCompletionPayload(payload);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.q1, 'Alice');
|
||||
});
|
||||
|
||||
it('should handle single quotes', () => {
|
||||
const payload = "{'q1':'hello','q2':'world'}";
|
||||
const result = parseCompletionPayload(payload);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.q1, 'hello');
|
||||
});
|
||||
|
||||
it('should handle Chinese text in values', () => {
|
||||
const payload = '{"q1":"小明","q2":"助手","q3":"简洁直接"}';
|
||||
const result = parseCompletionPayload(payload);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.q1, '小明');
|
||||
assert.equal(result!.q3, '简洁直接');
|
||||
});
|
||||
|
||||
it('should extract via regex as last resort for heavily malformed JSON', () => {
|
||||
// Simulate AI output with mixed formatting issues
|
||||
const payload = `{
|
||||
"q1": "Alice",
|
||||
"q2": "Claude",
|
||||
"q3": "concise and direct"
|
||||
"q4": "minimal interruptions",
|
||||
}`;
|
||||
const result = parseCompletionPayload(payload);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.q1, 'Alice');
|
||||
assert.equal(result!.q2, 'Claude');
|
||||
});
|
||||
|
||||
it('should return null for completely unparseable content', () => {
|
||||
const payload = 'this is not json at all';
|
||||
assert.equal(parseCompletionPayload(payload), null);
|
||||
});
|
||||
|
||||
it('should handle full 13-question payload with free-text answers', () => {
|
||||
const payload = '{"q1":"小明","q2":"助手","q3":"简洁直接","q4":"主动建议","q5":"不要删除文件、不要发送邮件、不要修改系统设置","q6":"学Rust、发布产品、多读书","q7":"列表","q8":"偏好和目标","q9":"密码和私钥","q10":"读README、看issues、了解结构","q11":"按项目","q12":"Inbox","q13":"移到archive文件夹"}';
|
||||
const result = parseCompletionPayload(payload);
|
||||
assert.ok(result);
|
||||
assert.equal(Object.keys(result!).length, 13);
|
||||
assert.equal(result!.q1, '小明');
|
||||
assert.equal(result!.q13, '移到archive文件夹');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── End-to-End extractCompletion ────────────────────────────────
|
||||
|
||||
describe('extractCompletion (end-to-end)', () => {
|
||||
it('should extract and parse a valid onboarding completion', () => {
|
||||
const content = `Here is your summary:
|
||||
|
||||
\`\`\`onboarding-complete
|
||||
{"q1":"Alice","q2":"Claude","q3":"concise"}
|
||||
\`\`\`
|
||||
|
||||
Done!`;
|
||||
const result = extractCompletion(content);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.type, 'onboarding');
|
||||
assert.equal(result!.answers.q1, 'Alice');
|
||||
});
|
||||
|
||||
it('should extract and parse a valid checkin completion', () => {
|
||||
const content = '```checkin-complete\n{"q1":"coded all day","q2":"no changes","q3":"remember to review PR"}\n```';
|
||||
const result = extractCompletion(content);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.type, 'checkin');
|
||||
assert.equal(result!.answers.q1, 'coded all day');
|
||||
});
|
||||
|
||||
it('should return null when fence exists but JSON is completely broken', () => {
|
||||
const content = '```onboarding-complete\nthis is not json\n```';
|
||||
assert.equal(extractCompletion(content), null);
|
||||
});
|
||||
|
||||
it('should handle CRLF + trailing comma + markdown bold combined', () => {
|
||||
const content = '```onboarding-complete\r\n{"q1":"**Alice**","q2":"Claude",}\r\n```';
|
||||
const result = extractCompletion(content);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.answers.q1, 'Alice');
|
||||
});
|
||||
|
||||
it('should handle fence with answers containing commas and colons', () => {
|
||||
const content = '```onboarding-complete\n{"q1":"Alice, Bob","q5":"no spam, no ads, no tracking: ever"}\n```';
|
||||
const result = extractCompletion(content);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.answers.q1, 'Alice, Bob');
|
||||
assert.ok(result!.answers.q5.includes('no tracking'));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Integration with workspace state ────────────────────────────
|
||||
|
||||
describe('onboarding completion + workspace state integration', () => {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Set a temp data dir before importing db module
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onboarding-int-test-'));
|
||||
process.env.CLAUDE_GUI_DATA_DIR = tmpDir;
|
||||
|
||||
const {
|
||||
initializeWorkspace,
|
||||
loadState,
|
||||
saveState,
|
||||
needsDailyCheckIn,
|
||||
} = require('../../lib/assistant-workspace') as typeof import('../../lib/assistant-workspace');
|
||||
const { getLocalDateString } = require('../../lib/utils') as typeof import('../../lib/utils');
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
it('onboarding success should set onboardingComplete=true and lastCheckInDate=today', () => {
|
||||
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onb-state-'));
|
||||
initializeWorkspace(workDir);
|
||||
|
||||
// Simulate what onboarding route does
|
||||
const today = getLocalDateString();
|
||||
const state = loadState(workDir);
|
||||
state.onboardingComplete = true;
|
||||
state.lastCheckInDate = today;
|
||||
state.schemaVersion = 3;
|
||||
saveState(workDir, state);
|
||||
|
||||
const reloaded = loadState(workDir);
|
||||
assert.equal(reloaded.onboardingComplete, true);
|
||||
assert.equal(reloaded.lastCheckInDate, today);
|
||||
assert.equal(needsDailyCheckIn(reloaded), false, 'Should not need check-in on onboarding day');
|
||||
|
||||
fs.rmSync(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('after onboarding, next day should need check-in', () => {
|
||||
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onb-checkin-'));
|
||||
initializeWorkspace(workDir);
|
||||
|
||||
const state = loadState(workDir);
|
||||
state.onboardingComplete = true;
|
||||
state.lastCheckInDate = '2020-01-01'; // yesterday or earlier
|
||||
state.schemaVersion = 3;
|
||||
saveState(workDir, state);
|
||||
|
||||
const reloaded = loadState(workDir);
|
||||
assert.equal(needsDailyCheckIn(reloaded), true);
|
||||
|
||||
fs.rmSync(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('re-entering session after onboarding should not re-trigger', () => {
|
||||
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onb-reenter-'));
|
||||
initializeWorkspace(workDir);
|
||||
|
||||
const today = getLocalDateString();
|
||||
const state = loadState(workDir);
|
||||
state.onboardingComplete = true;
|
||||
state.lastCheckInDate = today;
|
||||
saveState(workDir, state);
|
||||
|
||||
// Simulate re-entering: check trigger conditions
|
||||
const reloaded = loadState(workDir);
|
||||
const needsOnboarding = !reloaded.onboardingComplete;
|
||||
const needsCheckIn = reloaded.onboardingComplete && reloaded.lastCheckInDate !== today;
|
||||
assert.equal(needsOnboarding, false);
|
||||
assert.equal(needsCheckIn, false);
|
||||
|
||||
fs.rmSync(workDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Unit tests for the SkillKind system — type values, COMMAND_PROMPTS mapping,
|
||||
* and badge dispatch logic.
|
||||
*
|
||||
* Run with: npx tsx --test src/__tests__/unit/skill-kind.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { SkillKind } from '../../types';
|
||||
|
||||
// ─── SkillKind Type Values ──────────────────────────────────────
|
||||
|
||||
describe('SkillKind type values', () => {
|
||||
// SkillKind is a string literal union — we can't iterate it at runtime,
|
||||
// but we can verify the known values satisfy the type constraint.
|
||||
const validKinds: SkillKind[] = [
|
||||
'agent_skill',
|
||||
'slash_command',
|
||||
'sdk_command',
|
||||
'codepilot_command',
|
||||
];
|
||||
|
||||
it('should include exactly 4 expected kinds', () => {
|
||||
assert.equal(validKinds.length, 4);
|
||||
});
|
||||
|
||||
it('each kind should be a non-empty string', () => {
|
||||
for (const kind of validKinds) {
|
||||
assert.equal(typeof kind, 'string');
|
||||
assert.ok(kind.length > 0, `kind "${kind}" should be non-empty`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have no duplicate values', () => {
|
||||
const unique = new Set(validKinds);
|
||||
assert.equal(unique.size, validKinds.length, 'SkillKind values must be unique');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── COMMAND_PROMPTS Mapping ────────────────────────────────────
|
||||
|
||||
// Mirror of the COMMAND_PROMPTS from MessageInput.tsx (not exported, so we replicate)
|
||||
const COMMAND_PROMPTS: Record<string, string> = {
|
||||
'/doctor': 'Run diagnostic checks on this project. Check system health, dependencies, configuration files, and report any issues.',
|
||||
'/terminal-setup': 'Help me configure my terminal for optimal use with Claude Code. Check current setup and suggest improvements.',
|
||||
'/memory': 'Show the current CLAUDE.md project memory file and help me review or edit it.',
|
||||
};
|
||||
|
||||
// Mirror of built-in commands with kind info (subset relevant to this test)
|
||||
interface BuiltInCommandStub {
|
||||
label: string;
|
||||
value: string;
|
||||
kind?: SkillKind;
|
||||
immediate?: boolean;
|
||||
}
|
||||
|
||||
const BUILT_IN_COMMANDS: BuiltInCommandStub[] = [
|
||||
{ label: 'help', value: '/help', immediate: true },
|
||||
{ label: 'clear', value: '/clear', immediate: true },
|
||||
{ label: 'cost', value: '/cost', immediate: true },
|
||||
{ label: 'compact', value: '/compact', kind: 'sdk_command' },
|
||||
{ label: 'doctor', value: '/doctor', kind: 'codepilot_command' },
|
||||
{ label: 'init', value: '/init', kind: 'sdk_command' },
|
||||
{ label: 'review', value: '/review', kind: 'sdk_command' },
|
||||
{ label: 'terminal-setup', value: '/terminal-setup', kind: 'codepilot_command' },
|
||||
{ label: 'memory', value: '/memory', kind: 'codepilot_command' },
|
||||
];
|
||||
|
||||
describe('COMMAND_PROMPTS mapping', () => {
|
||||
it('should only have entries for codepilot_command kind', () => {
|
||||
const codepilotCommands = BUILT_IN_COMMANDS
|
||||
.filter(c => c.kind === 'codepilot_command')
|
||||
.map(c => c.value);
|
||||
|
||||
for (const key of Object.keys(COMMAND_PROMPTS)) {
|
||||
assert.ok(
|
||||
codepilotCommands.includes(key),
|
||||
`COMMAND_PROMPTS key "${key}" should correspond to a codepilot_command`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('every codepilot_command should have an expansion prompt', () => {
|
||||
const codepilotCommands = BUILT_IN_COMMANDS.filter(c => c.kind === 'codepilot_command');
|
||||
for (const cmd of codepilotCommands) {
|
||||
assert.ok(
|
||||
COMMAND_PROMPTS[cmd.value],
|
||||
`codepilot_command "${cmd.value}" should have an expansion prompt in COMMAND_PROMPTS`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('sdk_command entries should NOT be in COMMAND_PROMPTS', () => {
|
||||
const sdkCommands = BUILT_IN_COMMANDS.filter(c => c.kind === 'sdk_command');
|
||||
for (const cmd of sdkCommands) {
|
||||
assert.equal(
|
||||
COMMAND_PROMPTS[cmd.value],
|
||||
undefined,
|
||||
`sdk_command "${cmd.value}" should not have an expansion prompt`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('immediate commands should not have a kind', () => {
|
||||
const immediateCommands = BUILT_IN_COMMANDS.filter(c => c.immediate);
|
||||
for (const cmd of immediateCommands) {
|
||||
assert.equal(cmd.kind, undefined, `immediate command "${cmd.value}" should not have a kind`);
|
||||
}
|
||||
});
|
||||
|
||||
it('all expansion prompts should be non-empty strings', () => {
|
||||
for (const [key, prompt] of Object.entries(COMMAND_PROMPTS)) {
|
||||
assert.equal(typeof prompt, 'string');
|
||||
assert.ok(prompt.length > 10, `Prompt for "${key}" should be a meaningful sentence`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Badge Dispatch Logic ───────────────────────────────────────
|
||||
|
||||
// Pure-function extraction of the switch(badge.kind) dispatch from handleSubmit
|
||||
interface CommandBadge {
|
||||
command: string;
|
||||
label: string;
|
||||
description: string;
|
||||
kind: SkillKind;
|
||||
}
|
||||
|
||||
interface DispatchResult {
|
||||
prompt: string;
|
||||
displayLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mimics the badge dispatch logic from MessageInput.tsx handleSubmit.
|
||||
* Extracted as a pure function for testability.
|
||||
*/
|
||||
function dispatchBadge(badge: CommandBadge, userContent: string): DispatchResult {
|
||||
const displayLabel = `/${badge.label}`;
|
||||
|
||||
switch (badge.kind) {
|
||||
case 'agent_skill': {
|
||||
// Agent skills: always include skill name so Claude knows which skill was selected
|
||||
const agentPrompt = userContent
|
||||
? `Use the ${badge.label} skill. User context: ${userContent}`
|
||||
: `Please use the ${badge.label} skill.`;
|
||||
return { prompt: agentPrompt, displayLabel };
|
||||
}
|
||||
|
||||
case 'slash_command':
|
||||
case 'sdk_command': {
|
||||
const slashPrompt = userContent
|
||||
? `${badge.command} ${userContent}`
|
||||
: badge.command;
|
||||
return { prompt: slashPrompt, displayLabel };
|
||||
}
|
||||
|
||||
case 'codepilot_command': {
|
||||
const expandedPrompt = COMMAND_PROMPTS[badge.command] || '';
|
||||
const finalPrompt = userContent
|
||||
? `${expandedPrompt}\n\nUser context: ${userContent}`
|
||||
: expandedPrompt || badge.command;
|
||||
return { prompt: finalPrompt, displayLabel };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('badge dispatch logic', () => {
|
||||
describe('agent_skill kind', () => {
|
||||
const badge: CommandBadge = {
|
||||
command: '/git-commit',
|
||||
label: 'git-commit',
|
||||
description: 'Smart git commit',
|
||||
kind: 'agent_skill',
|
||||
};
|
||||
|
||||
it('should include skill name AND user context when context provided', () => {
|
||||
const result = dispatchBadge(badge, 'fix the typo in README');
|
||||
assert.ok(result.prompt.includes('git-commit'), 'prompt must reference the skill name');
|
||||
assert.ok(result.prompt.includes('fix the typo in README'), 'prompt must include user context');
|
||||
assert.equal(result.displayLabel, '/git-commit');
|
||||
});
|
||||
|
||||
it('should send trigger hint with skill name when no user content', () => {
|
||||
const result = dispatchBadge(badge, '');
|
||||
assert.equal(result.prompt, 'Please use the git-commit skill.');
|
||||
assert.equal(result.displayLabel, '/git-commit');
|
||||
});
|
||||
|
||||
it('should NOT include SKILL.md content but SHOULD include skill name', () => {
|
||||
const result = dispatchBadge(badge, 'some context');
|
||||
assert.ok(!result.prompt.includes('SKILL.md'), 'agent_skill should not expand SKILL.md');
|
||||
assert.ok(result.prompt.includes('git-commit'), 'agent_skill must include skill name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('slash_command kind', () => {
|
||||
const badge: CommandBadge = {
|
||||
command: '/my-custom-skill',
|
||||
label: 'my-custom-skill',
|
||||
description: 'A custom slash command',
|
||||
kind: 'slash_command',
|
||||
};
|
||||
|
||||
it('should send /{command} {context} format with user content', () => {
|
||||
const result = dispatchBadge(badge, 'extra context');
|
||||
assert.equal(result.prompt, '/my-custom-skill extra context');
|
||||
});
|
||||
|
||||
it('should send just the command when no user content', () => {
|
||||
const result = dispatchBadge(badge, '');
|
||||
assert.equal(result.prompt, '/my-custom-skill');
|
||||
});
|
||||
|
||||
it('should show display label with slash prefix', () => {
|
||||
const result = dispatchBadge(badge, '');
|
||||
assert.equal(result.displayLabel, '/my-custom-skill');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sdk_command kind', () => {
|
||||
const badge: CommandBadge = {
|
||||
command: '/compact',
|
||||
label: 'compact',
|
||||
description: 'Compress conversation context',
|
||||
kind: 'sdk_command',
|
||||
};
|
||||
|
||||
it('should send /{command} {context} format with user content', () => {
|
||||
const result = dispatchBadge(badge, 'keep last 5 messages');
|
||||
assert.equal(result.prompt, '/compact keep last 5 messages');
|
||||
});
|
||||
|
||||
it('should send just the command when no user content', () => {
|
||||
const result = dispatchBadge(badge, '');
|
||||
assert.equal(result.prompt, '/compact');
|
||||
});
|
||||
|
||||
it('should behave identically to slash_command', () => {
|
||||
const slashBadge: CommandBadge = { ...badge, kind: 'slash_command' };
|
||||
const sdkResult = dispatchBadge(badge, 'test input');
|
||||
const slashResult = dispatchBadge(slashBadge, 'test input');
|
||||
assert.equal(sdkResult.prompt, slashResult.prompt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('codepilot_command kind', () => {
|
||||
const badge: CommandBadge = {
|
||||
command: '/doctor',
|
||||
label: 'doctor',
|
||||
description: 'Diagnose project health',
|
||||
kind: 'codepilot_command',
|
||||
};
|
||||
|
||||
it('should expand via COMMAND_PROMPTS when no user content', () => {
|
||||
const result = dispatchBadge(badge, '');
|
||||
assert.equal(result.prompt, COMMAND_PROMPTS['/doctor']);
|
||||
assert.ok(result.prompt.includes('diagnostic checks'));
|
||||
});
|
||||
|
||||
it('should append user context to expanded prompt', () => {
|
||||
const result = dispatchBadge(badge, 'focus on deps');
|
||||
assert.ok(result.prompt.startsWith(COMMAND_PROMPTS['/doctor']));
|
||||
assert.ok(result.prompt.includes('User context: focus on deps'));
|
||||
});
|
||||
|
||||
it('should fall back to command string when no expansion prompt exists', () => {
|
||||
const unknownBadge: CommandBadge = {
|
||||
command: '/unknown-codepilot',
|
||||
label: 'unknown-codepilot',
|
||||
description: 'Unknown',
|
||||
kind: 'codepilot_command',
|
||||
};
|
||||
const result = dispatchBadge(unknownBadge, '');
|
||||
assert.equal(result.prompt, '/unknown-codepilot');
|
||||
});
|
||||
|
||||
it('should NOT send raw command for known codepilot commands', () => {
|
||||
const result = dispatchBadge(badge, '');
|
||||
assert.ok(!result.prompt.startsWith('/doctor'), 'should expand, not send raw /doctor');
|
||||
});
|
||||
|
||||
it('should show display label correctly', () => {
|
||||
const result = dispatchBadge(badge, '');
|
||||
assert.equal(result.displayLabel, '/doctor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-kind consistency', () => {
|
||||
it('all kinds should produce a displayLabel starting with /', () => {
|
||||
const kinds: SkillKind[] = ['agent_skill', 'slash_command', 'sdk_command', 'codepilot_command'];
|
||||
for (const kind of kinds) {
|
||||
const badge: CommandBadge = {
|
||||
command: '/test',
|
||||
label: 'test',
|
||||
description: 'test',
|
||||
kind,
|
||||
};
|
||||
const result = dispatchBadge(badge, '');
|
||||
assert.ok(result.displayLabel.startsWith('/'), `kind "${kind}" should produce /label`);
|
||||
}
|
||||
});
|
||||
|
||||
it('all kinds should produce a non-empty prompt', () => {
|
||||
const kinds: SkillKind[] = ['agent_skill', 'slash_command', 'sdk_command', 'codepilot_command'];
|
||||
for (const kind of kinds) {
|
||||
const badge: CommandBadge = {
|
||||
command: '/test',
|
||||
label: 'test',
|
||||
description: 'test',
|
||||
kind,
|
||||
};
|
||||
const result = dispatchBadge(badge, '');
|
||||
assert.ok(result.prompt.length > 0, `kind "${kind}" should produce a non-empty prompt`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { streamClaude } from '@/lib/claude-client';
|
||||
import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, acquireSessionLock, renewSessionLock, releaseSessionLock, setSessionRuntimeStatus, syncSdkTasks } from '@/lib/db';
|
||||
import { resolveProvider as resolveProviderUnified } from '@/lib/provider-resolver';
|
||||
import { notifySessionStart, notifySessionComplete, notifySessionError } from '@/lib/telegram-bot';
|
||||
import { extractCompletion } from '@/lib/onboarding-completion';
|
||||
import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment, ClaudeStreamOptions } from '@/types';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
@@ -37,8 +38,8 @@ export async function POST(request: NextRequest) {
|
||||
let activeLockId: string | undefined;
|
||||
|
||||
try {
|
||||
const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string; systemPromptAppend?: string; autoTrigger?: boolean; thinking?: unknown; effort?: string; enableFileCheckpointing?: boolean } = await request.json();
|
||||
const { session_id, content, model, mode, files, toolTimeout, provider_id, systemPromptAppend, autoTrigger, thinking, effort, enableFileCheckpointing } = body;
|
||||
const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string; systemPromptAppend?: string; autoTrigger?: boolean; thinking?: unknown; effort?: string; enableFileCheckpointing?: boolean; displayOverride?: string } = await request.json();
|
||||
const { session_id, content, model, mode, files, toolTimeout, provider_id, systemPromptAppend, autoTrigger, thinking, effort, enableFileCheckpointing, displayOverride } = body;
|
||||
|
||||
console.log('[chat API] content length:', content.length, 'first 200 chars:', content.slice(0, 200));
|
||||
console.log('[chat API] systemPromptAppend:', systemPromptAppend ? `${systemPromptAppend.length} chars` : 'none');
|
||||
@@ -81,7 +82,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Save user message — persist file metadata so attachments survive page reload
|
||||
// Skip saving for autoTrigger messages (invisible system triggers for assistant hooks)
|
||||
let savedContent = content;
|
||||
// Use displayOverride for DB storage if provided (e.g. /skillName instead of expanded prompt)
|
||||
let savedContent = displayOverride || content;
|
||||
let fileMeta: Array<{ id: string; name: string; type: string; size: number; filePath: string }> | undefined;
|
||||
if (!autoTrigger) {
|
||||
if (files && files.length > 0) {
|
||||
@@ -97,7 +99,7 @@ export async function POST(request: NextRequest) {
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
return { id: f.id, name: f.name, type: f.type, size: buffer.length, filePath };
|
||||
});
|
||||
savedContent = `<!--files:${JSON.stringify(fileMeta)}-->${content}`;
|
||||
savedContent = `<!--files:${JSON.stringify(fileMeta)}-->${displayOverride || content}`;
|
||||
}
|
||||
addMessage(session_id, 'user', savedContent);
|
||||
|
||||
@@ -240,12 +242,21 @@ Ask the following 13 questions ONE AT A TIME. Wait for the user's answer before
|
||||
12. Where should new information go by default?
|
||||
13. How should completed tasks be archived?
|
||||
|
||||
After all questions are answered, output a summary of the collected answers in exactly this format — this is critical for the system to process your answers:
|
||||
After the user answers the LAST question (Q13), you MUST immediately output the completion block below. Do NOT wait for the user to say anything else. Do NOT ask for confirmation. Just output the block right after your response to Q13.
|
||||
|
||||
CRITICAL FORMATTING RULES for the completion block:
|
||||
- Each value must be a single line (replace any newlines with spaces)
|
||||
- Escape all double quotes inside values with backslash: \\"
|
||||
- Do NOT use single quotes for JSON keys or values
|
||||
- Do NOT add trailing commas
|
||||
- The JSON must be on a SINGLE line
|
||||
|
||||
\`\`\`onboarding-complete
|
||||
{"q1":"answer1","q2":"answer2","q3":"answer3","q4":"answer4","q5":"answer5","q6":"answer6","q7":"answer7","q8":"answer8","q9":"answer9","q10":"answer10","q11":"answer11","q12":"answer12","q13":"answer13"}
|
||||
\`\`\`
|
||||
|
||||
After outputting the completion block, tell the user that the setup is complete and the system is now initializing their workspace. Keep this message brief and friendly.
|
||||
|
||||
Do NOT try to write files yourself. The system will automatically generate soul.md, user.md, claude.md, memory.md, config.json, and taxonomy.json from your collected answers.
|
||||
|
||||
Start by greeting the user and asking the first question.
|
||||
@@ -555,6 +566,27 @@ async function collectStreamResponse(
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// ── Server-side completion detection (reliable path) ──
|
||||
// After persisting the assistant message, check for onboarding/checkin
|
||||
// fences and process them directly on the server. This ensures completion
|
||||
// is captured even if the frontend misses it (page refresh, parse failure, etc.).
|
||||
try {
|
||||
const fullText = contentBlocks
|
||||
.filter((b): b is Extract<MessageContentBlock, { type: 'text' }> => b.type === 'text')
|
||||
.map((b) => b.text)
|
||||
.join('');
|
||||
const completion = extractCompletion(fullText);
|
||||
if (completion) {
|
||||
const workspacePath = getSetting('assistant_workspace_path');
|
||||
const session = getSession(sessionId);
|
||||
if (workspacePath && session && session.working_directory === workspacePath) {
|
||||
await processCompletionServerSide(completion, workspacePath, sessionId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[chat API] Server-side completion detection failed:', e);
|
||||
}
|
||||
|
||||
// Telegram notifications: completion or error (fire-and-forget)
|
||||
if (hasError) {
|
||||
notifySessionError(errorMessage, telegramOpts).catch(() => {});
|
||||
@@ -570,3 +602,47 @@ async function collectStreamResponse(
|
||||
onComplete?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a detected onboarding/checkin completion on the server side.
|
||||
* Calls the shared processor functions directly — no HTTP round-trip needed.
|
||||
*
|
||||
* Both processors are internally idempotent:
|
||||
* - processOnboarding checks state.onboardingComplete
|
||||
* - processCheckin checks state.lastCheckInDate === today
|
||||
*/
|
||||
async function processCompletionServerSide(
|
||||
completion: import('@/lib/onboarding-completion').ExtractedCompletion,
|
||||
_workspacePath: string,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (completion.type === 'onboarding') {
|
||||
const { processOnboarding } = await import('@/lib/onboarding-processor');
|
||||
console.log('[chat API] Server-side onboarding completion detected');
|
||||
await processOnboarding(completion.answers, sessionId);
|
||||
console.log('[chat API] Server-side onboarding completion succeeded');
|
||||
} else if (completion.type === 'checkin') {
|
||||
const { processCheckin } = await import('@/lib/checkin-processor');
|
||||
console.log('[chat API] Server-side checkin completion detected');
|
||||
await processCheckin(completion.answers, sessionId);
|
||||
console.log('[chat API] Server-side checkin completion succeeded');
|
||||
}
|
||||
|
||||
// Clear hookTriggeredSessionId directly (no HTTP needed)
|
||||
try {
|
||||
const { loadState, saveState } = await import('@/lib/assistant-workspace');
|
||||
const { getSetting: getSettingDirect } = await import('@/lib/db');
|
||||
const wsPath = getSettingDirect('assistant_workspace_path');
|
||||
if (wsPath) {
|
||||
const state = loadState(wsPath);
|
||||
state.hookTriggeredSessionId = undefined;
|
||||
saveState(wsPath, state);
|
||||
}
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[chat API] Server-side ${completion.type} processing failed:`, e);
|
||||
}
|
||||
}
|
||||
@@ -8,23 +8,26 @@ function getSettingsPath(): string {
|
||||
return path.join(os.homedir(), '.claude', 'settings.json');
|
||||
}
|
||||
|
||||
function readSettings(): Record<string, unknown> {
|
||||
const settingsPath = getSettingsPath();
|
||||
if (!fs.existsSync(settingsPath)) return {};
|
||||
// ~/.claude.json — Claude CLI stores user-scoped MCP servers here
|
||||
function getUserConfigPath(): string {
|
||||
return path.join(os.homedir(), '.claude.json');
|
||||
}
|
||||
|
||||
function readJsonFile(filePath: string): Record<string, unknown> {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeSettings(settings: Record<string, unknown>): void {
|
||||
const settingsPath = getSettingsPath();
|
||||
const dir = path.dirname(settingsPath);
|
||||
function writeJsonFile(filePath: string, data: Record<string, unknown>): void {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
@@ -35,20 +38,35 @@ export async function DELETE(
|
||||
const { name } = await params;
|
||||
const serverName = decodeURIComponent(name);
|
||||
|
||||
const settings = readSettings();
|
||||
const mcpServers = (settings.mcpServers || {}) as Record<string, MCPServerConfig>;
|
||||
let deleted = false;
|
||||
|
||||
if (!mcpServers[serverName]) {
|
||||
// Try deleting from ~/.claude/settings.json
|
||||
const settings = readJsonFile(getSettingsPath());
|
||||
const settingsServers = (settings.mcpServers || {}) as Record<string, MCPServerConfig>;
|
||||
if (settingsServers[serverName]) {
|
||||
delete settingsServers[serverName];
|
||||
settings.mcpServers = settingsServers;
|
||||
writeJsonFile(getSettingsPath(), settings);
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
// Also try deleting from ~/.claude.json
|
||||
const userConfig = readJsonFile(getUserConfigPath());
|
||||
const userServers = (userConfig.mcpServers || {}) as Record<string, MCPServerConfig>;
|
||||
if (userServers[serverName]) {
|
||||
delete userServers[serverName];
|
||||
userConfig.mcpServers = userServers;
|
||||
writeJsonFile(getUserConfigPath(), userConfig);
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
return NextResponse.json(
|
||||
{ error: `MCP server "${serverName}" not found` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
delete mcpServers[serverName];
|
||||
settings.mcpServers = mcpServers;
|
||||
writeSettings(settings);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -44,11 +44,18 @@ export async function GET(): Promise<NextResponse<MCPConfigResponse | ErrorRespo
|
||||
try {
|
||||
const settings = readSettings();
|
||||
const userConfig = readJsonFile(getUserConfigPath());
|
||||
const settingsServers = (settings.mcpServers || {}) as Record<string, MCPServerConfig>;
|
||||
const userConfigServers = (userConfig.mcpServers || {}) as Record<string, MCPServerConfig>;
|
||||
|
||||
// Merge: ~/.claude/settings.json takes precedence over ~/.claude.json
|
||||
const mcpServers = {
|
||||
...((userConfig.mcpServers || {}) as Record<string, MCPServerConfig>),
|
||||
...((settings.mcpServers || {}) as Record<string, MCPServerConfig>),
|
||||
};
|
||||
// Tag each server with _source so UI knows where it came from
|
||||
const mcpServers: Record<string, MCPServerConfig & { _source?: string }> = {};
|
||||
for (const [name, server] of Object.entries(userConfigServers)) {
|
||||
mcpServers[name] = { ...server, _source: 'claude.json' };
|
||||
}
|
||||
for (const [name, server] of Object.entries(settingsServers)) {
|
||||
mcpServers[name] = { ...server, _source: 'settings.json' };
|
||||
}
|
||||
return NextResponse.json({ mcpServers });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
@@ -63,12 +70,38 @@ export async function PUT(
|
||||
): Promise<NextResponse<SuccessResponse | ErrorResponse>> {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { mcpServers } = body as { mcpServers: Record<string, MCPServerConfig> };
|
||||
const incoming = body.mcpServers as Record<string, MCPServerConfig & { _source?: string }>;
|
||||
|
||||
// Split incoming servers by source and write to the correct file.
|
||||
// Servers without _source or with _source='settings.json' → settings.json
|
||||
// Servers with _source='claude.json' → ~/.claude.json
|
||||
const forSettings: Record<string, MCPServerConfig> = {};
|
||||
const forUserConfig: Record<string, MCPServerConfig> = {};
|
||||
|
||||
for (const [name, server] of Object.entries(incoming)) {
|
||||
const { _source, ...cleanServer } = server;
|
||||
if (_source === 'claude.json') {
|
||||
forUserConfig[name] = cleanServer;
|
||||
} else {
|
||||
forSettings[name] = cleanServer;
|
||||
}
|
||||
}
|
||||
|
||||
// Write settings.json
|
||||
const settings = readSettings();
|
||||
settings.mcpServers = mcpServers;
|
||||
settings.mcpServers = forSettings;
|
||||
writeSettings(settings);
|
||||
|
||||
// Write ~/.claude.json (only the mcpServers key, preserve other fields)
|
||||
const userConfig = readJsonFile(getUserConfigPath());
|
||||
userConfig.mcpServers = forUserConfig;
|
||||
const userConfigPath = getUserConfigPath();
|
||||
const dir = path.dirname(userConfigPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(userConfigPath, JSON.stringify(userConfig, null, 2), 'utf-8');
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
@@ -85,26 +118,33 @@ export async function POST(
|
||||
const body = await request.json();
|
||||
const { name, server } = body as { name: string; server: MCPServerConfig };
|
||||
|
||||
if (!name || !server || !server.command) {
|
||||
// stdio servers require command; sse/http servers require url
|
||||
const isRemote = server?.type === 'sse' || server?.type === 'http';
|
||||
if (!name || !server || (!isRemote && !server.command) || (isRemote && !server.url)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Name and server command are required' },
|
||||
{ error: isRemote ? 'Name and server URL are required' : 'Name and server command are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check both config files for name collision (merged namespace)
|
||||
const settings = readSettings();
|
||||
const userConfig = readJsonFile(getUserConfigPath());
|
||||
if (!settings.mcpServers) {
|
||||
settings.mcpServers = {};
|
||||
}
|
||||
|
||||
const mcpServers = settings.mcpServers as Record<string, MCPServerConfig>;
|
||||
if (mcpServers[name]) {
|
||||
const settingsServers = settings.mcpServers as Record<string, MCPServerConfig>;
|
||||
const userConfigServers = (userConfig.mcpServers || {}) as Record<string, MCPServerConfig>;
|
||||
if (settingsServers[name] || userConfigServers[name]) {
|
||||
return NextResponse.json(
|
||||
{ error: `MCP server "${name}" already exists` },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const mcpServers = settingsServers;
|
||||
|
||||
mcpServers[name] = server;
|
||||
writeSettings(settings);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import crypto from "crypto";
|
||||
import type { SkillKind } from "@/types";
|
||||
|
||||
function getGlobalCommandsDir(): string {
|
||||
return path.join(os.homedir(), ".claude", "commands");
|
||||
@@ -281,6 +282,8 @@ export async function GET(
|
||||
: firstLine || `Skill: /${name}`;
|
||||
}
|
||||
|
||||
const kind: SkillKind = found.filePath.endsWith("SKILL.md") ? "agent_skill" : "slash_command";
|
||||
|
||||
return NextResponse.json({
|
||||
skill: {
|
||||
name,
|
||||
@@ -289,6 +292,7 @@ export async function GET(
|
||||
source: found.source,
|
||||
installedSource: found.installedSource,
|
||||
filePath: found.filePath,
|
||||
kind,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -342,6 +346,8 @@ export async function PUT(
|
||||
? firstLine.replace(/^#+\s*/, "")
|
||||
: firstLine || `Skill: /${name}`;
|
||||
|
||||
const kind: SkillKind = found.filePath.endsWith("SKILL.md") ? "agent_skill" : "slash_command";
|
||||
|
||||
return NextResponse.json({
|
||||
skill: {
|
||||
name,
|
||||
@@ -350,6 +356,7 @@ export async function PUT(
|
||||
source: found.source,
|
||||
installedSource: found.installedSource,
|
||||
filePath: found.filePath,
|
||||
kind,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,12 +3,14 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import crypto from "crypto";
|
||||
import type { SkillKind } from "@/types";
|
||||
|
||||
interface SkillFile {
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
source: "global" | "project" | "plugin" | "installed";
|
||||
source: "global" | "project" | "plugin" | "installed" | "sdk";
|
||||
kind: SkillKind;
|
||||
installedSource?: "agents" | "claude";
|
||||
filePath: string;
|
||||
}
|
||||
@@ -86,6 +88,7 @@ function scanProjectSkills(dir: string): SkillFile[] {
|
||||
description,
|
||||
content,
|
||||
source: "project",
|
||||
kind: "agent_skill",
|
||||
filePath: skillMdPath,
|
||||
});
|
||||
}
|
||||
@@ -177,6 +180,7 @@ function scanInstalledSkills(
|
||||
description,
|
||||
content,
|
||||
source: "installed",
|
||||
kind: "agent_skill",
|
||||
installedSource,
|
||||
contentHash,
|
||||
filePath: skillMdPath,
|
||||
@@ -256,7 +260,7 @@ function scanDirectory(
|
||||
const description = firstLine.startsWith("#")
|
||||
? firstLine.replace(/^#+\s*/, "")
|
||||
: firstLine || `Skill: /${name}`;
|
||||
skills.push({ name, description, content, source, filePath });
|
||||
skills.push({ name, description, content, source, kind: "slash_command", filePath });
|
||||
}
|
||||
} catch {
|
||||
// ignore read errors
|
||||
@@ -332,7 +336,8 @@ export async function GET(request: NextRequest) {
|
||||
name: cmd.name,
|
||||
description: cmd.description || `SDK command: /${cmd.name}`,
|
||||
content: '', // SDK commands don't have local content
|
||||
source: 'sdk' as typeof all[number]['source'],
|
||||
source: 'sdk',
|
||||
kind: 'sdk_command',
|
||||
filePath: '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getSetting, getSession } from '@/lib/db';
|
||||
import { resolveProvider } from '@/lib/provider-resolver';
|
||||
import { loadState, saveState, writeDailyMemory } from '@/lib/assistant-workspace';
|
||||
import { getLocalDateString } from '@/lib/utils';
|
||||
import { generateTextFromProvider } from '@/lib/text-generator';
|
||||
import { processCheckin } from '@/lib/checkin-processor';
|
||||
|
||||
const CHECK_IN_QUESTIONS = [
|
||||
'assistant.checkInQ1',
|
||||
@@ -31,11 +25,6 @@ export async function GET() {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const workspacePath = getSetting('assistant_workspace_path');
|
||||
if (!workspacePath) {
|
||||
return NextResponse.json({ error: 'No workspace path configured' }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { answers, sessionId } = body as { answers: Record<string, string>; sessionId?: string };
|
||||
|
||||
@@ -43,142 +32,14 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Invalid answers format' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Look up the calling session for workspace validation AND provider/model context
|
||||
let session: ReturnType<typeof getSession> | undefined;
|
||||
if (sessionId) {
|
||||
session = getSession(sessionId) ?? undefined;
|
||||
if (session && session.working_directory !== workspacePath) {
|
||||
return NextResponse.json({ error: 'Session does not belong to current workspace' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const qaText = CHECK_IN_LABELS.map((q, i) => {
|
||||
const key = `q${i + 1}`;
|
||||
return `Q: ${q}\nA: ${answers[key] || '(skipped)'}`;
|
||||
}).join('\n\n');
|
||||
|
||||
const today = getLocalDateString();
|
||||
|
||||
// Read existing files for context
|
||||
const memoryPath = path.join(workspacePath, 'memory.md');
|
||||
const userPath = path.join(workspacePath, 'user.md');
|
||||
let existingMemory = '';
|
||||
let existingUser = '';
|
||||
try { existingMemory = fs.readFileSync(memoryPath, 'utf-8'); } catch { /* new file */ }
|
||||
try { existingUser = fs.readFileSync(userPath, 'utf-8'); } catch { /* new file */ }
|
||||
|
||||
try {
|
||||
// Resolve using session's provider/model so check-in uses the same provider as chat
|
||||
const resolved = resolveProvider({
|
||||
sessionProviderId: session?.provider_id || undefined,
|
||||
sessionModel: session?.model || undefined,
|
||||
});
|
||||
// Preserve 'env' semantics (see onboarding route for rationale)
|
||||
const providerId = resolved.provider?.id || 'env';
|
||||
const model = resolved.upstreamModel || resolved.model || getSetting('default_model') || 'claude-sonnet-4-20250514';
|
||||
|
||||
// Generate daily memory entry (episodic, not destructive)
|
||||
const dailyMemoryPrompt = `You maintain daily memory entries for an AI assistant. Given the user's daily check-in answers, generate a daily memory entry for ${today}.
|
||||
|
||||
Format it with these sections:
|
||||
## Work Log
|
||||
(What the user accomplished today)
|
||||
|
||||
## Priority Changes
|
||||
(Any shifts in goals or priorities)
|
||||
|
||||
## To Remember
|
||||
(Things the user wants remembered)
|
||||
|
||||
## Candidate Long-Term Memory
|
||||
(Facts that seem stable enough to promote to long-term memory — only include genuinely persistent facts, not transient updates)
|
||||
|
||||
Keep under 2000 characters.
|
||||
|
||||
Today's check-in (${today}):
|
||||
${qaText}`;
|
||||
|
||||
// Generate promotion candidates: only stable facts to append to memory.md
|
||||
const promotionPrompt = `You maintain a long-term memory file for an AI assistant. Given the user's check-in and the existing memory, output ONLY new stable facts that should be APPENDED to memory.md. These must be genuinely persistent facts (user preferences, recurring patterns, important relationships), NOT daily transients.
|
||||
|
||||
If there's nothing worth promoting, output exactly: (nothing to promote)
|
||||
|
||||
Existing memory.md:
|
||||
${existingMemory || '(empty)'}
|
||||
|
||||
Today's check-in (${today}):
|
||||
${qaText}`;
|
||||
|
||||
// Generate incremental user.md updates
|
||||
const userPrompt = `You maintain a user.md profile for an AI assistant. Given the user's daily check-in answers and the existing profile, generate an UPDATED user.md. Only update sections affected by today's answers. Keep it organized with markdown headers. Keep under 2000 characters.
|
||||
|
||||
Existing user.md:
|
||||
${existingUser || '(empty)'}
|
||||
|
||||
Today's check-in (${today}):
|
||||
${qaText}`;
|
||||
|
||||
const [dailyContent, promotionContent, newUser] = await Promise.all([
|
||||
generateTextFromProvider({ providerId, model, system: 'You maintain knowledge files for AI assistants. Output only the file content, no explanations.', prompt: dailyMemoryPrompt }),
|
||||
generateTextFromProvider({ providerId, model, system: 'You maintain knowledge files for AI assistants. Output only the content to append, no explanations.', prompt: promotionPrompt }),
|
||||
generateTextFromProvider({ providerId, model, system: 'You maintain user profile documents. Output only the file content, no explanations.', prompt: userPrompt }),
|
||||
]);
|
||||
|
||||
// Write daily memory file (episodic, per-day)
|
||||
if (dailyContent.trim()) {
|
||||
writeDailyMemory(workspacePath, today, dailyContent);
|
||||
}
|
||||
|
||||
// Promote stable facts to memory.md (additive append, NOT rewrite)
|
||||
// Dedup: skip if today's promotion already exists in memory.md
|
||||
if (promotionContent.trim() && !promotionContent.includes('(nothing to promote)')) {
|
||||
const currentMemory = fs.existsSync(memoryPath) ? fs.readFileSync(memoryPath, 'utf-8') : '';
|
||||
if (!currentMemory.includes(`## Promoted from ${today}`)) {
|
||||
const appendText = `\n\n## Promoted from ${today}\n${promotionContent.trim()}\n`;
|
||||
fs.appendFileSync(memoryPath, appendText, 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
// Incremental user.md update
|
||||
if (existingUser && newUser.trim()) {
|
||||
fs.writeFileSync(userPath, newUser, 'utf-8');
|
||||
}
|
||||
|
||||
// Archive old daily memories and promote candidates
|
||||
try {
|
||||
const { archiveDailyMemories, promoteDailyToLongTerm } = await import('@/lib/workspace-organizer');
|
||||
archiveDailyMemories(workspacePath);
|
||||
|
||||
// Promote memories that are 7+ days old
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
const dailyDir = path.join(workspacePath, 'memory', 'daily');
|
||||
if (fs.existsSync(dailyDir)) {
|
||||
const oldFiles = fs.readdirSync(dailyDir)
|
||||
.filter(f => /^\d{4}-\d{2}-\d{2}\.md$/.test(f))
|
||||
.filter(f => f.replace('.md', '') <= getLocalDateString(sevenDaysAgo));
|
||||
for (const f of oldFiles) {
|
||||
promoteDailyToLongTerm(workspacePath, f.replace('.md', ''));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// organizer module not available, skip archival
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[workspace/checkin] AI generation failed, writing raw daily entry:', e);
|
||||
// Fallback: write raw answers as daily memory
|
||||
const rawDailyContent = `# Daily Check-in ${today}\n\n${qaText}\n`;
|
||||
writeDailyMemory(workspacePath, today, rawDailyContent);
|
||||
}
|
||||
|
||||
// Update state
|
||||
const state = loadState(workspacePath);
|
||||
state.lastCheckInDate = today;
|
||||
saveState(workspacePath, state);
|
||||
|
||||
await processCheckin(answers, sessionId);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error('[workspace/checkin] POST failed:', e);
|
||||
return NextResponse.json({ error: 'Check-in failed' }, { status: 500 });
|
||||
const message = e instanceof Error ? e.message : 'Check-in failed';
|
||||
const status = message.includes('No workspace path') ? 400
|
||||
: message.includes('does not belong') ? 403
|
||||
: 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getSetting, getSession } from '@/lib/db';
|
||||
import { resolveProvider } from '@/lib/provider-resolver';
|
||||
import { loadState, saveState, ensureDailyDir, generateRootDocs } from '@/lib/assistant-workspace';
|
||||
import { getLocalDateString } from '@/lib/utils';
|
||||
import { generateTextFromProvider } from '@/lib/text-generator';
|
||||
import { processOnboarding } from '@/lib/onboarding-processor';
|
||||
|
||||
const QUESTIONS = [
|
||||
'assistant.onboardingQ1',
|
||||
@@ -51,11 +45,6 @@ export async function GET() {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const workspacePath = getSetting('assistant_workspace_path');
|
||||
if (!workspacePath) {
|
||||
return NextResponse.json({ error: 'No workspace path configured' }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { answers, sessionId } = body as { answers: Record<string, string>; sessionId?: string };
|
||||
|
||||
@@ -63,142 +52,14 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Invalid answers format' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Look up the calling session for workspace validation AND provider/model context
|
||||
let session: ReturnType<typeof getSession> | undefined;
|
||||
if (sessionId) {
|
||||
session = getSession(sessionId) ?? undefined;
|
||||
if (session && session.working_directory !== workspacePath) {
|
||||
return NextResponse.json({ error: 'Session does not belong to current workspace' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// Build Q&A text for the prompt
|
||||
const qaText = QUESTION_LABELS.map((q, i) => {
|
||||
const key = `q${i + 1}`;
|
||||
return `Q: ${q}\nA: ${answers[key] || '(skipped)'}`;
|
||||
}).join('\n\n');
|
||||
|
||||
let soulContent: string;
|
||||
let userContent: string;
|
||||
let claudeContent: string;
|
||||
let memoryContent: string;
|
||||
|
||||
try {
|
||||
// Resolve using session's provider/model so onboarding uses the same provider as chat
|
||||
const resolved = resolveProvider({
|
||||
sessionProviderId: session?.provider_id || undefined,
|
||||
sessionModel: session?.model || undefined,
|
||||
});
|
||||
// Preserve 'env' semantics: when provider=undefined, pass 'env' so text-generator
|
||||
// uses shell env credentials instead of falling back to a random DB provider
|
||||
const providerId = resolved.provider?.id || 'env';
|
||||
const model = resolved.upstreamModel || resolved.model || getSetting('default_model') || 'claude-sonnet-4-20250514';
|
||||
|
||||
const soulPrompt = `Based on the following user onboarding answers, generate a concise "soul.md" file that defines an AI assistant's personality, communication style, and behavioral rules. Write in second person ("You are..."). Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaText}`;
|
||||
|
||||
const userPrompt = `Based on the following user onboarding answers, generate a concise "user.md" profile that captures the user's preferences, goals, and boundaries. Write in third person. Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaText}`;
|
||||
|
||||
const claudePrompt = `Based on the following user onboarding answers, generate a "claude.md" rules file for an AI assistant. Include:
|
||||
- Execution rules (what to do when entering a project, based on Q10)
|
||||
- Communication style rules (based on Q3, Q4, Q7)
|
||||
- Memory rules (what to remember/forget, based on Q8, Q9)
|
||||
- Hard boundaries (based on Q5)
|
||||
Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaText}`;
|
||||
|
||||
const memoryPrompt = `Based on the following user onboarding answers, generate an initial "memory.md" file with long-term facts about the user worth remembering. Include user goals, preferences, and any stable facts. Keep it under 1000 characters. Use markdown headers.\n\n${qaText}`;
|
||||
|
||||
[soulContent, userContent, claudeContent, memoryContent] = await Promise.all([
|
||||
generateTextFromProvider({ providerId, model, system: 'You generate configuration files for AI assistants. Output only the file content, no explanations.', prompt: soulPrompt }),
|
||||
generateTextFromProvider({ providerId, model, system: 'You generate user profile documents. Output only the file content, no explanations.', prompt: userPrompt }),
|
||||
generateTextFromProvider({ providerId, model, system: 'You generate configuration files for AI assistants. Output only the file content, no explanations.', prompt: claudePrompt }),
|
||||
generateTextFromProvider({ providerId, model, system: 'You generate knowledge files for AI assistants. Output only the file content, no explanations.', prompt: memoryPrompt }),
|
||||
]);
|
||||
|
||||
// Fallback if AI returned empty
|
||||
if (!soulContent.trim() || !userContent.trim()) {
|
||||
throw new Error('AI returned empty content');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[workspace/onboarding] AI generation failed, using raw answers:', e);
|
||||
// Fallback: write raw answers
|
||||
soulContent = `# Soul\n\n## Communication Style\n- Address user as: ${answers.q1 || 'not specified'}\n- Assistant name: ${answers.q2 || 'not specified'}\n- Style: ${answers.q3 || 'not specified'}\n- Approach: ${answers.q4 || 'not specified'}\n`;
|
||||
userContent = `# User Profile\n\n## Preferences\n- Boundaries: ${answers.q5 || 'not specified'}\n- Goals: ${answers.q6 || 'not specified'}\n- Output format: ${answers.q7 || 'not specified'}\n- Memory allowed: ${answers.q8 || 'not specified'}\n- Memory forbidden: ${answers.q9 || 'not specified'}\n- Project entry: ${answers.q10 || 'not specified'}\n- Organization: ${answers.q11 || 'not specified'}\n- Default capture: ${answers.q12 || 'not specified'}\n- Archive policy: ${answers.q13 || 'not specified'}\n`;
|
||||
claudeContent = `# Rules\n\n## Execution\n- On project entry: ${answers.q10 || 'not specified'}\n\n## Boundaries\n- ${answers.q5 || 'not specified'}\n\n## Memory\n- Allowed: ${answers.q8 || 'not specified'}\n- Forbidden: ${answers.q9 || 'not specified'}\n`;
|
||||
memoryContent = `# Memory\n\n## User Goals\n- ${answers.q6 || 'not specified'}\n`;
|
||||
}
|
||||
|
||||
// Write all core files
|
||||
fs.writeFileSync(path.join(workspacePath, 'soul.md'), soulContent, 'utf-8');
|
||||
fs.writeFileSync(path.join(workspacePath, 'user.md'), userContent, 'utf-8');
|
||||
if (claudeContent.trim()) {
|
||||
fs.writeFileSync(path.join(workspacePath, 'claude.md'), claudeContent, 'utf-8');
|
||||
}
|
||||
if (memoryContent.trim()) {
|
||||
fs.writeFileSync(path.join(workspacePath, 'memory.md'), memoryContent, 'utf-8');
|
||||
}
|
||||
|
||||
// Ensure V2 directories
|
||||
ensureDailyDir(workspacePath);
|
||||
const inboxDir = path.join(workspacePath, 'Inbox');
|
||||
if (!fs.existsSync(inboxDir)) {
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate config.json from answers
|
||||
try {
|
||||
const { loadConfig, saveConfig } = await import('@/lib/workspace-config');
|
||||
const config = loadConfig(workspacePath);
|
||||
|
||||
// Apply onboarding answers to config
|
||||
const orgStyle = (answers.q11 || '').toLowerCase();
|
||||
if (orgStyle.includes('project')) config.organizationStyle = 'project';
|
||||
else if (orgStyle.includes('time')) config.organizationStyle = 'time';
|
||||
else if (orgStyle.includes('topic')) config.organizationStyle = 'topic';
|
||||
else config.organizationStyle = 'mixed';
|
||||
|
||||
if (answers.q12) {
|
||||
// Sanitize captureDefault: only allow relative paths within workspace
|
||||
let capture = answers.q12.trim();
|
||||
if (path.isAbsolute(capture) || capture.startsWith('~') || capture.includes('..')) {
|
||||
capture = 'Inbox'; // fallback to safe default
|
||||
}
|
||||
config.captureDefault = capture;
|
||||
}
|
||||
|
||||
saveConfig(workspacePath, config);
|
||||
} catch {
|
||||
// config module not available, skip
|
||||
}
|
||||
|
||||
// Generate taxonomy from existing directories
|
||||
try {
|
||||
const { loadTaxonomy, saveTaxonomy, inferTaxonomyFromDirs } = await import('@/lib/workspace-taxonomy');
|
||||
const taxonomy = loadTaxonomy(workspacePath);
|
||||
if (taxonomy.categories.length === 0) {
|
||||
const inferred = inferTaxonomyFromDirs(workspacePath);
|
||||
if (inferred.length > 0) {
|
||||
taxonomy.categories = inferred;
|
||||
saveTaxonomy(workspacePath, taxonomy);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// taxonomy module not available, skip
|
||||
}
|
||||
|
||||
// Generate root docs
|
||||
generateRootDocs(workspacePath);
|
||||
|
||||
// Update state
|
||||
const today = getLocalDateString();
|
||||
const state = loadState(workspacePath);
|
||||
state.onboardingComplete = true;
|
||||
state.lastCheckInDate = today; // Skip daily check-in on the day of onboarding
|
||||
state.schemaVersion = 3;
|
||||
saveState(workspacePath, state);
|
||||
|
||||
await processOnboarding(answers, sessionId);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error('[workspace/onboarding] POST failed:', e);
|
||||
return NextResponse.json({ error: 'Onboarding failed' }, { status: 500 });
|
||||
const message = e instanceof Error ? e.message : 'Onboarding failed';
|
||||
const status = message.includes('No workspace path') ? 400
|
||||
: message.includes('does not belong') ? 403
|
||||
: 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ export default function NewChatPage() {
|
||||
}, [pendingPermission, setPendingApprovalSessionId]);
|
||||
|
||||
const sendFirstMessage = useCallback(
|
||||
async (content: string, _files?: unknown, systemPromptAppend?: string) => {
|
||||
async (content: string, _files?: unknown, systemPromptAppend?: string, displayOverride?: string) => {
|
||||
if (isStreaming) return;
|
||||
|
||||
// Require a project directory before sending
|
||||
@@ -152,12 +152,12 @@ export default function NewChatPage() {
|
||||
// Notify ChatListPanel to refresh immediately
|
||||
window.dispatchEvent(new CustomEvent('session-created'));
|
||||
|
||||
// Add user message to UI
|
||||
// Add user message to UI — use displayOverride for chat bubble if provided
|
||||
const userMessage: Message = {
|
||||
id: 'temp-' + Date.now(),
|
||||
session_id: session.id,
|
||||
role: 'user',
|
||||
content,
|
||||
content: displayOverride || content,
|
||||
created_at: new Date().toISOString(),
|
||||
token_usage: null,
|
||||
};
|
||||
@@ -181,6 +181,7 @@ export default function NewChatPage() {
|
||||
...(systemPromptAppend ? { systemPromptAppend } : {}),
|
||||
...(selectedEffort ? { effort: selectedEffort } : {}),
|
||||
...(thinkingConfig ? { thinking: thinkingConfig } : {}),
|
||||
...(displayOverride ? { displayOverride } : {}),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -102,6 +102,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
const pendingImageNoticesRef = useRef<string[]>([]);
|
||||
// Ref for sendMessage to allow self-referencing in timeout auto-retry
|
||||
const sendMessageRef = useRef<(content: string, files?: FileAttachment[]) => Promise<void>>(undefined);
|
||||
// Init metadata from SDK (tools, slash_commands, skills) — populated on first status event
|
||||
const initMetaRef = useRef<{ tools?: unknown; slash_commands?: unknown; skills?: unknown } | null>(null);
|
||||
|
||||
const handleModeChange = useCallback((newMode: string) => {
|
||||
setMode(newMode);
|
||||
@@ -151,7 +153,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
// If stream completed while this ChatView was unmounted, consume finalMessageContent now.
|
||||
// Re-fetch messages from DB to avoid duplicates (backend already persisted the reply).
|
||||
if (existing.phase !== 'active' && existing.finalMessageContent) {
|
||||
detectAssistantCompletion(existing.finalMessageContent);
|
||||
// Completion processing (onboarding/checkin) is handled server-side only
|
||||
// in the chat API's finally block — no frontend duplicate needed.
|
||||
fetch(`/api/chat/sessions/${sessionId}/messages?limit=50`)
|
||||
.then(res => res.ok ? res.json() : null)
|
||||
.then(data => {
|
||||
@@ -200,8 +203,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
// Append the final assistant message to the messages list
|
||||
const finalContent = event.snapshot.finalMessageContent;
|
||||
if (finalContent) {
|
||||
// Check for assistant project completion signals
|
||||
detectAssistantCompletion(finalContent);
|
||||
// Completion processing (onboarding/checkin) is handled server-side only.
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: 'temp-assistant-' + Date.now(),
|
||||
@@ -278,6 +280,41 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
|
||||
if (!needsOnboarding && !needsCheckIn) return;
|
||||
|
||||
// ── Compensation: check if a past message already contains a completion fence ──
|
||||
// This handles the case where the server-side detection also missed (e.g. crash/restart)
|
||||
// and the frontend is about to re-trigger onboarding unnecessarily.
|
||||
if (needsOnboarding && initialMessages.length > 0) {
|
||||
try {
|
||||
const { extractCompletion } = await import('@/lib/onboarding-completion');
|
||||
// Scan assistant messages from newest to oldest for an unprocessed completion
|
||||
for (let i = initialMessages.length - 1; i >= 0; i--) {
|
||||
const msg = initialMessages[i];
|
||||
if (msg.role !== 'assistant') continue;
|
||||
const completion = extractCompletion(msg.content);
|
||||
if (completion?.type === 'onboarding') {
|
||||
console.log('[ChatView] Found unprocessed onboarding completion in message history, compensating...');
|
||||
const resp = await fetch('/api/workspace/onboarding', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answers: completion.answers, sessionId }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
await fetch('/api/workspace/hook-triggered', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: '__clear__' }),
|
||||
}).catch(() => {});
|
||||
console.log('[ChatView] Onboarding compensation succeeded, skipping re-trigger');
|
||||
return; // Don't re-trigger onboarding
|
||||
}
|
||||
break; // Found fence but processing failed — fall through to re-trigger
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ChatView] Onboarding compensation check failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// For daily check-in, only trigger in the most recent session for this workspace.
|
||||
// This prevents older sessions from hijacking the check-in when reopened.
|
||||
if (needsCheckIn) {
|
||||
@@ -317,6 +354,10 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
sendMessageFn: (retryContent: string, retryFiles?: FileAttachment[]) => {
|
||||
sendMessageRef.current?.(retryContent, retryFiles);
|
||||
},
|
||||
onInitMeta: (meta) => {
|
||||
initMetaRef.current = meta;
|
||||
console.log('[ChatView] SDK init meta received:', meta);
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[ChatView] Assistant auto-trigger failed:', e);
|
||||
@@ -433,69 +474,6 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
[sessionId, setPendingApprovalSessionId]
|
||||
);
|
||||
|
||||
// Detect assistant project completion signals in final message content.
|
||||
// Only processes fences when this session's workingDirectory matches the current assistant workspace path.
|
||||
const detectAssistantCompletion = useCallback(async (content: string) => {
|
||||
// Guard: only allow fence processing for the current assistant workspace session
|
||||
if (!workingDirectory) return;
|
||||
try {
|
||||
const wsRes = await fetch('/api/settings/workspace');
|
||||
if (!wsRes.ok) return;
|
||||
const wsData = await wsRes.json();
|
||||
if (!wsData.path || wsData.path !== workingDirectory) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const clearHookTriggered = () =>
|
||||
fetch('/api/workspace/hook-triggered', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: '__clear__' }),
|
||||
}).catch(() => {});
|
||||
|
||||
// Check for onboarding completion
|
||||
const onboardingMatch = content.match(/```onboarding-complete\s*\r?\n([\s\S]*?)\r?\n\s*```/);
|
||||
if (onboardingMatch) {
|
||||
try {
|
||||
const answers = JSON.parse(onboardingMatch[1].trim());
|
||||
const resp = await fetch('/api/workspace/onboarding', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answers, sessionId }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
await clearHookTriggered();
|
||||
} else {
|
||||
console.error('[ChatView] Onboarding API returned', resp.status);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ChatView] Onboarding completion failed:', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for check-in completion
|
||||
const checkinMatch = content.match(/```checkin-complete\s*\r?\n([\s\S]*?)\r?\n\s*```/);
|
||||
if (checkinMatch) {
|
||||
try {
|
||||
const answers = JSON.parse(checkinMatch[1].trim());
|
||||
const resp = await fetch('/api/workspace/checkin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answers, sessionId }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
await clearHookTriggered();
|
||||
} else {
|
||||
console.error('[ChatView] Check-in API returned', resp.status);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ChatView] Check-in completion failed:', e);
|
||||
}
|
||||
}
|
||||
}, [sessionId, workingDirectory]);
|
||||
|
||||
// Build SDK thinking config from settings
|
||||
const buildThinkingConfig = useCallback((): { type: string } | undefined => {
|
||||
if (!thinkingMode || thinkingMode === 'adaptive') return { type: 'adaptive' };
|
||||
@@ -550,6 +528,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
pendingImageNotices: notices,
|
||||
effort: selectedEffort,
|
||||
thinking: buildThinkingConfig(),
|
||||
displayOverride,
|
||||
onModeChanged: (sdkMode) => {
|
||||
const uiMode = sdkMode === 'plan' ? 'plan' : 'code';
|
||||
handleModeChange(uiMode);
|
||||
@@ -557,6 +536,10 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
sendMessageFn: (retryContent: string, retryFiles?: FileAttachment[]) => {
|
||||
sendMessageRef.current?.(retryContent, retryFiles);
|
||||
},
|
||||
onInitMeta: (meta) => {
|
||||
initMetaRef.current = meta;
|
||||
console.log('[ChatView] SDK init meta received:', meta);
|
||||
},
|
||||
});
|
||||
},
|
||||
[sessionId, isStreaming, mode, currentModel, currentProviderId, selectedEffort, buildThinkingConfig, handleModeChange]
|
||||
@@ -734,6 +717,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
onAssistantTrigger={checkAssistantTrigger}
|
||||
effort={selectedEffort}
|
||||
onEffortChange={setSelectedEffort}
|
||||
sdkInitMeta={initMetaRef.current}
|
||||
/>
|
||||
<ChatComposerActionBar
|
||||
left={<ImageGenToggle />}
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
usePromptInputAttachments,
|
||||
} from '@/components/ai-elements/prompt-input';
|
||||
import type { ChatStatus } from 'ai';
|
||||
import type { FileAttachment, ProviderModelGroup } from '@/types';
|
||||
import type { FileAttachment, ProviderModelGroup, SkillKind } from '@/types';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { SlashCommandButton } from './SlashCommandButton';
|
||||
import { useImageGen } from '@/hooks/useImageGen';
|
||||
@@ -101,6 +101,8 @@ interface MessageInputProps {
|
||||
/** Effort selection lifted to parent for inclusion in the stream chain */
|
||||
effort?: string;
|
||||
onEffortChange?: (effort: string | undefined) => void;
|
||||
/** SDK init metadata — when available, used to validate command/skill availability */
|
||||
sdkInitMeta?: { tools?: unknown; slash_commands?: unknown; skills?: unknown } | null;
|
||||
}
|
||||
|
||||
interface PopoverItem {
|
||||
@@ -111,7 +113,8 @@ interface PopoverItem {
|
||||
builtIn?: boolean;
|
||||
immediate?: boolean;
|
||||
installedSource?: "agents" | "claude";
|
||||
source?: "global" | "project" | "plugin" | "installed";
|
||||
source?: "global" | "project" | "plugin" | "installed" | "sdk";
|
||||
kind?: SkillKind;
|
||||
icon?: typeof CommandLineIcon;
|
||||
}
|
||||
|
||||
@@ -119,7 +122,7 @@ interface CommandBadge {
|
||||
command: string;
|
||||
label: string;
|
||||
description: string;
|
||||
isSkill: boolean;
|
||||
kind: SkillKind;
|
||||
installedSource?: "agents" | "claude";
|
||||
}
|
||||
|
||||
@@ -137,12 +140,12 @@ const BUILT_IN_COMMANDS: PopoverItem[] = [
|
||||
{ label: 'help', value: '/help', description: 'Show available commands and tips', descriptionKey: 'messageInput.helpDesc', builtIn: true, immediate: true, icon: HelpCircleIcon },
|
||||
{ label: 'clear', value: '/clear', description: 'Clear conversation history', descriptionKey: 'messageInput.clearDesc', builtIn: true, immediate: true, icon: Delete02Icon },
|
||||
{ label: 'cost', value: '/cost', description: 'Show token usage statistics', descriptionKey: 'messageInput.costDesc', builtIn: true, immediate: true, icon: Coins01Icon },
|
||||
{ label: 'compact', value: '/compact', description: 'Compress conversation context', descriptionKey: 'messageInput.compactDesc', builtIn: true, icon: FileZipIcon },
|
||||
{ label: 'doctor', value: '/doctor', description: 'Diagnose project health', descriptionKey: 'messageInput.doctorDesc', builtIn: true, icon: Stethoscope02Icon },
|
||||
{ label: 'init', value: '/init', description: 'Initialize CLAUDE.md for project', descriptionKey: 'messageInput.initDesc', builtIn: true, icon: FileEditIcon },
|
||||
{ label: 'review', value: '/review', description: 'Review code quality', descriptionKey: 'messageInput.reviewDesc', builtIn: true, icon: SearchList01Icon },
|
||||
{ label: 'terminal-setup', value: '/terminal-setup', description: 'Configure terminal settings', descriptionKey: 'messageInput.terminalSetupDesc', builtIn: true, icon: CommandLineIcon },
|
||||
{ label: 'memory', value: '/memory', description: 'Edit project memory file', descriptionKey: 'messageInput.memoryDesc', builtIn: true, icon: BrainIcon },
|
||||
{ label: 'compact', value: '/compact', description: 'Compress conversation context', descriptionKey: 'messageInput.compactDesc', builtIn: true, kind: 'sdk_command', icon: FileZipIcon },
|
||||
{ label: 'doctor', value: '/doctor', description: 'Diagnose project health', descriptionKey: 'messageInput.doctorDesc', builtIn: true, kind: 'codepilot_command', icon: Stethoscope02Icon },
|
||||
{ label: 'init', value: '/init', description: 'Initialize CLAUDE.md for project', descriptionKey: 'messageInput.initDesc', builtIn: true, kind: 'sdk_command', icon: FileEditIcon },
|
||||
{ label: 'review', value: '/review', description: 'Review code quality', descriptionKey: 'messageInput.reviewDesc', builtIn: true, kind: 'sdk_command', icon: SearchList01Icon },
|
||||
{ label: 'terminal-setup', value: '/terminal-setup', description: 'Configure terminal settings', descriptionKey: 'messageInput.terminalSetupDesc', builtIn: true, kind: 'codepilot_command', icon: CommandLineIcon },
|
||||
{ label: 'memory', value: '/memory', description: 'Edit project memory file', descriptionKey: 'messageInput.memoryDesc', builtIn: true, kind: 'codepilot_command', icon: BrainIcon },
|
||||
];
|
||||
|
||||
interface ModeOption {
|
||||
@@ -380,6 +383,7 @@ export function MessageInput({
|
||||
onAssistantTrigger,
|
||||
effort: effortProp,
|
||||
onEffortChange,
|
||||
sdkInitMeta,
|
||||
}: MessageInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const imageGen = useImageGen();
|
||||
@@ -489,25 +493,78 @@ export function MessageInput({
|
||||
const data = await res.json();
|
||||
const skills = data.skills || [];
|
||||
apiSkills = skills
|
||||
.map((s: { name: string; description: string; source?: "global" | "project" | "plugin" | "installed"; installedSource?: "agents" | "claude" }) => ({
|
||||
.map((s: { name: string; description: string; source?: "global" | "project" | "plugin" | "installed" | "sdk"; kind?: SkillKind; installedSource?: "agents" | "claude" }) => ({
|
||||
label: s.name,
|
||||
value: `/${s.name}`,
|
||||
description: s.description || "",
|
||||
builtIn: false,
|
||||
installedSource: s.installedSource,
|
||||
source: s.source,
|
||||
kind: s.kind || 'slash_command',
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// API not available - just use built-in commands
|
||||
}
|
||||
|
||||
// When SDK init metadata is available, use it as the truth source:
|
||||
// 1. Filter out filesystem-scanned items that the SDK session didn't actually load
|
||||
// 2. Add any SDK-reported commands/skills missing from the filesystem scan
|
||||
// Note: SDK system:init reports slash_commands and skills as string[] (names only)
|
||||
if (sdkInitMeta) {
|
||||
const rawCmds = sdkInitMeta.slash_commands;
|
||||
const rawSkills = sdkInitMeta.skills;
|
||||
const sdkCommandNames = new Set(
|
||||
Array.isArray(rawCmds) ? rawCmds.map(c => typeof c === 'string' ? c : (c as { name?: string })?.name).filter(Boolean) as string[] : []
|
||||
);
|
||||
const sdkSkillNames = new Set(
|
||||
Array.isArray(rawSkills) ? rawSkills.map(s => typeof s === 'string' ? s : (s as { name?: string })?.name).filter(Boolean) as string[] : []
|
||||
);
|
||||
|
||||
// Only filter if SDK actually reported capabilities (non-empty arrays)
|
||||
if (sdkCommandNames.size > 0 || sdkSkillNames.size > 0) {
|
||||
apiSkills = apiSkills.filter(item => {
|
||||
if (item.kind === 'agent_skill') return sdkSkillNames.has(item.label);
|
||||
return sdkCommandNames.has(item.label);
|
||||
});
|
||||
}
|
||||
|
||||
const existingNames = new Set(apiSkills.map(s => s.label));
|
||||
|
||||
// Add SDK-reported commands not found in filesystem scan
|
||||
for (const cmdName of sdkCommandNames) {
|
||||
if (!existingNames.has(cmdName)) {
|
||||
apiSkills.push({
|
||||
label: cmdName,
|
||||
value: `/${cmdName}`,
|
||||
description: `SDK command: /${cmdName}`,
|
||||
builtIn: false,
|
||||
source: 'sdk',
|
||||
kind: 'sdk_command',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add SDK-reported skills not found in filesystem scan
|
||||
for (const skillName of sdkSkillNames) {
|
||||
if (!existingNames.has(skillName)) {
|
||||
apiSkills.push({
|
||||
label: skillName,
|
||||
value: `/${skillName}`,
|
||||
description: `Skill: /${skillName}`,
|
||||
builtIn: false,
|
||||
kind: 'agent_skill',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate: remove API skills that share a name with built-in commands
|
||||
const builtInNames = new Set(BUILT_IN_COMMANDS.map(c => c.label));
|
||||
const uniqueSkills = apiSkills.filter(s => !builtInNames.has(s.label));
|
||||
|
||||
return [...BUILT_IN_COMMANDS, ...uniqueSkills];
|
||||
}, [workingDirectory]);
|
||||
}, [workingDirectory, sdkInitMeta]);
|
||||
|
||||
// Close popover
|
||||
const closePopover = useCallback(() => {
|
||||
@@ -553,7 +610,7 @@ export function MessageInput({
|
||||
command: item.value,
|
||||
label: item.label,
|
||||
description: item.description || '',
|
||||
isSkill: !item.builtIn,
|
||||
kind: item.kind || 'slash_command',
|
||||
installedSource: item.installedSource,
|
||||
});
|
||||
setInputValue('');
|
||||
@@ -682,41 +739,49 @@ export function MessageInput({
|
||||
return;
|
||||
}
|
||||
|
||||
// If badge is active, expand the command/skill and send
|
||||
// If badge is active, dispatch by kind
|
||||
if (badge && !isStreaming) {
|
||||
let expandedPrompt = '';
|
||||
|
||||
if (badge.isSkill) {
|
||||
// Fetch skill content from API
|
||||
try {
|
||||
const detailParams = new URLSearchParams();
|
||||
if (badge.installedSource) detailParams.set("source", badge.installedSource);
|
||||
if (workingDirectory) detailParams.set("cwd", workingDirectory);
|
||||
const qs = detailParams.toString();
|
||||
const res = await fetch(
|
||||
`/api/skills/${encodeURIComponent(badge.label)}${qs ? `?${qs}` : ""}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
expandedPrompt = data.skill?.content || '';
|
||||
}
|
||||
} catch {
|
||||
// Fallback: use command name
|
||||
}
|
||||
} else {
|
||||
// Built-in prompt command expansion
|
||||
expandedPrompt = COMMAND_PROMPTS[badge.command] || '';
|
||||
}
|
||||
|
||||
const finalPrompt = content
|
||||
? `${expandedPrompt}\n\nUser context: ${content}`
|
||||
: expandedPrompt || badge.command;
|
||||
|
||||
const files = await convertFiles();
|
||||
setBadge(null);
|
||||
setInputValue('');
|
||||
onSend(finalPrompt, files.length > 0 ? files : undefined);
|
||||
return;
|
||||
const displayLabel = `/${badge.label}`;
|
||||
|
||||
switch (badge.kind) {
|
||||
case 'agent_skill': {
|
||||
// Agent skills: SDK loads SKILL.md natively from filesystem.
|
||||
// Always include skill name so Claude knows which skill was selected.
|
||||
// User context is appended after the skill reference.
|
||||
const agentPrompt = content
|
||||
? `Use the ${badge.label} skill. User context: ${content}`
|
||||
: `Please use the ${badge.label} skill.`;
|
||||
setBadge(null);
|
||||
setInputValue('');
|
||||
onSend(agentPrompt, files.length > 0 ? files : undefined, undefined, displayLabel);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'slash_command':
|
||||
case 'sdk_command': {
|
||||
// Slash commands & SDK commands: send "/{command} {context}" as-is for SDK to handle
|
||||
const slashPrompt = content
|
||||
? `${badge.command} ${content}`
|
||||
: badge.command;
|
||||
setBadge(null);
|
||||
setInputValue('');
|
||||
onSend(slashPrompt, files.length > 0 ? files : undefined, undefined, displayLabel);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'codepilot_command': {
|
||||
// CodePilot-specific commands: expand via COMMAND_PROMPTS, show /command in bubble
|
||||
const expandedPrompt = COMMAND_PROMPTS[badge.command] || '';
|
||||
const finalPrompt = content
|
||||
? `${expandedPrompt}\n\nUser context: ${content}`
|
||||
: expandedPrompt || badge.command;
|
||||
setBadge(null);
|
||||
setInputValue('');
|
||||
onSend(finalPrompt, files.length > 0 ? files : undefined, undefined, displayLabel);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const files = await convertFiles();
|
||||
@@ -738,20 +803,20 @@ export function MessageInput({
|
||||
command: cmd.value,
|
||||
label: cmd.label,
|
||||
description: cmd.description || '',
|
||||
isSkill: false,
|
||||
kind: cmd.kind || 'sdk_command',
|
||||
});
|
||||
setInputValue('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Not a built-in command — treat as a skill
|
||||
// Not a built-in command — default to slash_command (SDK will handle)
|
||||
const skillName = content.slice(1);
|
||||
if (skillName) {
|
||||
setBadge({
|
||||
command: content,
|
||||
label: skillName,
|
||||
description: '',
|
||||
isSkill: true,
|
||||
kind: 'slash_command',
|
||||
});
|
||||
setInputValue('');
|
||||
return;
|
||||
@@ -972,8 +1037,8 @@ export function MessageInput({
|
||||
{/* Popover */}
|
||||
{popoverMode && (allDisplayedItems.length > 0 || aiSearchLoading) && (() => {
|
||||
const builtInItems = filteredItems.filter(item => item.builtIn);
|
||||
const projectItems = filteredItems.filter(item => !item.builtIn && item.source === 'project');
|
||||
const skillItems = filteredItems.filter(item => !item.builtIn && item.source !== 'project');
|
||||
const slashCommandItems = filteredItems.filter(item => !item.builtIn && item.kind !== 'agent_skill');
|
||||
const agentSkillItems = filteredItems.filter(item => !item.builtIn && item.kind === 'agent_skill');
|
||||
let globalIdx = 0;
|
||||
|
||||
const renderItem = (item: PopoverItem, idx: number) => (
|
||||
@@ -991,7 +1056,9 @@ export function MessageInput({
|
||||
<HugeiconsIcon icon={AtIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : item.builtIn && item.icon ? (
|
||||
<HugeiconsIcon icon={item.icon} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : !item.builtIn && item.source === 'project' ? (
|
||||
) : item.kind === 'agent_skill' ? (
|
||||
<HugeiconsIcon icon={BrainIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : item.kind === 'slash_command' ? (
|
||||
<HugeiconsIcon icon={FileEditIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : !item.builtIn ? (
|
||||
<HugeiconsIcon icon={GlobalIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
@@ -1077,23 +1144,23 @@ export function MessageInput({
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{projectItems.length > 0 && (
|
||||
{slashCommandItems.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Project Commands
|
||||
Slash Commands
|
||||
</div>
|
||||
{projectItems.map((item) => {
|
||||
{slashCommandItems.map((item) => {
|
||||
const idx = globalIdx++;
|
||||
return renderItem(item, idx);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{skillItems.length > 0 && (
|
||||
{agentSkillItems.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Skills
|
||||
Agent Skills
|
||||
</div>
|
||||
{skillItems.map((item) => {
|
||||
{agentSkillItems.map((item) => {
|
||||
const idx = globalIdx++;
|
||||
return renderItem(item, idx);
|
||||
})}
|
||||
|
||||
@@ -19,9 +19,11 @@ interface McpRuntimeStatus {
|
||||
serverInfo?: { name: string; version: string };
|
||||
}
|
||||
|
||||
type MCPServerWithSource = MCPServer & { _source?: string };
|
||||
|
||||
export function McpManager() {
|
||||
const { t } = useTranslation();
|
||||
const [servers, setServers] = useState<Record<string, MCPServer>>({});
|
||||
const [servers, setServers] = useState<Record<string, MCPServerWithSource>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editingName, setEditingName] = useState<string | undefined>();
|
||||
@@ -116,9 +118,11 @@ export function McpManager() {
|
||||
|
||||
async function handleSave(name: string, server: MCPServer) {
|
||||
if (editingName && editingName !== name) {
|
||||
// Rename: preserve _source from the original entry
|
||||
const original = servers[editingName];
|
||||
const updated = { ...servers };
|
||||
delete updated[editingName];
|
||||
updated[name] = server;
|
||||
updated[name] = original?._source ? { ...server, _source: original._source } : server;
|
||||
try {
|
||||
await fetch("/api/plugins/mcp", {
|
||||
method: "PUT",
|
||||
@@ -130,7 +134,10 @@ export function McpManager() {
|
||||
console.error("Failed to save MCP server:", err);
|
||||
}
|
||||
} else if (editingName) {
|
||||
const updated = { ...servers, [name]: server };
|
||||
// Edit in-place: preserve _source
|
||||
const original = servers[editingName];
|
||||
const serverWithSource = original?._source ? { ...server, _source: original._source } : server;
|
||||
const updated = { ...servers, [name]: serverWithSource };
|
||||
try {
|
||||
await fetch("/api/plugins/mcp", {
|
||||
method: "PUT",
|
||||
@@ -162,13 +169,26 @@ export function McpManager() {
|
||||
|
||||
async function handleJsonSave(jsonStr: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
const parsed = JSON.parse(jsonStr) as Record<string, MCPServer>;
|
||||
// JSON editor only manages settings.json servers.
|
||||
// Merge back: keep claude.json servers untouched, replace settings.json servers.
|
||||
const claudeJsonServers: Record<string, MCPServerWithSource> = {};
|
||||
for (const [name, server] of Object.entries(servers)) {
|
||||
if (server._source === 'claude.json') {
|
||||
claudeJsonServers[name] = server;
|
||||
}
|
||||
}
|
||||
const settingsServers: Record<string, MCPServerWithSource> = {};
|
||||
for (const [name, server] of Object.entries(parsed)) {
|
||||
settingsServers[name] = { ...server, _source: 'settings.json' };
|
||||
}
|
||||
const merged = { ...claudeJsonServers, ...settingsServers };
|
||||
await fetch("/api/plugins/mcp", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mcpServers: parsed }),
|
||||
body: JSON.stringify({ mcpServers: merged }),
|
||||
});
|
||||
setServers(parsed);
|
||||
setServers(merged);
|
||||
} catch (err) {
|
||||
console.error("Failed to save MCP config:", err);
|
||||
}
|
||||
@@ -234,8 +254,25 @@ export function McpManager() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="json" className="mt-4">
|
||||
{Object.values(servers).some(s => s._source === 'claude.json') && (
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Servers from ~/.claude.json are managed by Claude CLI and not shown here.
|
||||
Use the list tab to edit or delete them.
|
||||
</p>
|
||||
)}
|
||||
<ConfigEditor
|
||||
value={JSON.stringify(servers, null, 2)}
|
||||
value={JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(servers)
|
||||
.filter(([, v]) => v._source !== 'claude.json')
|
||||
.map(([k, v]) => {
|
||||
const { _source, ...rest } = v;
|
||||
return [k, rest];
|
||||
})
|
||||
),
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
onSave={handleJsonSave}
|
||||
label={t('mcp.serverConfig')}
|
||||
/>
|
||||
|
||||
@@ -162,7 +162,6 @@ export function McpServerEditor({
|
||||
...(env ? { env } : {}),
|
||||
}
|
||||
: {
|
||||
command: '',
|
||||
type: serverType,
|
||||
...(url ? { url: url.trim() } : {}),
|
||||
...(serverArgs.length > 0 ? { args: serverArgs } : {}),
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface SSECallbacks {
|
||||
onRewindPoint: (sdkUserMessageId: string) => void;
|
||||
onKeepAlive: () => void;
|
||||
onError: (accumulated: string) => void;
|
||||
onInitMeta?: (meta: { tools?: unknown; slash_commands?: unknown; skills?: unknown }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,6 +92,11 @@ function handleSSEEvent(
|
||||
const statusData = JSON.parse(event.data);
|
||||
if (statusData.session_id) {
|
||||
callbacks.onStatus(`Connected (${statusData.requested_model || statusData.model || 'claude'})`);
|
||||
callbacks.onInitMeta?.({
|
||||
tools: statusData.tools,
|
||||
slash_commands: statusData.slash_commands,
|
||||
skills: statusData.skills,
|
||||
});
|
||||
} else if (statusData.notification) {
|
||||
callbacks.onStatus(statusData.message || statusData.title || undefined);
|
||||
} else {
|
||||
@@ -254,6 +260,7 @@ export function useSSEStream() {
|
||||
onRewindPoint: (id) => callbacksRef.current?.onRewindPoint(id),
|
||||
onKeepAlive: () => callbacksRef.current?.onKeepAlive(),
|
||||
onError: (a) => callbacksRef.current?.onError(a),
|
||||
onInitMeta: (m) => callbacksRef.current?.onInitMeta?.(m),
|
||||
};
|
||||
|
||||
return consumeSSEStream(reader, proxied);
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Core check-in processing logic, extracted from the API route
|
||||
* so it can be called directly from server-side completion detection
|
||||
* without an HTTP round-trip.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getSetting, getSession } from '@/lib/db';
|
||||
import { resolveProvider } from '@/lib/provider-resolver';
|
||||
import { loadState, saveState, writeDailyMemory } from '@/lib/assistant-workspace';
|
||||
import { getLocalDateString } from '@/lib/utils';
|
||||
import { generateTextFromProvider } from '@/lib/text-generator';
|
||||
|
||||
const CHECK_IN_LABELS = [
|
||||
'What did you work on or accomplish today?',
|
||||
'Any changes to your current priorities or goals?',
|
||||
'Anything you\'d like me to remember going forward?',
|
||||
];
|
||||
|
||||
/**
|
||||
* Process check-in completion. Generates daily memory, promotes stable facts,
|
||||
* updates user profile.
|
||||
*
|
||||
* Idempotent for today: if state.lastCheckInDate is already today, returns early.
|
||||
*
|
||||
* @throws Error if workspace path is not configured or processing fails
|
||||
*/
|
||||
export async function processCheckin(
|
||||
answers: Record<string, string>,
|
||||
sessionId?: string,
|
||||
): Promise<void> {
|
||||
const workspacePath = getSetting('assistant_workspace_path');
|
||||
if (!workspacePath) {
|
||||
throw new Error('No workspace path configured');
|
||||
}
|
||||
|
||||
const today = getLocalDateString();
|
||||
|
||||
// Idempotent check: skip if already checked in today
|
||||
const currentState = loadState(workspacePath);
|
||||
if (currentState.lastCheckInDate === today) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the calling session for provider/model context
|
||||
let session: ReturnType<typeof getSession> | undefined;
|
||||
if (sessionId) {
|
||||
session = getSession(sessionId) ?? undefined;
|
||||
if (session && session.working_directory !== workspacePath) {
|
||||
throw new Error('Session does not belong to current workspace');
|
||||
}
|
||||
}
|
||||
|
||||
const qaText = CHECK_IN_LABELS.map((q, i) => {
|
||||
const key = `q${i + 1}`;
|
||||
return `Q: ${q}\nA: ${answers[key] || '(skipped)'}`;
|
||||
}).join('\n\n');
|
||||
|
||||
// Read existing files for context
|
||||
const memoryPath = path.join(workspacePath, 'memory.md');
|
||||
const userPath = path.join(workspacePath, 'user.md');
|
||||
let existingMemory = '';
|
||||
let existingUser = '';
|
||||
try { existingMemory = fs.readFileSync(memoryPath, 'utf-8'); } catch { /* new file */ }
|
||||
try { existingUser = fs.readFileSync(userPath, 'utf-8'); } catch { /* new file */ }
|
||||
|
||||
try {
|
||||
const resolved = resolveProvider({
|
||||
sessionProviderId: session?.provider_id || undefined,
|
||||
sessionModel: session?.model || undefined,
|
||||
});
|
||||
const providerId = resolved.provider?.id || 'env';
|
||||
const model = resolved.upstreamModel || resolved.model || getSetting('default_model') || 'claude-sonnet-4-20250514';
|
||||
|
||||
const dailyMemoryPrompt = `You maintain daily memory entries for an AI assistant. Given the user's daily check-in answers, generate a daily memory entry for ${today}.
|
||||
|
||||
Format it with these sections:
|
||||
## Work Log
|
||||
(What the user accomplished today)
|
||||
|
||||
## Priority Changes
|
||||
(Any shifts in goals or priorities)
|
||||
|
||||
## To Remember
|
||||
(Things the user wants remembered)
|
||||
|
||||
## Candidate Long-Term Memory
|
||||
(Facts that seem stable enough to promote to long-term memory — only include genuinely persistent facts, not transient updates)
|
||||
|
||||
Keep under 2000 characters.
|
||||
|
||||
Today's check-in (${today}):
|
||||
${qaText}`;
|
||||
|
||||
const promotionPrompt = `You maintain a long-term memory file for an AI assistant. Given the user's check-in and the existing memory, output ONLY new stable facts that should be APPENDED to memory.md. These must be genuinely persistent facts (user preferences, recurring patterns, important relationships), NOT daily transients.
|
||||
|
||||
If there's nothing worth promoting, output exactly: (nothing to promote)
|
||||
|
||||
Existing memory.md:
|
||||
${existingMemory || '(empty)'}
|
||||
|
||||
Today's check-in (${today}):
|
||||
${qaText}`;
|
||||
|
||||
const userPrompt = `You maintain a user.md profile for an AI assistant. Given the user's daily check-in answers and the existing profile, generate an UPDATED user.md. Only update sections affected by today's answers. Keep it organized with markdown headers. Keep under 2000 characters.
|
||||
|
||||
Existing user.md:
|
||||
${existingUser || '(empty)'}
|
||||
|
||||
Today's check-in (${today}):
|
||||
${qaText}`;
|
||||
|
||||
const [dailyContent, promotionContent, newUser] = await Promise.all([
|
||||
generateTextFromProvider({ providerId, model, system: 'You maintain knowledge files for AI assistants. Output only the file content, no explanations.', prompt: dailyMemoryPrompt }),
|
||||
generateTextFromProvider({ providerId, model, system: 'You maintain knowledge files for AI assistants. Output only the content to append, no explanations.', prompt: promotionPrompt }),
|
||||
generateTextFromProvider({ providerId, model, system: 'You maintain user profile documents. Output only the file content, no explanations.', prompt: userPrompt }),
|
||||
]);
|
||||
|
||||
// Write daily memory file (episodic, per-day)
|
||||
if (dailyContent.trim()) {
|
||||
writeDailyMemory(workspacePath, today, dailyContent);
|
||||
}
|
||||
|
||||
// Promote stable facts to memory.md (additive append, NOT rewrite)
|
||||
// Dedup: skip if today's promotion already exists in memory.md
|
||||
if (promotionContent.trim() && !promotionContent.includes('(nothing to promote)')) {
|
||||
const currentMemory = fs.existsSync(memoryPath) ? fs.readFileSync(memoryPath, 'utf-8') : '';
|
||||
if (!currentMemory.includes(`## Promoted from ${today}`)) {
|
||||
const appendText = `\n\n## Promoted from ${today}\n${promotionContent.trim()}\n`;
|
||||
fs.appendFileSync(memoryPath, appendText, 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
// Incremental user.md update
|
||||
if (existingUser && newUser.trim()) {
|
||||
fs.writeFileSync(userPath, newUser, 'utf-8');
|
||||
}
|
||||
|
||||
// Archive old daily memories and promote candidates
|
||||
try {
|
||||
const { archiveDailyMemories, promoteDailyToLongTerm } = await import('@/lib/workspace-organizer');
|
||||
archiveDailyMemories(workspacePath);
|
||||
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
const dailyDir = path.join(workspacePath, 'memory', 'daily');
|
||||
if (fs.existsSync(dailyDir)) {
|
||||
const oldFiles = fs.readdirSync(dailyDir)
|
||||
.filter(f => /^\d{4}-\d{2}-\d{2}\.md$/.test(f))
|
||||
.filter(f => f.replace('.md', '') <= getLocalDateString(sevenDaysAgo));
|
||||
for (const f of oldFiles) {
|
||||
promoteDailyToLongTerm(workspacePath, f.replace('.md', ''));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// organizer module not available, skip archival
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[checkin-processor] AI generation failed, writing raw daily entry:', e);
|
||||
const rawDailyContent = `# Daily Check-in ${today}\n\n${qaText}\n`;
|
||||
writeDailyMemory(workspacePath, today, rawDailyContent);
|
||||
}
|
||||
|
||||
// Update state
|
||||
const state = loadState(workspacePath);
|
||||
state.lastCheckInDate = today;
|
||||
saveState(workspacePath, state);
|
||||
}
|
||||
@@ -798,6 +798,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
const sysMsg = message as SDKSystemMessage;
|
||||
if ('subtype' in sysMsg) {
|
||||
if (sysMsg.subtype === 'init') {
|
||||
const initMsg = sysMsg as SDKSystemMessage & { slash_commands?: unknown; skills?: unknown };
|
||||
controller.enqueue(formatSSE({
|
||||
type: 'status',
|
||||
data: JSON.stringify({
|
||||
@@ -805,6 +806,8 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
model: sysMsg.model,
|
||||
requested_model: model,
|
||||
tools: sysMsg.tools,
|
||||
slash_commands: initMsg.slash_commands,
|
||||
skills: initMsg.skills,
|
||||
}),
|
||||
}));
|
||||
} else if (sysMsg.subtype === 'status') {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Shared module for extracting and processing onboarding/checkin completion
|
||||
* fences from assistant messages. Used by both frontend (ChatView) and
|
||||
* backend (collectStreamResponse) to ensure completion is never missed.
|
||||
*/
|
||||
|
||||
// ─── Fence Extraction ───────────────────────────────────────────────
|
||||
|
||||
/** Regex for onboarding-complete fence — tolerates CRLF, extra whitespace, optional language tag */
|
||||
const ONBOARDING_FENCE_RE =
|
||||
/```+\s*onboarding-complete[^\n]*\r?\n([\s\S]*?)\r?\n\s*```+/;
|
||||
|
||||
/** Regex for checkin-complete fence */
|
||||
const CHECKIN_FENCE_RE =
|
||||
/```+\s*checkin-complete[^\n]*\r?\n([\s\S]*?)\r?\n\s*```+/;
|
||||
|
||||
export interface CompletionFence {
|
||||
type: 'onboarding' | 'checkin';
|
||||
rawPayload: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a completion fence from assistant message content.
|
||||
* Returns null if no fence found.
|
||||
*/
|
||||
export function extractCompletionFence(content: string): CompletionFence | null {
|
||||
const onboardingMatch = content.match(ONBOARDING_FENCE_RE);
|
||||
if (onboardingMatch) {
|
||||
return { type: 'onboarding', rawPayload: onboardingMatch[1] };
|
||||
}
|
||||
const checkinMatch = content.match(CHECKIN_FENCE_RE);
|
||||
if (checkinMatch) {
|
||||
return { type: 'checkin', rawPayload: checkinMatch[1] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Robust JSON Parsing ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Attempt to parse a JSON payload from an AI-generated completion fence.
|
||||
* Applies progressive repair strategies for common formatting issues:
|
||||
* 1. Strip markdown bold/italic markers
|
||||
* 2. Fix unescaped newlines inside string values
|
||||
* 3. Fix single quotes → double quotes (only around keys/values)
|
||||
* 4. Remove trailing commas before } or ]
|
||||
* 5. As last resort, extract key-value pairs with regex
|
||||
*/
|
||||
export function parseCompletionPayload(raw: string): Record<string, string> | null {
|
||||
const trimmed = raw.trim();
|
||||
|
||||
// Pre-clean: strip markdown formatting (**bold**, *italic*) from values
|
||||
// This is safe because * is not valid unquoted JSON syntax
|
||||
let cleaned = trimmed.replace(/\*{1,2}([^*]+)\*{1,2}/g, '$1');
|
||||
|
||||
// Strategy 1: direct parse
|
||||
try {
|
||||
const parsed = JSON.parse(cleaned);
|
||||
if (isValidAnswerMap(parsed)) return normalizeAnswerMap(parsed);
|
||||
} catch { /* continue */ }
|
||||
|
||||
// Strategy 3: fix unescaped newlines inside JSON strings
|
||||
// Replace literal newlines between quotes with \\n
|
||||
cleaned = cleaned.replace(/"([^"]*?)"/g, (_match, inner: string) => {
|
||||
return '"' + inner.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t') + '"';
|
||||
});
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(cleaned);
|
||||
if (isValidAnswerMap(parsed)) return normalizeAnswerMap(parsed);
|
||||
} catch { /* continue */ }
|
||||
|
||||
// Strategy 4: single quotes → double quotes (careful: only structural quotes)
|
||||
cleaned = cleaned.replace(/'/g, '"');
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(cleaned);
|
||||
if (isValidAnswerMap(parsed)) return normalizeAnswerMap(parsed);
|
||||
} catch { /* continue */ }
|
||||
|
||||
// Strategy 5: remove trailing commas before } or ]
|
||||
cleaned = cleaned.replace(/,\s*([}\]])/g, '$1');
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(cleaned);
|
||||
if (isValidAnswerMap(parsed)) return normalizeAnswerMap(parsed);
|
||||
} catch { /* continue */ }
|
||||
|
||||
// Strategy 6: regex extraction as last resort
|
||||
// Match patterns like "q1": "some value" or "q1":"some value"
|
||||
const result: Record<string, string> = {};
|
||||
const kvRegex = /"(q\d{1,2})"\s*:\s*"((?:[^"\\]|\\.)*)"/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = kvRegex.exec(trimmed)) !== null) {
|
||||
result[m[1]] = m[2].replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\r/g, '');
|
||||
}
|
||||
if (Object.keys(result).length >= 3) {
|
||||
// Accept if we got at least 3 answers (partial is better than nothing)
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isValidAnswerMap(obj: unknown): obj is Record<string, unknown> {
|
||||
return typeof obj === 'object' && obj !== null && !Array.isArray(obj);
|
||||
}
|
||||
|
||||
function normalizeAnswerMap(obj: Record<string, unknown>): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
result[key] = typeof value === 'string' ? value : String(value ?? '');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── High-Level Helpers ─────────────────────────────────────────────
|
||||
|
||||
export interface ExtractedCompletion {
|
||||
type: 'onboarding' | 'checkin';
|
||||
answers: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and parse a completion from assistant message content.
|
||||
* Returns null if no valid completion found.
|
||||
*/
|
||||
export function extractCompletion(content: string): ExtractedCompletion | null {
|
||||
const fence = extractCompletionFence(content);
|
||||
if (!fence) return null;
|
||||
|
||||
const answers = parseCompletionPayload(fence.rawPayload);
|
||||
if (!answers) return null;
|
||||
|
||||
return { type: fence.type, answers };
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Core onboarding processing logic, extracted from the API route
|
||||
* so it can be called directly from server-side completion detection
|
||||
* without an HTTP round-trip.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getSetting, getSession } from '@/lib/db';
|
||||
import { resolveProvider } from '@/lib/provider-resolver';
|
||||
import { loadState, saveState, ensureDailyDir, generateRootDocs } from '@/lib/assistant-workspace';
|
||||
import { getLocalDateString } from '@/lib/utils';
|
||||
import { generateTextFromProvider } from '@/lib/text-generator';
|
||||
|
||||
const QUESTION_LABELS = [
|
||||
'How should I address you?',
|
||||
'What name should I use for myself?',
|
||||
'Do you prefer "concise and direct" or "detailed explanations"?',
|
||||
'Do you prefer "minimal interruptions" or "proactive suggestions"?',
|
||||
'What are your three hard boundaries?',
|
||||
'What are your three most important current goals?',
|
||||
'Do you prefer output as "lists", "reports", or "conversation summaries"?',
|
||||
'What information may be written to long-term memory?',
|
||||
'What information must never be written to long-term memory?',
|
||||
'What three things should I do first when entering a project?',
|
||||
'How do you organize your materials? (by project / time / topic / mixed)',
|
||||
'Where should new information go by default?',
|
||||
'How should completed tasks be archived?',
|
||||
];
|
||||
|
||||
/**
|
||||
* Process onboarding completion. Generates workspace files from answers.
|
||||
* Idempotent: if state.onboardingComplete is already true, returns early.
|
||||
*
|
||||
* @throws Error if workspace path is not configured or processing fails
|
||||
*/
|
||||
export async function processOnboarding(
|
||||
answers: Record<string, string>,
|
||||
sessionId?: string,
|
||||
): Promise<void> {
|
||||
const workspacePath = getSetting('assistant_workspace_path');
|
||||
if (!workspacePath) {
|
||||
throw new Error('No workspace path configured');
|
||||
}
|
||||
|
||||
// Idempotent check
|
||||
const currentState = loadState(workspacePath);
|
||||
if (currentState.onboardingComplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the calling session for provider/model context
|
||||
let session: ReturnType<typeof getSession> | undefined;
|
||||
if (sessionId) {
|
||||
session = getSession(sessionId) ?? undefined;
|
||||
if (session && session.working_directory !== workspacePath) {
|
||||
throw new Error('Session does not belong to current workspace');
|
||||
}
|
||||
}
|
||||
|
||||
// Build Q&A text for the prompt
|
||||
const qaText = QUESTION_LABELS.map((q, i) => {
|
||||
const key = `q${i + 1}`;
|
||||
return `Q: ${q}\nA: ${answers[key] || '(skipped)'}`;
|
||||
}).join('\n\n');
|
||||
|
||||
let soulContent: string;
|
||||
let userContent: string;
|
||||
let claudeContent: string;
|
||||
let memoryContent: string;
|
||||
|
||||
try {
|
||||
const resolved = resolveProvider({
|
||||
sessionProviderId: session?.provider_id || undefined,
|
||||
sessionModel: session?.model || undefined,
|
||||
});
|
||||
const providerId = resolved.provider?.id || 'env';
|
||||
const model = resolved.upstreamModel || resolved.model || getSetting('default_model') || 'claude-sonnet-4-20250514';
|
||||
|
||||
const soulPrompt = `Based on the following user onboarding answers, generate a concise "soul.md" file that defines an AI assistant's personality, communication style, and behavioral rules. Write in second person ("You are..."). Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaText}`;
|
||||
|
||||
const userPrompt = `Based on the following user onboarding answers, generate a concise "user.md" profile that captures the user's preferences, goals, and boundaries. Write in third person. Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaText}`;
|
||||
|
||||
const claudePrompt = `Based on the following user onboarding answers, generate a "claude.md" rules file for an AI assistant. Include:
|
||||
- Execution rules (what to do when entering a project, based on Q10)
|
||||
- Communication style rules (based on Q3, Q4, Q7)
|
||||
- Memory rules (what to remember/forget, based on Q8, Q9)
|
||||
- Hard boundaries (based on Q5)
|
||||
Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaText}`;
|
||||
|
||||
const memoryPrompt = `Based on the following user onboarding answers, generate an initial "memory.md" file with long-term facts about the user worth remembering. Include user goals, preferences, and any stable facts. Keep it under 1000 characters. Use markdown headers.\n\n${qaText}`;
|
||||
|
||||
[soulContent, userContent, claudeContent, memoryContent] = await Promise.all([
|
||||
generateTextFromProvider({ providerId, model, system: 'You generate configuration files for AI assistants. Output only the file content, no explanations.', prompt: soulPrompt }),
|
||||
generateTextFromProvider({ providerId, model, system: 'You generate user profile documents. Output only the file content, no explanations.', prompt: userPrompt }),
|
||||
generateTextFromProvider({ providerId, model, system: 'You generate configuration files for AI assistants. Output only the file content, no explanations.', prompt: claudePrompt }),
|
||||
generateTextFromProvider({ providerId, model, system: 'You generate knowledge files for AI assistants. Output only the file content, no explanations.', prompt: memoryPrompt }),
|
||||
]);
|
||||
|
||||
if (!soulContent.trim() || !userContent.trim()) {
|
||||
throw new Error('AI returned empty content');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[onboarding-processor] AI generation failed, using raw answers:', e);
|
||||
soulContent = `# Soul\n\n## Communication Style\n- Address user as: ${answers.q1 || 'not specified'}\n- Assistant name: ${answers.q2 || 'not specified'}\n- Style: ${answers.q3 || 'not specified'}\n- Approach: ${answers.q4 || 'not specified'}\n`;
|
||||
userContent = `# User Profile\n\n## Preferences\n- Boundaries: ${answers.q5 || 'not specified'}\n- Goals: ${answers.q6 || 'not specified'}\n- Output format: ${answers.q7 || 'not specified'}\n- Memory allowed: ${answers.q8 || 'not specified'}\n- Memory forbidden: ${answers.q9 || 'not specified'}\n- Project entry: ${answers.q10 || 'not specified'}\n- Organization: ${answers.q11 || 'not specified'}\n- Default capture: ${answers.q12 || 'not specified'}\n- Archive policy: ${answers.q13 || 'not specified'}\n`;
|
||||
claudeContent = `# Rules\n\n## Execution\n- On project entry: ${answers.q10 || 'not specified'}\n\n## Boundaries\n- ${answers.q5 || 'not specified'}\n\n## Memory\n- Allowed: ${answers.q8 || 'not specified'}\n- Forbidden: ${answers.q9 || 'not specified'}\n`;
|
||||
memoryContent = `# Memory\n\n## User Goals\n- ${answers.q6 || 'not specified'}\n`;
|
||||
}
|
||||
|
||||
// Write all core files
|
||||
fs.writeFileSync(path.join(workspacePath, 'soul.md'), soulContent, 'utf-8');
|
||||
fs.writeFileSync(path.join(workspacePath, 'user.md'), userContent, 'utf-8');
|
||||
if (claudeContent.trim()) {
|
||||
fs.writeFileSync(path.join(workspacePath, 'claude.md'), claudeContent, 'utf-8');
|
||||
}
|
||||
if (memoryContent.trim()) {
|
||||
fs.writeFileSync(path.join(workspacePath, 'memory.md'), memoryContent, 'utf-8');
|
||||
}
|
||||
|
||||
// Ensure V2 directories
|
||||
ensureDailyDir(workspacePath);
|
||||
const inboxDir = path.join(workspacePath, 'Inbox');
|
||||
if (!fs.existsSync(inboxDir)) {
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate config.json from answers
|
||||
try {
|
||||
const { loadConfig, saveConfig } = await import('@/lib/workspace-config');
|
||||
const config = loadConfig(workspacePath);
|
||||
|
||||
const orgStyle = (answers.q11 || '').toLowerCase();
|
||||
if (orgStyle.includes('project')) config.organizationStyle = 'project';
|
||||
else if (orgStyle.includes('time')) config.organizationStyle = 'time';
|
||||
else if (orgStyle.includes('topic')) config.organizationStyle = 'topic';
|
||||
else config.organizationStyle = 'mixed';
|
||||
|
||||
if (answers.q12) {
|
||||
let capture = answers.q12.trim();
|
||||
if (path.isAbsolute(capture) || capture.startsWith('~') || capture.includes('..')) {
|
||||
capture = 'Inbox';
|
||||
}
|
||||
config.captureDefault = capture;
|
||||
}
|
||||
|
||||
saveConfig(workspacePath, config);
|
||||
} catch {
|
||||
// config module not available, skip
|
||||
}
|
||||
|
||||
// Generate taxonomy from existing directories
|
||||
try {
|
||||
const { loadTaxonomy, saveTaxonomy, inferTaxonomyFromDirs } = await import('@/lib/workspace-taxonomy');
|
||||
const taxonomy = loadTaxonomy(workspacePath);
|
||||
if (taxonomy.categories.length === 0) {
|
||||
const inferred = inferTaxonomyFromDirs(workspacePath);
|
||||
if (inferred.length > 0) {
|
||||
taxonomy.categories = inferred;
|
||||
saveTaxonomy(workspacePath, taxonomy);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// taxonomy module not available, skip
|
||||
}
|
||||
|
||||
// Generate root docs
|
||||
generateRootDocs(workspacePath);
|
||||
|
||||
// Update state
|
||||
const today = getLocalDateString();
|
||||
const state = loadState(workspacePath);
|
||||
state.onboardingComplete = true;
|
||||
state.lastCheckInDate = today;
|
||||
state.schemaVersion = 3;
|
||||
saveState(workspacePath, state);
|
||||
}
|
||||
@@ -485,11 +485,9 @@ function buildResolution(
|
||||
// Has credentials?
|
||||
const hasCredentials = !!(provider.api_key) || authStyle === 'env_only';
|
||||
|
||||
// Settings sources — skip 'user' when a provider is configured to avoid
|
||||
// ~/.claude/settings.json env overrides conflicting with provider config
|
||||
const settingSources = hasCredentials
|
||||
? ['project', 'local']
|
||||
: ['user', 'project', 'local'];
|
||||
// Settings sources — always include 'user' so SDK can load skills from
|
||||
// ~/.claude/skills/. Env override conflicts are handled by envOverrides.
|
||||
const settingSources = ['user', 'project', 'local'];
|
||||
|
||||
return {
|
||||
provider,
|
||||
|
||||
@@ -64,6 +64,10 @@ export interface StartStreamParams {
|
||||
effort?: string;
|
||||
/** SDK thinking config */
|
||||
thinking?: { type: string; budgetTokens?: number };
|
||||
/** Called when init status event provides metadata (tools, slash_commands, skills) */
|
||||
onInitMeta?: (meta: { tools?: unknown; slash_commands?: unknown; skills?: unknown }) => void;
|
||||
/** Display-only content for user message (e.g. /skillName instead of expanded prompt) */
|
||||
displayOverride?: string;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
@@ -236,6 +240,7 @@ async function runStream(stream: ActiveStream, params: StartStreamParams): Promi
|
||||
...(params.autoTrigger ? { autoTrigger: true } : {}),
|
||||
...(params.effort ? { effort: params.effort } : {}),
|
||||
...(params.thinking ? { thinking: params.thinking } : {}),
|
||||
...(params.displayOverride ? { displayOverride: params.displayOverride } : {}),
|
||||
}),
|
||||
signal: stream.abortController.signal,
|
||||
});
|
||||
@@ -344,6 +349,10 @@ async function runStream(stream: ActiveStream, params: StartStreamParams): Promi
|
||||
stream.accumulatedText = acc;
|
||||
emit(stream, 'snapshot-updated');
|
||||
},
|
||||
onInitMeta: (meta) => {
|
||||
markActive();
|
||||
params.onInitMeta?.(meta);
|
||||
},
|
||||
});
|
||||
|
||||
// Stream completed successfully — build final message content
|
||||
|
||||
+7
-1
@@ -51,6 +51,12 @@ export interface FilePreview {
|
||||
line_count: number;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Skill / Command Types
|
||||
// ==========================================
|
||||
|
||||
export type SkillKind = 'agent_skill' | 'slash_command' | 'sdk_command' | 'codepilot_command';
|
||||
|
||||
// ==========================================
|
||||
// Task Types
|
||||
// ==========================================
|
||||
@@ -452,7 +458,7 @@ export interface PluginInfo {
|
||||
}
|
||||
|
||||
export interface MCPServerConfig {
|
||||
command: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
type?: 'stdio' | 'sse' | 'http';
|
||||
|
||||
Reference in new issue
Block a user