Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ current_provider = "deepseek"
max_steps = 100
stream_output = true

[[providers]]
[[providers.deepseek]]
name = "deepseek"
env_api_key = "DEEPSEEK_API_KEY" # 仅存环境变量名
model = "deepseek-chat"
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,13 +175,15 @@ current_provider = "deepseek"
max_steps = 100
stream_output = false

[[providers]]
[[providers.deepseek]]
name = "deepseek"
env_api_key = "DEEPSEEK_API_KEY"
model = "deepseek-chat"
base_url = "https://api.deepseek.com"
```

可通过多个 `[[providers.<name>]]` 段落配置多个 Provider。

MCP 服务器示例:

```toml
Expand Down
10 changes: 10 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@dqbd/tiktoken": "^1.0.22",
"@modelcontextprotocol/sdk": "^1.24.3",
"openai": "^6.10.0",
"react-devtools-core": "^7.0.1",
"toml": "^3.0.0"
}
}
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"module": "src/index.ts",
"private": true,
"dependencies": {
"ignore": "^7.0.5",
"zod": "^4.1.13"
}
}
22 changes: 21 additions & 1 deletion packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ describe('mcp config serialization', () => {
})

const text = await Bun.file(configPath).text()
expect(text).toContain('[[providers.deepseek]]')
expect(text).toContain('[mcp_servers.remote]')
expect(text).toContain('type = "streamable_http"')
expect(text).toContain('url = "https://example.com/mcp"')
Expand All @@ -141,7 +142,7 @@ current_provider = "deepseek"
stream_output = false
max_steps = 42

[[providers]]
[[providers.deepseek]]
name = "deepseek"
env_api_key = "DEEPSEEK_API_KEY"
model = "deepseek-chat"
Expand Down Expand Up @@ -171,4 +172,23 @@ url = "https://legacy.example.com/mcp"
expectSseServer(legacy)
expect(legacy.url).toBe('https://legacy.example.com/mcp')
})

test('loadMemoConfig ignores legacy providers array', async () => {
const home = join(tempBase, 'memo-home-legacy')
process.env.MEMO_HOME = home
await mkdir(home, { recursive: true })
const configText = `
current_provider = "legacy"

[[providers]]
name = "legacy"
env_api_key = "LEGACY_API_KEY"
model = "legacy-model"
`
await Bun.write(join(home, 'config.toml'), configText)

const loaded = await loadMemoConfig()
expect(loaded.needsSetup).toBe(true)
expect(loaded.config.current_provider).not.toBe('legacy')
})
})
68 changes: 56 additions & 12 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export type MemoConfig = {
providers: ProviderConfig[]
}

type ParsedMemoConfig = Omit<Partial<MemoConfig>, 'providers'> & { providers?: unknown }

const DEFAULT_MEMO_HOME = join(homedir(), '.memo')
const DEFAULT_SESSIONS_DIR = 'sessions'
const DEFAULT_MEMORY_FILE = 'memo.md'
Expand All @@ -64,6 +66,29 @@ const DEFAULT_CONFIG: MemoConfig = {
mcp_servers: {},
}

function formatTomlKey(key: string) {
return /^[A-Za-z0-9_-]+$/.test(key) ? key : JSON.stringify(key)
}

function normalizeProviders(input: unknown): ProviderConfig[] {
if (!input || typeof input !== 'object' || Array.isArray(input)) return []

const providers: ProviderConfig[] = []
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
if (!value) continue
const entries = Array.isArray(value) ? value : [value]
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue
const provider = { ...(entry as ProviderConfig) }
if ((typeof provider.name !== 'string' || provider.name.length === 0) && key) {
provider.name = key
}
providers.push(provider)
}
}
return providers
}

function expandHome(path: string) {
if (path.startsWith('~')) {
return join(homedir(), path.slice(1))
Expand All @@ -74,13 +99,23 @@ function expandHome(path: string) {
function serializeConfig(config: MemoConfig) {
const providers = config.providers
.map(
(p) =>
`[[providers]]
name = "${p.name}"
env_api_key = "${p.env_api_key}"
model = "${p.model}"
${p.base_url ? `base_url = "${p.base_url}"\n` : ''}`,
(p) => {
const name = typeof p?.name === 'string' ? p.name : ''
if (!name) return ''
const key = formatTomlKey(name)
const lines = [
`[[providers.${key}]]`,
`name = ${JSON.stringify(name)}`,
`env_api_key = ${JSON.stringify(String(p.env_api_key ?? ''))}`,
`model = ${JSON.stringify(String(p.model ?? ''))}`,
]
if (p.base_url) {
lines.push(`base_url = ${JSON.stringify(String(p.base_url))}`)
}
return lines.join('\n')
},
)
.filter(Boolean)
.join('\n\n')

let mcpSection = ''
Expand Down Expand Up @@ -139,12 +174,13 @@ export async function loadMemoConfig(): Promise<LoadedConfig> {
return { config: DEFAULT_CONFIG, home, configPath, needsSetup: true }
}
const text = await file.text()
const parsed = parse(text) as Partial<MemoConfig>
const parsed = parse(text) as ParsedMemoConfig
const providers = normalizeProviders(parsed.providers)
const merged: MemoConfig = {
current_provider: parsed.current_provider ?? DEFAULT_CONFIG.current_provider,
max_steps: parsed.max_steps ?? DEFAULT_CONFIG.max_steps,
stream_output: parsed.stream_output ?? DEFAULT_CONFIG.stream_output,
providers: parsed.providers ?? [],
providers,
mcp_servers: parsed.mcp_servers ?? {},
}
const needsSetup = !merged.providers.length
Expand Down Expand Up @@ -198,14 +234,22 @@ export function buildSessionPath(baseDir: string, sessionId: string) {
const HH = String(now.getHours()).padStart(2, '0')
const MM = String(now.getMinutes()).padStart(2, '0')
const SS = String(now.getSeconds()).padStart(2, '0')
const cwd = process.cwd()
const safeParts = cwd.split(/[/\\]+/).map((p) => sanitizePathComponent(p))
const truncatedParts = truncatePath(safeParts, 180)
const dirName = truncatedParts.join('-')
const dirName = buildSessionDirName(process.cwd())
const fileName = `${yyyy}-${mm}-${dd}_${HH}${MM}${SS}_${sessionId}.jsonl`
return join(baseDir, dirName, fileName)
}

function buildSessionDirName(cwd: string) {
const safeParts = cwd.split(/[/\\]+/).map((p) => sanitizePathComponent(p))
const truncatedParts = truncatePath(safeParts, 180)
return truncatedParts.join('-') || 'root'
}

/** 获取某个 cwd 对应的 session 日志目录。 */
export function getSessionLogDir(baseDir: string, cwd: string) {
return join(baseDir, buildSessionDirName(cwd))
}

/** 提供一个新的 sessionId,便于外部复用。 */
export function createSessionId() {
return randomUUID()
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from '@memo/core/config/config'
export * from '@memo/core/utils/utils'
export * from '@memo/core/utils/tokenizer'
export * from '@memo/core/runtime/session'
export * from '@memo/core/runtime/suggestions'
40 changes: 25 additions & 15 deletions packages/core/src/runtime/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export async function withDefaultDeps(
tokenCounter: TokenCounter
maxSteps: number
dispose: () => Promise<void>
historyFilePath?: string
}> {
const loaded = await loadMemoConfig()
const config = loaded.config
Expand Down Expand Up @@ -83,6 +84,10 @@ export async function withDefaultDeps(
return basePrompt
}
const streamOutput = options.stream ?? config.stream_output ?? false
const sessionsDir = getSessionsDir(loaded, options)
const historyFilePath = buildSessionPath(sessionsDir, sessionId)
const defaultHistorySink = new JsonlHistorySink(historyFilePath)

return {
tools: combinedTools,
dispose: async () => {
Expand All @@ -91,7 +96,7 @@ export async function withDefaultDeps(
},
callLLM:
deps.callLLM ??
(async (messages, onChunk) => {
(async (messages, onChunk, callOptions) => {
const provider = selectProvider(config, options.providerName)
const apiKey =
process.env[provider.env_api_key] ??
Expand All @@ -107,12 +112,15 @@ export async function withDefaultDeps(
baseURL: provider.base_url,
})
if (streamOutput) {
const stream = await client.chat.completions.create({
model: provider.model,
messages,
temperature: 0.35,
stream: true,
})
const stream = await client.chat.completions.create(
{
model: provider.model,
messages,
temperature: 0.35,
stream: true,
},
{ signal: callOptions?.signal },
)
let content = ''
for await (const part of stream) {
const delta = part.choices?.[0]?.delta?.content
Expand All @@ -123,11 +131,14 @@ export async function withDefaultDeps(
}
return { content, streamed: true }
} else {
const data = await client.chat.completions.create({
model: provider.model,
messages,
temperature: 0.35,
})
const data = await client.chat.completions.create(
{
model: provider.model,
messages,
temperature: 0.35,
},
{ signal: callOptions?.signal },
)
const content = data.choices?.[0]?.message?.content
if (typeof content !== 'string') {
throw new Error('OpenAI-compatible API returned empty content')
Expand All @@ -143,10 +154,9 @@ export async function withDefaultDeps(
}
}),
loadPrompt,
historySinks: deps.historySinks ?? [
new JsonlHistorySink(buildSessionPath(getSessionsDir(loaded, options), sessionId)),
],
historySinks: deps.historySinks ?? [defaultHistorySink],
tokenCounter: deps.tokenCounter ?? createTokenCounter(options.tokenizerModel),
maxSteps: options.maxSteps ?? config.max_steps ?? 100,
historyFilePath: historyFilePath,
}
}
11 changes: 1 addition & 10 deletions packages/core/src/runtime/prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

你是 **MemoAgent**,由 mCell 设计开发,并在 BunJS Runtime 中运行的高级编码代理。你的目标是精确、安全且有帮助地解决用户的编码任务。

你的核心工作方式是 **ReAct 循环**。

# 核心特质 (Personality)

- **简洁明了**:沟通高效,避免废话。
Expand All @@ -17,6 +15,7 @@
- **普通回复**:直接输出文本。
- **使用工具**:当需要执行操作时,只输出一个 **Markdown JSON 代码块**,不要输出任何额外文字。
- **最终回答**:当任务完成或需要回复用户时,直接输出您的回答(支持 Markdown),**不要**再调用工具。
- **硬性格式约束**:调用工具的消息必须是“只含 JSON 代码块、无其它内容”的单独消息;禁止在同一条消息里出现 JSON 工具块与任何文本、提示、思考或解释(包括前后空行/注释/Markdown)。

## 工具调用示例

Expand Down Expand Up @@ -108,11 +107,3 @@
- Add: `{"type": "add", "todos": [{"content": "string", "status": "pending"}]}`
- Update: `{"type": "update", "todos": [{"id": "string", "content": "string", "status": "completed"}]}`
- Remove: `{"type": "remove", "ids": ["string"]}`

# 启动确认

现在,等待用户输入。一旦收到任务:

1. 分析需求。
2. (如果任务复杂) 使用 `todo` 建立计划。
3. 开始工作。
Loading