protoagent 0.0.5 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +99 -19
  2. package/dist/App.js +602 -0
  3. package/dist/agentic-loop.js +492 -525
  4. package/dist/cli.js +39 -0
  5. package/dist/components/CollapsibleBox.js +26 -0
  6. package/dist/components/ConfigDialog.js +40 -0
  7. package/dist/components/ConsolidatedToolMessage.js +41 -0
  8. package/dist/components/FormattedMessage.js +93 -0
  9. package/dist/components/Table.js +275 -0
  10. package/dist/config.js +171 -0
  11. package/dist/mcp.js +170 -0
  12. package/dist/providers.js +137 -0
  13. package/dist/sessions.js +161 -0
  14. package/dist/skills.js +229 -0
  15. package/dist/sub-agent.js +103 -0
  16. package/dist/system-prompt.js +131 -0
  17. package/dist/tools/bash.js +178 -0
  18. package/dist/tools/edit-file.js +65 -171
  19. package/dist/tools/index.js +79 -134
  20. package/dist/tools/list-directory.js +20 -73
  21. package/dist/tools/read-file.js +57 -101
  22. package/dist/tools/search-files.js +74 -162
  23. package/dist/tools/todo.js +57 -140
  24. package/dist/tools/webfetch.js +310 -0
  25. package/dist/tools/write-file.js +44 -135
  26. package/dist/utils/approval.js +69 -0
  27. package/dist/utils/compactor.js +87 -0
  28. package/dist/utils/cost-tracker.js +26 -81
  29. package/dist/utils/format-message.js +26 -0
  30. package/dist/utils/logger.js +101 -307
  31. package/dist/utils/path-validation.js +74 -0
  32. package/package.json +45 -51
  33. package/LICENSE +0 -21
  34. package/dist/config/client.js +0 -315
  35. package/dist/config/commands.js +0 -223
  36. package/dist/config/manager.js +0 -117
  37. package/dist/config/mcp-commands.js +0 -266
  38. package/dist/config/mcp-manager.js +0 -240
  39. package/dist/config/mcp-types.js +0 -28
  40. package/dist/config/providers.js +0 -229
  41. package/dist/config/setup.js +0 -209
  42. package/dist/config/system-prompt.js +0 -397
  43. package/dist/config/types.js +0 -4
  44. package/dist/index.js +0 -229
  45. package/dist/tools/create-directory.js +0 -76
  46. package/dist/tools/directory-operations.js +0 -195
  47. package/dist/tools/file-operations.js +0 -211
  48. package/dist/tools/run-shell-command.js +0 -746
  49. package/dist/tools/search-operations.js +0 -179
  50. package/dist/tools/shell-operations.js +0 -342
  51. package/dist/tools/task-complete.js +0 -26
  52. package/dist/tools/view-directory-tree.js +0 -125
  53. package/dist/tools.js +0 -2
  54. package/dist/utils/conversation-compactor.js +0 -140
  55. package/dist/utils/enhanced-prompt.js +0 -23
  56. package/dist/utils/file-operations-approval.js +0 -373
  57. package/dist/utils/interrupt-handler.js +0 -127
  58. package/dist/utils/user-cancellation.js +0 -34
package/dist/config.js ADDED
@@ -0,0 +1,171 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { useState, useEffect } from 'react';
6
+ import { Box, Text } from 'ink';
7
+ import { Select, TextInput, PasswordInput } from '@inkjs/ui';
8
+ import { SUPPORTED_MODELS, getProvider } from './providers.js';
9
+ const CONFIG_DIR_MODE = 0o700;
10
+ const CONFIG_FILE_MODE = 0o600;
11
+ function hardenPermissions(targetPath, mode) {
12
+ if (process.platform === 'win32')
13
+ return;
14
+ chmodSync(targetPath, mode);
15
+ }
16
+ export function resolveApiKey(config) {
17
+ const directApiKey = config.apiKey?.trim();
18
+ if (directApiKey) {
19
+ return directApiKey;
20
+ }
21
+ const provider = getProvider(config.provider);
22
+ if (!provider?.apiKeyEnvVar) {
23
+ return null;
24
+ }
25
+ const envApiKey = process.env[provider.apiKeyEnvVar]?.trim();
26
+ return envApiKey || null;
27
+ }
28
+ export const getConfigDirectory = () => {
29
+ const homeDir = os.homedir();
30
+ if (process.platform === 'win32') {
31
+ return path.join(homeDir, 'AppData', 'Local', 'protoagent');
32
+ }
33
+ return path.join(homeDir, '.local', 'share', 'protoagent');
34
+ };
35
+ export const getConfigPath = () => {
36
+ return path.join(getConfigDirectory(), 'config.json');
37
+ };
38
+ export const ensureConfigDirectory = () => {
39
+ const dir = getConfigDirectory();
40
+ if (!existsSync(dir)) {
41
+ mkdirSync(dir, { recursive: true, mode: CONFIG_DIR_MODE });
42
+ }
43
+ hardenPermissions(dir, CONFIG_DIR_MODE);
44
+ };
45
+ export const readConfig = () => {
46
+ const configPath = getConfigPath();
47
+ if (existsSync(configPath)) {
48
+ try {
49
+ const content = readFileSync(configPath, 'utf8');
50
+ const raw = JSON.parse(content);
51
+ // Handle legacy format: { provider, model, credentials: { KEY: "..." } }
52
+ let apiKey = raw.apiKey;
53
+ if (!apiKey && raw.credentials && typeof raw.credentials === 'object') {
54
+ const provider = SUPPORTED_MODELS.find((p) => p.id === raw.provider);
55
+ if (provider) {
56
+ apiKey = raw.credentials[provider.apiKeyEnvVar];
57
+ }
58
+ // Fallback: grab the first non-empty value
59
+ if (!apiKey) {
60
+ apiKey = Object.values(raw.credentials).find((v) => typeof v === 'string' && v.length > 0);
61
+ }
62
+ }
63
+ if (!raw.provider || !raw.model) {
64
+ return null;
65
+ }
66
+ return {
67
+ provider: raw.provider,
68
+ model: raw.model,
69
+ apiKey: typeof apiKey === 'string' && apiKey.trim().length > 0 ? apiKey.trim() : undefined,
70
+ };
71
+ }
72
+ catch (error) {
73
+ console.error('Error reading config file:', error);
74
+ return null;
75
+ }
76
+ }
77
+ return null;
78
+ };
79
+ export const writeConfig = (config) => {
80
+ ensureConfigDirectory();
81
+ const configPath = getConfigPath();
82
+ const normalizedConfig = {
83
+ provider: config.provider,
84
+ model: config.model,
85
+ ...(config.apiKey?.trim() ? { apiKey: config.apiKey.trim() } : {}),
86
+ };
87
+ writeFileSync(configPath, JSON.stringify(normalizedConfig, null, 2), { encoding: 'utf8', mode: CONFIG_FILE_MODE });
88
+ hardenPermissions(configPath, CONFIG_FILE_MODE);
89
+ };
90
+ export const InitialLoading = ({ setExistingConfig, setStep }) => {
91
+ useEffect(() => {
92
+ const config = readConfig();
93
+ if (config) {
94
+ setExistingConfig(config);
95
+ setStep(1);
96
+ }
97
+ else {
98
+ setStep(2);
99
+ }
100
+ }, []);
101
+ return _jsx(Text, { children: "Loading configuration..." });
102
+ };
103
+ export const ResetPrompt = ({ existingConfig, setStep, setConfigWritten }) => {
104
+ const [resetInput, setResetInput] = useState('');
105
+ const provider = getProvider(existingConfig.provider);
106
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Existing configuration found:" }), _jsxs(Text, { children: [" Provider: ", provider?.name || existingConfig.provider] }), _jsxs(Text, { children: [" Model: ", existingConfig.model] }), _jsxs(Text, { children: [" API Key: ", '*'.repeat(8)] }), _jsx(Text, { children: " " }), _jsx(Text, { children: "Do you want to reset and configure a new one? (y/n)" }), _jsx(TextInput, { onSubmit: (answer) => {
107
+ if (answer.toLowerCase() === 'y') {
108
+ setStep(2);
109
+ }
110
+ else {
111
+ setConfigWritten(false);
112
+ setStep(4);
113
+ }
114
+ } })] }));
115
+ };
116
+ export const ModelSelection = ({ setSelectedProviderId, setSelectedModelId, setStep, }) => {
117
+ const items = SUPPORTED_MODELS.flatMap((provider) => provider.models.map((model) => ({
118
+ label: `${provider.name} - ${model.name}`,
119
+ value: `${provider.id}:::${model.id}`,
120
+ })));
121
+ const handleSelect = (value) => {
122
+ const [providerId, modelId] = value.split(':::');
123
+ setSelectedProviderId(providerId);
124
+ setSelectedModelId(modelId);
125
+ setStep(3);
126
+ };
127
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Select an AI Model:" }), _jsx(Select, { options: items, onChange: handleSelect })] }));
128
+ };
129
+ export const ApiKeyInput = ({ selectedProviderId, selectedModelId, setStep, setConfigWritten, }) => {
130
+ const [errorMessage, setErrorMessage] = useState('');
131
+ const provider = getProvider(selectedProviderId);
132
+ const handleApiKeySubmit = (value) => {
133
+ if (value.trim().length === 0) {
134
+ setErrorMessage('API key cannot be empty.');
135
+ return;
136
+ }
137
+ const newConfig = {
138
+ provider: selectedProviderId,
139
+ model: selectedModelId,
140
+ apiKey: value.trim(),
141
+ };
142
+ writeConfig(newConfig);
143
+ setConfigWritten(true);
144
+ setStep(4);
145
+ };
146
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Enter API Key for ", provider?.name || selectedProviderId, ":"] }), errorMessage && _jsx(Text, { color: "red", children: errorMessage }), _jsx(PasswordInput, { placeholder: `Enter your ${provider?.apiKeyEnvVar || 'API'} key`, onSubmit: handleApiKeySubmit })] }));
147
+ };
148
+ export const ConfigResult = ({ configWritten }) => {
149
+ return (_jsxs(Box, { flexDirection: "column", children: [configWritten ? (_jsx(Text, { color: "green", children: "Configuration saved successfully!" })) : (_jsx(Text, { color: "yellow", children: "Configuration not changed." })), _jsx(Text, { children: "You can now run ProtoAgent." })] }));
150
+ };
151
+ export const ConfigureComponent = () => {
152
+ const [step, setStep] = useState(0);
153
+ const [existingConfig, setExistingConfig] = useState(null);
154
+ const [selectedProviderId, setSelectedProviderId] = useState('');
155
+ const [selectedModelId, setSelectedModelId] = useState('');
156
+ const [configWritten, setConfigWritten] = useState(false);
157
+ switch (step) {
158
+ case 0:
159
+ return _jsx(InitialLoading, { setExistingConfig: setExistingConfig, setStep: setStep });
160
+ case 1:
161
+ return _jsx(ResetPrompt, { existingConfig: existingConfig, setStep: setStep, setConfigWritten: setConfigWritten });
162
+ case 2:
163
+ return (_jsx(ModelSelection, { setSelectedProviderId: setSelectedProviderId, setSelectedModelId: setSelectedModelId, setStep: setStep }));
164
+ case 3:
165
+ return (_jsx(ApiKeyInput, { selectedProviderId: selectedProviderId, selectedModelId: selectedModelId, setStep: setStep, setConfigWritten: setConfigWritten }));
166
+ case 4:
167
+ return _jsx(ConfigResult, { configWritten: configWritten });
168
+ default:
169
+ return _jsx(Text, { children: "Unknown step." });
170
+ }
171
+ };
package/dist/mcp.js ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * MCP (Model Context Protocol) client.
3
+ *
4
+ * Uses the official @modelcontextprotocol/sdk to connect to MCP servers
5
+ * over both stdio (spawned processes) and HTTP transports.
6
+ *
7
+ * Configuration in `.protoagent/mcp.json`:
8
+ * {
9
+ * "servers": {
10
+ * "my-stdio-server": {
11
+ * "type": "stdio",
12
+ * "command": "npx",
13
+ * "args": ["-y", "@my/mcp-server"],
14
+ * "env": { "API_KEY": "..." }
15
+ * },
16
+ * "my-http-server": {
17
+ * "type": "http",
18
+ * "url": "http://localhost:3000/mcp"
19
+ * }
20
+ * }
21
+ * }
22
+ *
23
+ * Stdio servers are spawned as child processes communicating over stdin/stdout.
24
+ * HTTP servers connect to a running server via HTTP POST/GET with SSE streaming.
25
+ */
26
+ import fs from 'node:fs/promises';
27
+ import path from 'node:path';
28
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
29
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
30
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
31
+ import { logger } from './utils/logger.js';
32
+ import { registerDynamicTool, registerDynamicHandler } from './tools/index.js';
33
+ const connections = new Map();
34
+ /**
35
+ * Create an MCP client connection for a stdio server.
36
+ */
37
+ async function connectStdioServer(serverName, config) {
38
+ const transport = new StdioClientTransport({
39
+ command: config.command,
40
+ args: config.args || [],
41
+ env: config.env || {},
42
+ });
43
+ const client = new Client({
44
+ name: 'protoagent',
45
+ version: '0.0.1',
46
+ }, {
47
+ capabilities: {},
48
+ });
49
+ await client.connect(transport);
50
+ return {
51
+ client,
52
+ serverName,
53
+ transport,
54
+ };
55
+ }
56
+ /**
57
+ * Create an MCP client connection for an HTTP server.
58
+ */
59
+ async function connectHttpServer(serverName, config) {
60
+ const transport = new StreamableHTTPClientTransport(new URL(config.url));
61
+ const client = new Client({
62
+ name: 'protoagent',
63
+ version: '0.0.1',
64
+ }, {
65
+ capabilities: {},
66
+ });
67
+ await client.connect(transport);
68
+ return {
69
+ client,
70
+ serverName,
71
+ transport,
72
+ };
73
+ }
74
+ /**
75
+ * Register all tools from an MCP server into the dynamic tool registry.
76
+ */
77
+ async function registerMcpTools(conn) {
78
+ try {
79
+ const response = await conn.client.listTools();
80
+ const tools = response.tools || [];
81
+ logger.info(`MCP [${conn.serverName}] discovered ${tools.length} tools`);
82
+ for (const tool of tools) {
83
+ const toolName = `mcp_${conn.serverName}_${tool.name}`;
84
+ registerDynamicTool({
85
+ type: 'function',
86
+ function: {
87
+ name: toolName,
88
+ description: `[MCP: ${conn.serverName}] ${tool.description || tool.name}`,
89
+ parameters: tool.inputSchema,
90
+ },
91
+ });
92
+ registerDynamicHandler(toolName, async (args) => {
93
+ const result = await conn.client.callTool({
94
+ name: tool.name,
95
+ arguments: args,
96
+ });
97
+ // MCP tool results are arrays of content blocks
98
+ if (Array.isArray(result.content)) {
99
+ return result.content
100
+ .map((c) => {
101
+ if (c.type === 'text')
102
+ return c.text;
103
+ return JSON.stringify(c);
104
+ })
105
+ .join('\n');
106
+ }
107
+ return JSON.stringify(result);
108
+ });
109
+ }
110
+ }
111
+ catch (err) {
112
+ logger.error(`Failed to register tools for MCP [${conn.serverName}]: ${err}`);
113
+ }
114
+ }
115
+ /**
116
+ * Load MCP configuration and connect to all configured servers.
117
+ * Registers their tools in the dynamic tool registry.
118
+ */
119
+ export async function initializeMcp() {
120
+ const configPath = path.join(process.cwd(), '.protoagent', 'mcp.json');
121
+ let config;
122
+ try {
123
+ const content = await fs.readFile(configPath, 'utf8');
124
+ config = JSON.parse(content);
125
+ }
126
+ catch {
127
+ // No MCP config — that's fine, most projects won't have one
128
+ return;
129
+ }
130
+ if (!config.servers || Object.keys(config.servers).length === 0)
131
+ return;
132
+ logger.info(`Loading MCP servers from ${configPath}`);
133
+ for (const [name, serverConfig] of Object.entries(config.servers)) {
134
+ try {
135
+ let conn;
136
+ if (serverConfig.type === 'stdio') {
137
+ logger.debug(`Connecting to stdio MCP server: ${name}`);
138
+ conn = await connectStdioServer(name, serverConfig);
139
+ }
140
+ else if (serverConfig.type === 'http') {
141
+ logger.debug(`Connecting to HTTP MCP server: ${name} (${serverConfig.url})`);
142
+ conn = await connectHttpServer(name, serverConfig);
143
+ }
144
+ else {
145
+ logger.error(`Unknown MCP server type for "${name}": ${serverConfig.type}`);
146
+ continue;
147
+ }
148
+ connections.set(name, conn);
149
+ await registerMcpTools(conn);
150
+ }
151
+ catch (err) {
152
+ logger.error(`Failed to connect to MCP server "${name}": ${err}`);
153
+ }
154
+ }
155
+ }
156
+ /**
157
+ * Close all MCP connections.
158
+ */
159
+ export async function closeMcp() {
160
+ for (const [name, conn] of connections) {
161
+ try {
162
+ logger.debug(`Closing MCP connection: ${name}`);
163
+ await conn.client.close();
164
+ }
165
+ catch (err) {
166
+ logger.error(`Error closing MCP connection [${name}]: ${err}`);
167
+ }
168
+ }
169
+ connections.clear();
170
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Model and provider definitions.
3
+ *
4
+ * All providers use the OpenAI SDK via compatible endpoints.
5
+ * To add a new provider, add an entry here with its baseURL
6
+ * and API key env var name, and any models it supports.
7
+ */
8
+ export const SUPPORTED_MODELS = [
9
+ {
10
+ id: 'openai',
11
+ name: 'OpenAI',
12
+ apiKeyEnvVar: 'OPENAI_API_KEY',
13
+ models: [
14
+ {
15
+ id: 'gpt-5.2',
16
+ name: 'GPT-5.2',
17
+ contextWindow: 200_000,
18
+ pricingPerMillionInput: 6.00,
19
+ pricingPerMillionOutput: 24.00,
20
+ },
21
+ {
22
+ id: 'gpt-5-mini',
23
+ name: 'GPT-5 Mini',
24
+ contextWindow: 200_000,
25
+ pricingPerMillionInput: 0.15,
26
+ pricingPerMillionOutput: 0.60,
27
+ },
28
+ {
29
+ id: 'gpt-4.1',
30
+ name: 'GPT-4.1',
31
+ contextWindow: 128_000,
32
+ pricingPerMillionInput: 2.50,
33
+ pricingPerMillionOutput: 10.00,
34
+ },
35
+ ],
36
+ },
37
+ {
38
+ id: 'anthropic',
39
+ name: 'Anthropic Claude',
40
+ baseURL: 'https://api.anthropic.com/v1/',
41
+ apiKeyEnvVar: 'ANTHROPIC_API_KEY',
42
+ models: [
43
+ {
44
+ id: 'claude-opus-4-6',
45
+ name: 'Claude Opus 4.6',
46
+ contextWindow: 200_000,
47
+ pricingPerMillionInput: 5.00,
48
+ pricingPerMillionOutput: 25.00,
49
+ },
50
+ {
51
+ id: 'claude-sonnet-4-6',
52
+ name: 'Claude Sonnet 4.6',
53
+ contextWindow: 200_000,
54
+ pricingPerMillionInput: 3.00,
55
+ pricingPerMillionOutput: 15.00,
56
+ },
57
+ {
58
+ id: 'claude-haiku-4-5',
59
+ name: 'Claude Haiku 4.5',
60
+ contextWindow: 200_000,
61
+ pricingPerMillionInput: 1.00,
62
+ pricingPerMillionOutput: 5.00,
63
+ },
64
+ ],
65
+ },
66
+ {
67
+ id: 'google',
68
+ name: 'Google Gemini',
69
+ baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai/',
70
+ apiKeyEnvVar: 'GEMINI_API_KEY',
71
+ models: [
72
+ {
73
+ id: 'gemini-3-flash-preview',
74
+ name: 'Gemini 3 Flash (Preview)',
75
+ contextWindow: 1_000_000,
76
+ pricingPerMillionInput: 0.075,
77
+ pricingPerMillionOutput: 0.30,
78
+ },
79
+ {
80
+ id: 'gemini-3-pro-preview',
81
+ name: 'Gemini 3 Pro (Preview)',
82
+ contextWindow: 1_000_000,
83
+ pricingPerMillionInput: 1.25,
84
+ pricingPerMillionOutput: 10.00,
85
+ },
86
+ {
87
+ id: 'gemini-2.5-flash',
88
+ name: 'Gemini 2.5 Flash',
89
+ contextWindow: 1_000_000,
90
+ pricingPerMillionInput: 0.075,
91
+ pricingPerMillionOutput: 0.30,
92
+ },
93
+ {
94
+ id: 'gemini-2.5-pro',
95
+ name: 'Gemini 2.5 Pro',
96
+ contextWindow: 1_000_000,
97
+ pricingPerMillionInput: 1.25,
98
+ pricingPerMillionOutput: 10.00,
99
+ },
100
+ ],
101
+ },
102
+ {
103
+ id: 'cerebras',
104
+ name: 'Cerebras',
105
+ baseURL: 'https://api.cerebras.ai/v1',
106
+ apiKeyEnvVar: 'CEREBRAS_API_KEY',
107
+ models: [
108
+ {
109
+ id: 'llama-4-scout-17b-16e-instruct',
110
+ name: 'Llama 4 Scout 17B',
111
+ contextWindow: 128_000,
112
+ pricingPerMillionInput: 0.00,
113
+ pricingPerMillionOutput: 0.00,
114
+ },
115
+ ],
116
+ },
117
+ ];
118
+ /** Find a provider by ID. */
119
+ export function getProvider(providerId) {
120
+ return SUPPORTED_MODELS.find((p) => p.id === providerId);
121
+ }
122
+ /** Find a model's details by provider and model ID. */
123
+ export function getModelDetails(providerId, modelId) {
124
+ const provider = getProvider(providerId);
125
+ return provider?.models.find((m) => m.id === modelId);
126
+ }
127
+ /** Get model pricing in per-token format (for cost-tracker). */
128
+ export function getModelPricing(providerId, modelId) {
129
+ const details = getModelDetails(providerId, modelId);
130
+ if (!details)
131
+ return undefined;
132
+ return {
133
+ inputPerToken: details.pricingPerMillionInput / 1_000_000,
134
+ outputPerToken: details.pricingPerMillionOutput / 1_000_000,
135
+ contextWindow: details.contextWindow,
136
+ };
137
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Session persistence — Save and load conversation history.
3
+ *
4
+ * Sessions are stored as JSON files in `~/.local/share/protoagent/sessions/`.
5
+ * Each session has a unique ID, a title, and the full message history.
6
+ *
7
+ * The agent can resume a previous session or start a new one.
8
+ */
9
+ import fs from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import os from 'node:os';
12
+ import crypto from 'node:crypto';
13
+ import { chmodSync } from 'node:fs';
14
+ import { logger } from './utils/logger.js';
15
+ const SESSION_DIR_MODE = 0o700;
16
+ const SESSION_FILE_MODE = 0o600;
17
+ const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
18
+ function hardenPermissions(targetPath, mode) {
19
+ if (process.platform === 'win32')
20
+ return;
21
+ chmodSync(targetPath, mode);
22
+ }
23
+ function assertValidSessionId(id) {
24
+ if (!SESSION_ID_PATTERN.test(id)) {
25
+ throw new Error(`Invalid session ID: ${id}`);
26
+ }
27
+ }
28
+ export function ensureSystemPromptAtTop(messages, systemPrompt) {
29
+ const firstSystemIndex = messages.findIndex((message) => message.role === 'system');
30
+ if (firstSystemIndex === -1) {
31
+ return [{ role: 'system', content: systemPrompt }, ...messages];
32
+ }
33
+ const firstSystemMessage = messages[firstSystemIndex];
34
+ const normalizedSystemMessage = {
35
+ ...firstSystemMessage,
36
+ role: 'system',
37
+ content: systemPrompt,
38
+ };
39
+ return [
40
+ normalizedSystemMessage,
41
+ ...messages.slice(0, firstSystemIndex),
42
+ ...messages.slice(firstSystemIndex + 1),
43
+ ];
44
+ }
45
+ function getSessionsDir() {
46
+ const homeDir = os.homedir();
47
+ if (process.platform === 'win32') {
48
+ return path.join(homeDir, 'AppData', 'Local', 'protoagent', 'sessions');
49
+ }
50
+ return path.join(homeDir, '.local', 'share', 'protoagent', 'sessions');
51
+ }
52
+ async function ensureSessionsDir() {
53
+ const dir = getSessionsDir();
54
+ await fs.mkdir(dir, { recursive: true, mode: SESSION_DIR_MODE });
55
+ hardenPermissions(dir, SESSION_DIR_MODE);
56
+ return dir;
57
+ }
58
+ function sessionPath(id) {
59
+ assertValidSessionId(id);
60
+ return path.join(getSessionsDir(), `${id}.json`);
61
+ }
62
+ /** Create a new session. */
63
+ export function createSession(model, provider) {
64
+ return {
65
+ id: crypto.randomUUID(),
66
+ title: 'New session',
67
+ createdAt: new Date().toISOString(),
68
+ updatedAt: new Date().toISOString(),
69
+ model,
70
+ provider,
71
+ todos: [],
72
+ completionMessages: [],
73
+ };
74
+ }
75
+ /** Save a session to disk. */
76
+ export async function saveSession(session) {
77
+ await ensureSessionsDir();
78
+ session.updatedAt = new Date().toISOString();
79
+ const filePath = sessionPath(session.id);
80
+ await fs.writeFile(filePath, JSON.stringify(session, null, 2), { encoding: 'utf8', mode: SESSION_FILE_MODE });
81
+ hardenPermissions(filePath, SESSION_FILE_MODE);
82
+ logger.debug(`Session saved: ${session.id}`);
83
+ }
84
+ /** Load a session by ID. Returns null if not found. */
85
+ export async function loadSession(id) {
86
+ try {
87
+ const content = await fs.readFile(sessionPath(id), 'utf8');
88
+ const session = JSON.parse(content);
89
+ return {
90
+ id: session.id ?? id,
91
+ title: session.title ?? 'New session',
92
+ createdAt: session.createdAt ?? new Date().toISOString(),
93
+ updatedAt: session.updatedAt ?? new Date().toISOString(),
94
+ model: session.model ?? '',
95
+ provider: session.provider ?? '',
96
+ todos: Array.isArray(session.todos) ? session.todos : [],
97
+ completionMessages: Array.isArray(session.completionMessages) ? session.completionMessages : [],
98
+ };
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ /** List all sessions (sorted by most recently updated). */
105
+ export async function listSessions() {
106
+ const dir = getSessionsDir();
107
+ let entries;
108
+ try {
109
+ entries = await fs.readdir(dir);
110
+ }
111
+ catch {
112
+ return [];
113
+ }
114
+ const summaries = [];
115
+ for (const entry of entries) {
116
+ if (!entry.endsWith('.json'))
117
+ continue;
118
+ try {
119
+ const content = await fs.readFile(path.join(dir, entry), 'utf8');
120
+ const session = JSON.parse(content);
121
+ summaries.push({
122
+ id: session.id,
123
+ title: session.title,
124
+ createdAt: session.createdAt,
125
+ updatedAt: session.updatedAt,
126
+ messageCount: session.completionMessages.length,
127
+ });
128
+ }
129
+ catch {
130
+ // Skip corrupt session files
131
+ }
132
+ }
133
+ // Sort by most recently updated
134
+ summaries.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
135
+ return summaries;
136
+ }
137
+ /** Delete a session. */
138
+ export async function deleteSession(id) {
139
+ try {
140
+ await fs.unlink(sessionPath(id));
141
+ return true;
142
+ }
143
+ catch {
144
+ return false;
145
+ }
146
+ }
147
+ /**
148
+ * Generate a short title for a session from the first user message.
149
+ * Uses the LLM to summarise if the message is long.
150
+ */
151
+ export function generateTitle(messages) {
152
+ const firstUserMsg = messages.find((m) => m.role === 'user');
153
+ if (!firstUserMsg || !('content' in firstUserMsg) || typeof firstUserMsg.content !== 'string') {
154
+ return 'New session';
155
+ }
156
+ const content = firstUserMsg.content;
157
+ // Simple heuristic: take first 60 chars of the first user message
158
+ if (content.length <= 60)
159
+ return content;
160
+ return content.slice(0, 57) + '...';
161
+ }