picocode-core 0.9.119
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/package.json +33 -0
- package/src/agent-transcript.js +80 -0
- package/src/agent.js +170 -0
- package/src/agents.js +213 -0
- package/src/attachments.js +141 -0
- package/src/boot.js +28 -0
- package/src/catalog-snapshot.json +1 -0
- package/src/catalog.js +86 -0
- package/src/codex-models.js +56 -0
- package/src/commands.js +61 -0
- package/src/compaction.js +82 -0
- package/src/completion.js +21 -0
- package/src/config.js +32 -0
- package/src/context.js +82 -0
- package/src/controller.js +1263 -0
- package/src/conversation-search.js +101 -0
- package/src/deliberation-history.js +65 -0
- package/src/deliberation.js +61 -0
- package/src/derive.js +307 -0
- package/src/events.js +54 -0
- package/src/export.js +16 -0
- package/src/files.js +39 -0
- package/src/format.js +6 -0
- package/src/fuzzy.js +41 -0
- package/src/git.js +156 -0
- package/src/history.js +51 -0
- package/src/init.js +25 -0
- package/src/keys.js +26 -0
- package/src/mcp.js +280 -0
- package/src/memory.js +120 -0
- package/src/models.js +14 -0
- package/src/openai-auth.js +204 -0
- package/src/paths.js +67 -0
- package/src/reversible-edit.js +79 -0
- package/src/rewind.js +84 -0
- package/src/session-index.js +264 -0
- package/src/session-lock.js +27 -0
- package/src/session.js +160 -0
- package/src/shells.js +166 -0
- package/src/skills.js +164 -0
- package/src/steer.js +129 -0
- package/src/system-prompt.js +49 -0
- package/src/terminal-theme.js +49 -0
- package/src/tools/bash.js +184 -0
- package/src/tools/diff.js +18 -0
- package/src/tools/edit.js +95 -0
- package/src/tools/glob.js +43 -0
- package/src/tools/grep.js +59 -0
- package/src/tools/index.js +296 -0
- package/src/tools/read.js +49 -0
- package/src/tools/recorder.js +74 -0
- package/src/tools/web.js +84 -0
- package/src/tools/write.js +48 -0
- package/src/update.js +82 -0
- package/src/user-tools.js +59 -0
- package/src/wakeups.js +40 -0
package/src/fuzzy.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function fuzzyScore(query, path) {
|
|
2
|
+
const q = query.toLowerCase()
|
|
3
|
+
const s = path.toLowerCase()
|
|
4
|
+
if (!q) return 0
|
|
5
|
+
|
|
6
|
+
const substringIndex = s.indexOf(q)
|
|
7
|
+
if (substringIndex !== -1) return 10_000 - substringIndex - s.length / 10_000
|
|
8
|
+
|
|
9
|
+
let best = -1
|
|
10
|
+
for (let start = s.indexOf(q[0]); start !== -1; start = s.indexOf(q[0], start + 1)) {
|
|
11
|
+
let score = 0
|
|
12
|
+
let prev = -1
|
|
13
|
+
let matched = true
|
|
14
|
+
for (const ch of q) {
|
|
15
|
+
const i = s.indexOf(ch, prev === -1 ? start : prev + 1)
|
|
16
|
+
if (i === -1) {
|
|
17
|
+
matched = false
|
|
18
|
+
break
|
|
19
|
+
}
|
|
20
|
+
if (prev !== -1 && i === prev + 1) score += 3
|
|
21
|
+
if (i === 0 || '/.-_'.includes(s[i - 1])) score += 2
|
|
22
|
+
score += 1
|
|
23
|
+
prev = i
|
|
24
|
+
}
|
|
25
|
+
if (matched && score > best) best = score
|
|
26
|
+
}
|
|
27
|
+
if (best < 0) return -1
|
|
28
|
+
const base = s.slice(s.lastIndexOf('/') + 1)
|
|
29
|
+
if (base.startsWith(q)) best += 8
|
|
30
|
+
else if (base.includes(q)) best += 5
|
|
31
|
+
return best - s.length / 100
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function rankFuzzy(items, query, score) {
|
|
35
|
+
if (!query) return items
|
|
36
|
+
return items
|
|
37
|
+
.map((item, index) => ({ item, index, score: score(query, item) }))
|
|
38
|
+
.filter((entry) => entry.score >= 0)
|
|
39
|
+
.sort((a, b) => b.score - a.score || a.index - b.index)
|
|
40
|
+
.map((entry) => entry.item)
|
|
41
|
+
}
|
package/src/git.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { readFileSync, statSync, watch } from 'node:fs'
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const DEBOUNCE_MS = 500
|
|
6
|
+
const POLL_MS = 15000
|
|
7
|
+
const GIT_TIMEOUT_MS = 5000
|
|
8
|
+
|
|
9
|
+
function findGitDir(startDir) {
|
|
10
|
+
let dir = startDir
|
|
11
|
+
while (true) {
|
|
12
|
+
const candidate = join(dir, '.git')
|
|
13
|
+
try {
|
|
14
|
+
const info = statSync(candidate)
|
|
15
|
+
if (info.isDirectory()) return candidate
|
|
16
|
+
const text = readFileSync(candidate, 'utf-8')
|
|
17
|
+
const match = text.match(/^gitdir:\s*(.+?)\s*$/m)
|
|
18
|
+
if (match) return isAbsolute(match[1]) ? match[1] : resolve(dir, match[1])
|
|
19
|
+
} catch {}
|
|
20
|
+
const parent = dirname(dir)
|
|
21
|
+
if (parent === dir) return null
|
|
22
|
+
dir = parent
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readBranch(gitDir) {
|
|
27
|
+
try {
|
|
28
|
+
const head = readFileSync(join(gitDir, 'HEAD'), 'utf-8').trim()
|
|
29
|
+
const ref = head.match(/^ref: refs\/heads\/(.+)$/)
|
|
30
|
+
return ref ? ref[1] : head.slice(0, 7)
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseShortstat(output) {
|
|
37
|
+
const added = output.match(/(\d+) insertion/)
|
|
38
|
+
const removed = output.match(/(\d+) deletion/)
|
|
39
|
+
return {
|
|
40
|
+
added: added ? Number(added[1]) : 0,
|
|
41
|
+
removed: removed ? Number(removed[1]) : 0,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createGitService({ onChange = () => {} } = {}) {
|
|
46
|
+
let enabled = false
|
|
47
|
+
let root = null
|
|
48
|
+
let gitDir = null
|
|
49
|
+
let epoch = 0
|
|
50
|
+
let watcher = null
|
|
51
|
+
let poll = null
|
|
52
|
+
let debounce = null
|
|
53
|
+
let child = null
|
|
54
|
+
let rerun = false
|
|
55
|
+
let current = null
|
|
56
|
+
|
|
57
|
+
function teardown() {
|
|
58
|
+
epoch += 1
|
|
59
|
+
watcher?.close()
|
|
60
|
+
watcher = null
|
|
61
|
+
if (poll) clearInterval(poll)
|
|
62
|
+
poll = null
|
|
63
|
+
if (debounce) clearTimeout(debounce)
|
|
64
|
+
debounce = null
|
|
65
|
+
child?.kill('SIGKILL')
|
|
66
|
+
child = null
|
|
67
|
+
rerun = false
|
|
68
|
+
if (current) {
|
|
69
|
+
current = null
|
|
70
|
+
onChange()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function setup() {
|
|
75
|
+
teardown()
|
|
76
|
+
if (!enabled || !root) return
|
|
77
|
+
gitDir = findGitDir(root)
|
|
78
|
+
if (!gitDir) return
|
|
79
|
+
const started = epoch
|
|
80
|
+
try {
|
|
81
|
+
watcher = watch(gitDir, () => {
|
|
82
|
+
if (epoch === started) refresh()
|
|
83
|
+
})
|
|
84
|
+
watcher.on('error', () => {})
|
|
85
|
+
} catch {
|
|
86
|
+
watcher = null
|
|
87
|
+
}
|
|
88
|
+
poll = setInterval(run, POLL_MS)
|
|
89
|
+
poll.unref?.()
|
|
90
|
+
run()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function refresh() {
|
|
94
|
+
if (!enabled || !gitDir || debounce) return
|
|
95
|
+
debounce = setTimeout(() => {
|
|
96
|
+
debounce = null
|
|
97
|
+
run()
|
|
98
|
+
}, DEBOUNCE_MS)
|
|
99
|
+
debounce.unref?.()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function run() {
|
|
103
|
+
if (!enabled || !gitDir || !root) return
|
|
104
|
+
if (child) {
|
|
105
|
+
rerun = true
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
const started = epoch
|
|
109
|
+
const branch = readBranch(gitDir)
|
|
110
|
+
const proc = spawn('git', ['--no-optional-locks', 'diff', 'HEAD', '--shortstat'], {
|
|
111
|
+
cwd: root,
|
|
112
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
113
|
+
})
|
|
114
|
+
child = proc
|
|
115
|
+
let output = ''
|
|
116
|
+
proc.stdout.on('data', (chunk) => {
|
|
117
|
+
output += chunk
|
|
118
|
+
})
|
|
119
|
+
const timeout = setTimeout(() => proc.kill('SIGKILL'), GIT_TIMEOUT_MS)
|
|
120
|
+
timeout.unref?.()
|
|
121
|
+
let finished = false
|
|
122
|
+
const done = (code) => {
|
|
123
|
+
if (finished) return
|
|
124
|
+
finished = true
|
|
125
|
+
clearTimeout(timeout)
|
|
126
|
+
if (child === proc) child = null
|
|
127
|
+
if (epoch !== started) return
|
|
128
|
+
const stats = code === 0 ? parseShortstat(output) : { added: 0, removed: 0 }
|
|
129
|
+
const next = branch ? { branch, ...stats } : null
|
|
130
|
+
if (JSON.stringify(next) !== JSON.stringify(current)) {
|
|
131
|
+
current = next
|
|
132
|
+
onChange()
|
|
133
|
+
}
|
|
134
|
+
if (rerun) {
|
|
135
|
+
rerun = false
|
|
136
|
+
refresh()
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
proc.on('close', done)
|
|
140
|
+
proc.on('error', () => done(-1))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
status: () => current,
|
|
145
|
+
refresh,
|
|
146
|
+
retarget(nextRoot) {
|
|
147
|
+
root = nextRoot
|
|
148
|
+
setup()
|
|
149
|
+
},
|
|
150
|
+
setEnabled(value) {
|
|
151
|
+
enabled = value === true
|
|
152
|
+
setup()
|
|
153
|
+
},
|
|
154
|
+
dispose: teardown,
|
|
155
|
+
}
|
|
156
|
+
}
|
package/src/history.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { appendFile, readFile, readdir } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { picoHome, projectDir, projectHistoryFile, ensureDir } from './paths.js'
|
|
4
|
+
|
|
5
|
+
const MAX_PROMPTS_PER_SCOPE = 1000
|
|
6
|
+
|
|
7
|
+
export async function appendPrompt(root, text) {
|
|
8
|
+
ensureDir(projectDir(root))
|
|
9
|
+
await appendFile(projectHistoryFile(root), JSON.stringify({ text, at: Date.now() }) + '\n')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function parsePromptLines(text) {
|
|
13
|
+
return text
|
|
14
|
+
.split('\n')
|
|
15
|
+
.map((line) => {
|
|
16
|
+
try {
|
|
17
|
+
return line.trim() ? JSON.parse(line) : null
|
|
18
|
+
} catch {
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function readPrompts(file, scope) {
|
|
26
|
+
try {
|
|
27
|
+
const entries = parsePromptLines(await readFile(file, 'utf-8'))
|
|
28
|
+
return entries
|
|
29
|
+
.filter((e) => typeof e.text === 'string' && e.text.trim())
|
|
30
|
+
.slice(-MAX_PROMPTS_PER_SCOPE)
|
|
31
|
+
.map((e) => ({ text: e.text, at: e.at || 0, scope }))
|
|
32
|
+
} catch {
|
|
33
|
+
return []
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function loadProjectPrompts(root) {
|
|
38
|
+
return readPrompts(projectHistoryFile(root), 'project')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function loadGlobalPrompts() {
|
|
42
|
+
const projectsDir = join(picoHome(), 'projects')
|
|
43
|
+
let projects = []
|
|
44
|
+
try {
|
|
45
|
+
projects = await readdir(projectsDir)
|
|
46
|
+
} catch {}
|
|
47
|
+
const nested = await Promise.all(
|
|
48
|
+
projects.map((p) => readPrompts(join(projectsDir, p, 'history.jsonl'), 'everywhere')),
|
|
49
|
+
)
|
|
50
|
+
return nested.flat().sort((a, b) => b.at - a.at).slice(0, MAX_PROMPTS_PER_SCOPE)
|
|
51
|
+
}
|
package/src/init.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const INIT_PROMPT = `Initialize or improve this repository's AGENTS.md guidance for future pico sessions.
|
|
2
|
+
|
|
3
|
+
Do this discovery yourself as the main agent. Do not call agent_plan, agent_start, agent_list, agent_collect, or delegate any part of this task to another agent.
|
|
4
|
+
|
|
5
|
+
Inspect the repository directly and then create or update its AGENTS.md files. Read enough of the actual project to understand its structure and workflows, including the relevant manifests, documentation, configuration, source layout, tests, scripts, and existing instruction files. Use targeted searches and representative files rather than exhaustively reading every file. If an AGENTS.md already exists, preserve useful human-authored guidance and improve it conservatively instead of replacing it blindly. Check CLAUDE.md files for useful repository guidance when relevant, but write canonical guidance to AGENTS.md.
|
|
6
|
+
|
|
7
|
+
Write instructions that help an agent work correctly in this specific repository. Capture only durable, non-obvious information such as:
|
|
8
|
+
|
|
9
|
+
- the project's purpose and high-level architecture
|
|
10
|
+
- important module boundaries and data flow
|
|
11
|
+
- authoritative build, test, lint, typecheck, and development commands
|
|
12
|
+
- repository-specific conventions and constraints
|
|
13
|
+
- where and how to add common kinds of changes
|
|
14
|
+
- validation expectations and operational gotchas
|
|
15
|
+
|
|
16
|
+
Keep the root AGENTS.md concise: aim for 100-200 lines or roughly 1,000-2,000 tokens, and treat about 3,000 tokens as a hard default ceiling. This is guidance, not generated documentation. Prefer terse sections and bullets. Omit generic programming advice, exhaustive directory listings, dependency inventories, and facts that are obvious from standard files or easy to rediscover. Do not speculate or document claims you have not verified.
|
|
17
|
+
|
|
18
|
+
If materially different subtrees need scoped instructions, put focused AGENTS.md files in those subtrees instead of expanding the root file. Create them only when they reduce total context and contain genuinely local guidance; do not fragment the instructions unnecessarily or duplicate the root file. Remember that a nested file augments the root instructions for work in that subtree.
|
|
19
|
+
|
|
20
|
+
Make the edits in this invocation, then briefly summarize which AGENTS.md files you created or changed and what you verified.`
|
|
21
|
+
|
|
22
|
+
export function initPrompt(args = '') {
|
|
23
|
+
const request = args.trim()
|
|
24
|
+
return request ? `${INIT_PROMPT}\n\nAdditional request from the user:\n${request}` : INIT_PROMPT
|
|
25
|
+
}
|
package/src/keys.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { setKeys } from '@prsm/ai'
|
|
2
|
+
|
|
3
|
+
const PROVIDERS = [
|
|
4
|
+
{ id: 'google', label: 'Gemini', env: ['GEMINI_API_KEY', 'GOOGLE_AI_API_KEY'] },
|
|
5
|
+
{ id: 'anthropic', label: 'Claude', env: ['ANTHROPIC_API_KEY'] },
|
|
6
|
+
{ id: 'openai', label: 'OpenAI', env: ['OPENAI_API_KEY'] },
|
|
7
|
+
{ id: 'xai', label: 'Grok', env: ['XAI_API_KEY'] },
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
export function discoverKeys(env = process.env) {
|
|
11
|
+
const keys = {}
|
|
12
|
+
for (const p of PROVIDERS) {
|
|
13
|
+
const name = p.env.find((n) => env[n])
|
|
14
|
+
if (name) keys[p.id] = env[name]
|
|
15
|
+
}
|
|
16
|
+
return keys
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function applyKeys(keys) {
|
|
20
|
+
setKeys(keys)
|
|
21
|
+
return Object.keys(keys)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function keyHint(providerId) {
|
|
25
|
+
return PROVIDERS.find((p) => p.id === providerId)?.env[0] || `${providerId.toUpperCase()}_API_KEY`
|
|
26
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { connectMCP } from '@prsm/ai'
|
|
3
|
+
import { globalMcpFile, projectMcpFile, projectDir, ensureDir, picoHome } from './paths.js'
|
|
4
|
+
|
|
5
|
+
function tokenize(str) {
|
|
6
|
+
const tokens = []
|
|
7
|
+
const re = /([A-Za-z0-9_-]+=)?"([^"]*)"|([A-Za-z0-9_-]+=)?'([^']*)'|(\S+)/g
|
|
8
|
+
let m
|
|
9
|
+
while ((m = re.exec(str))) {
|
|
10
|
+
if (m[1] !== undefined || m[3] !== undefined) tokens.push((m[1] ?? m[3]) + (m[2] ?? m[4]))
|
|
11
|
+
else tokens.push(m[2] ?? m[4] ?? m[5])
|
|
12
|
+
}
|
|
13
|
+
return tokens
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseCommand(str) {
|
|
17
|
+
const tokens = tokenize(str)
|
|
18
|
+
|
|
19
|
+
const env = {}
|
|
20
|
+
while (tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) {
|
|
21
|
+
const sep = tokens[0].indexOf('=')
|
|
22
|
+
env[tokens[0].slice(0, sep)] = tokens[0].slice(sep + 1)
|
|
23
|
+
tokens.shift()
|
|
24
|
+
}
|
|
25
|
+
if (!tokens.length) throw new Error('empty command')
|
|
26
|
+
return { command: tokens[0], args: tokens.slice(1), env }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseServerSpec(str) {
|
|
30
|
+
const tokens = tokenize(str)
|
|
31
|
+
if (!/^https?:\/\//.test(tokens[0] || '')) return { type: 'stdio', ...parseCommand(str) }
|
|
32
|
+
|
|
33
|
+
const [url, ...rest] = tokens
|
|
34
|
+
const headers = {}
|
|
35
|
+
for (const token of rest) {
|
|
36
|
+
const sep = token.indexOf('=')
|
|
37
|
+
if (sep < 1) throw new Error(`http server spec only takes a url and Header=value pairs, got "${token}"`)
|
|
38
|
+
headers[token.slice(0, sep)] = token.slice(sep + 1)
|
|
39
|
+
}
|
|
40
|
+
return { type: 'http', url, headers }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const REDACTED_HEADER = '••••••••'
|
|
44
|
+
|
|
45
|
+
export function isSensitiveHeader(name) {
|
|
46
|
+
return /^(authorization|proxy-authorization|cookie|set-cookie|x-api-key|api-key|token|secret)$/i.test(name)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function quoteHeaderValue(value) {
|
|
50
|
+
if (!/[\s'"\\]/.test(value)) return value
|
|
51
|
+
if (!value.includes('"')) return `"${value}"`
|
|
52
|
+
if (!value.includes("'")) return `'${value}'`
|
|
53
|
+
throw new Error('header values cannot contain both single and double quotes')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function formatHttpServerSpec(url, headers) {
|
|
57
|
+
const values = Object.entries(headers).map(([name, value]) => `${name}=${quoteHeaderValue(value)}`)
|
|
58
|
+
return [url, ...values].join(' ')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function redactServerSpec(command) {
|
|
62
|
+
let spec
|
|
63
|
+
try {
|
|
64
|
+
spec = parseServerSpec(command)
|
|
65
|
+
} catch {
|
|
66
|
+
return command
|
|
67
|
+
}
|
|
68
|
+
if (spec.type !== 'http') return command
|
|
69
|
+
const headers = Object.fromEntries(Object.entries(spec.headers).map(([name, value]) => [name, isSensitiveHeader(name) ? REDACTED_HEADER : value]))
|
|
70
|
+
return formatHttpServerSpec(spec.url, headers)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readJson(file, fallback) {
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(await readFile(file, 'utf-8'))
|
|
76
|
+
} catch {
|
|
77
|
+
return fallback
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function readRegistry() {
|
|
82
|
+
return readJson(globalMcpFile(), { servers: {} })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function writeRegistry(registry) {
|
|
86
|
+
ensureDir(picoHome())
|
|
87
|
+
await writeFile(globalMcpFile(), JSON.stringify(registry, null, 2) + '\n')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function readProjectConfig(root) {
|
|
91
|
+
const config = await readJson(projectMcpFile(root), {})
|
|
92
|
+
return { disabled: config.disabled || {}, servers: config.servers || {} }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function writeProjectConfig(root, config) {
|
|
96
|
+
ensureDir(projectDir(root))
|
|
97
|
+
await writeFile(projectMcpFile(root), JSON.stringify(config, null, 2) + '\n')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function createMcpTransport(command) {
|
|
101
|
+
const spec = parseServerSpec(command)
|
|
102
|
+
if (spec.type === 'http') {
|
|
103
|
+
const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js')
|
|
104
|
+
const fetch = (url, init) => init?.method === 'GET'
|
|
105
|
+
? Promise.resolve(new Response(null, { status: 405 }))
|
|
106
|
+
: globalThis.fetch(url, init)
|
|
107
|
+
return new StreamableHTTPClientTransport(new URL(spec.url), {
|
|
108
|
+
requestInit: Object.keys(spec.headers).length ? { headers: spec.headers } : undefined,
|
|
109
|
+
fetch,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js')
|
|
113
|
+
return new StdioClientTransport({
|
|
114
|
+
command: spec.command,
|
|
115
|
+
args: spec.args,
|
|
116
|
+
env: { ...process.env, ...spec.env },
|
|
117
|
+
stderr: 'pipe',
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function createMcpRuntime({ root, onChange = () => {} }) {
|
|
122
|
+
const registry = await readRegistry()
|
|
123
|
+
const projectConfig = await readProjectConfig(root)
|
|
124
|
+
const servers = new Map()
|
|
125
|
+
|
|
126
|
+
const register = (name, command, scope) => {
|
|
127
|
+
servers.set(name, {
|
|
128
|
+
name,
|
|
129
|
+
command,
|
|
130
|
+
scope,
|
|
131
|
+
enabled: !projectConfig.disabled[name],
|
|
132
|
+
status: projectConfig.disabled[name] ? 'disabled' : 'idle',
|
|
133
|
+
error: null,
|
|
134
|
+
connection: null,
|
|
135
|
+
transport: null,
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
for (const [name, command] of Object.entries(registry.servers)) register(name, command, 'global')
|
|
139
|
+
for (const [name, command] of Object.entries(projectConfig.servers)) register(name, command, 'project')
|
|
140
|
+
|
|
141
|
+
async function connect(name) {
|
|
142
|
+
const server = servers.get(name)
|
|
143
|
+
if (!server) return
|
|
144
|
+
server.status = 'connecting'
|
|
145
|
+
server.error = null
|
|
146
|
+
onChange()
|
|
147
|
+
try {
|
|
148
|
+
server.transport = await createMcpTransport(server.command)
|
|
149
|
+
server.connection = await connectMCP({
|
|
150
|
+
name,
|
|
151
|
+
transport: () => server.transport,
|
|
152
|
+
})
|
|
153
|
+
server.status = 'connected'
|
|
154
|
+
} catch (err) {
|
|
155
|
+
server.status = 'error'
|
|
156
|
+
server.error = String(err.message || err).slice(0, 300)
|
|
157
|
+
server.connection = null
|
|
158
|
+
server.transport = null
|
|
159
|
+
}
|
|
160
|
+
onChange()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function disconnect(name) {
|
|
164
|
+
const server = servers.get(name)
|
|
165
|
+
if (server?.connection) {
|
|
166
|
+
await server.connection.close().catch(() => {})
|
|
167
|
+
server.connection = null
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
connectAll() {
|
|
173
|
+
const pending = []
|
|
174
|
+
for (const server of servers.values()) {
|
|
175
|
+
if (server.enabled) pending.push(connect(server.name))
|
|
176
|
+
}
|
|
177
|
+
return Promise.allSettled(pending)
|
|
178
|
+
},
|
|
179
|
+
async add(name, command, scope = 'global') {
|
|
180
|
+
if (scope === 'project') {
|
|
181
|
+
const config = await readProjectConfig(root)
|
|
182
|
+
config.servers[name] = command
|
|
183
|
+
await writeProjectConfig(root, config)
|
|
184
|
+
} else {
|
|
185
|
+
const registry = await readRegistry()
|
|
186
|
+
registry.servers[name] = command
|
|
187
|
+
await writeRegistry(registry)
|
|
188
|
+
}
|
|
189
|
+
servers.set(name, { name, command, scope, enabled: true, status: 'idle', error: null, connection: null, transport: null })
|
|
190
|
+
await connect(name)
|
|
191
|
+
},
|
|
192
|
+
async update(name, command) {
|
|
193
|
+
const server = servers.get(name)
|
|
194
|
+
if (!server) return
|
|
195
|
+
if (server.scope === 'project') {
|
|
196
|
+
const config = await readProjectConfig(root)
|
|
197
|
+
config.servers[name] = command
|
|
198
|
+
await writeProjectConfig(root, config)
|
|
199
|
+
} else {
|
|
200
|
+
const registry = await readRegistry()
|
|
201
|
+
registry.servers[name] = command
|
|
202
|
+
await writeRegistry(registry)
|
|
203
|
+
}
|
|
204
|
+
await disconnect(name)
|
|
205
|
+
server.command = command
|
|
206
|
+
server.status = server.enabled ? 'idle' : 'disabled'
|
|
207
|
+
server.error = null
|
|
208
|
+
onChange()
|
|
209
|
+
if (server.enabled) await connect(name)
|
|
210
|
+
},
|
|
211
|
+
async remove(name) {
|
|
212
|
+
const scope = servers.get(name)?.scope
|
|
213
|
+
if (scope === 'project') {
|
|
214
|
+
const config = await readProjectConfig(root)
|
|
215
|
+
delete config.servers[name]
|
|
216
|
+
await writeProjectConfig(root, config)
|
|
217
|
+
} else {
|
|
218
|
+
const registry = await readRegistry()
|
|
219
|
+
delete registry.servers[name]
|
|
220
|
+
await writeRegistry(registry)
|
|
221
|
+
}
|
|
222
|
+
await disconnect(name)
|
|
223
|
+
servers.delete(name)
|
|
224
|
+
onChange()
|
|
225
|
+
},
|
|
226
|
+
async toggle(name) {
|
|
227
|
+
const server = servers.get(name)
|
|
228
|
+
if (!server) return
|
|
229
|
+
const config = await readProjectConfig(root)
|
|
230
|
+
if (server.enabled) {
|
|
231
|
+
// flip the visible state first: a slow-closing server (headless
|
|
232
|
+
// browsers, heavy processes) must not make disable feel dead
|
|
233
|
+
server.enabled = false
|
|
234
|
+
server.status = 'disabled'
|
|
235
|
+
onChange()
|
|
236
|
+
config.disabled[name] = true
|
|
237
|
+
await writeProjectConfig(root, config)
|
|
238
|
+
disconnect(name).catch(() => {})
|
|
239
|
+
} else {
|
|
240
|
+
server.enabled = true
|
|
241
|
+
delete config.disabled[name]
|
|
242
|
+
await writeProjectConfig(root, config)
|
|
243
|
+
await connect(name)
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
reconnect(name) {
|
|
247
|
+
return disconnect(name).then(() => connect(name))
|
|
248
|
+
},
|
|
249
|
+
list() {
|
|
250
|
+
return [...servers.values()].map((s) => ({
|
|
251
|
+
name: s.name,
|
|
252
|
+
command: s.command,
|
|
253
|
+
scope: s.scope,
|
|
254
|
+
enabled: s.enabled,
|
|
255
|
+
status: s.status,
|
|
256
|
+
error: s.error,
|
|
257
|
+
toolCount: s.connection?.tools.length || 0,
|
|
258
|
+
tools: (s.connection?.tools || []).map((t) => ({ name: t.name, description: t.description || '' })),
|
|
259
|
+
}))
|
|
260
|
+
},
|
|
261
|
+
tools() {
|
|
262
|
+
return [...servers.values()]
|
|
263
|
+
.filter((s) => s.connection)
|
|
264
|
+
.flatMap((s) => s.connection.tools)
|
|
265
|
+
},
|
|
266
|
+
closeAll() {
|
|
267
|
+
return Promise.allSettled([...servers.keys()].map(disconnect))
|
|
268
|
+
},
|
|
269
|
+
terminateAll() {
|
|
270
|
+
for (const server of servers.values()) {
|
|
271
|
+
const pid = server.transport?.pid
|
|
272
|
+
if (pid) {
|
|
273
|
+
try {
|
|
274
|
+
process.kill(pid, 'SIGTERM')
|
|
275
|
+
} catch {}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
}
|
package/src/memory.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { readFile, readdir, writeFile, mkdir, unlink, rename, access } from 'node:fs/promises'
|
|
2
|
+
import { basename, dirname, join } from 'node:path'
|
|
3
|
+
import { picoHome, projectDir } from './paths.js'
|
|
4
|
+
import { parseFrontmatter } from './skills.js'
|
|
5
|
+
|
|
6
|
+
export function globalMemoryDir() {
|
|
7
|
+
return join(picoHome(), 'memory')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function projectMemoryDir(root) {
|
|
11
|
+
return join(projectDir(root), 'memory')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function slugify(name) {
|
|
15
|
+
const slug = String(name)
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
18
|
+
.replace(/^-+|-+$/g, '')
|
|
19
|
+
.slice(0, 60)
|
|
20
|
+
if (!slug) throw new Error('memory name must contain letters or numbers')
|
|
21
|
+
return slug
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function scanDir(dir, scope) {
|
|
25
|
+
let names = []
|
|
26
|
+
try {
|
|
27
|
+
names = await readdir(dir)
|
|
28
|
+
} catch {
|
|
29
|
+
return []
|
|
30
|
+
}
|
|
31
|
+
const memories = []
|
|
32
|
+
for (const file of names) {
|
|
33
|
+
if (!file.endsWith('.md')) continue
|
|
34
|
+
const disabled = file.startsWith('_')
|
|
35
|
+
const filename = disabled ? file.slice(1) : file
|
|
36
|
+
try {
|
|
37
|
+
const { meta, body } = parseFrontmatter(await readFile(join(dir, file), 'utf-8'))
|
|
38
|
+
memories.push({
|
|
39
|
+
name: meta.name || filename.slice(0, -3),
|
|
40
|
+
description: meta.description || '',
|
|
41
|
+
scope,
|
|
42
|
+
file: join(dir, file),
|
|
43
|
+
body: body.trim(),
|
|
44
|
+
disabled,
|
|
45
|
+
})
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
return memories
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createMemory(root) {
|
|
52
|
+
const dirFor = (scope) => (scope === 'global' ? globalMemoryDir() : projectMemoryDir(root))
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
async list() {
|
|
56
|
+
const global = await scanDir(globalMemoryDir(), 'global')
|
|
57
|
+
const project = await scanDir(projectMemoryDir(root), 'project')
|
|
58
|
+
return [...project, ...global].sort((a, b) =>
|
|
59
|
+
a.name.localeCompare(b.name) || Number(a.disabled) - Number(b.disabled) || a.scope.localeCompare(b.scope),
|
|
60
|
+
)
|
|
61
|
+
},
|
|
62
|
+
async remember({ name, description, content, scope = 'project' }) {
|
|
63
|
+
if (!['project', 'global'].includes(scope)) throw new Error('scope must be project or global')
|
|
64
|
+
const slug = slugify(name)
|
|
65
|
+
const dir = dirFor(scope)
|
|
66
|
+
await mkdir(dir, { recursive: true })
|
|
67
|
+
const file = join(dir, `${slug}.md`)
|
|
68
|
+
const text = `---\nname: ${slug}\ndescription: ${String(description).replace(/\n/g, ' ')}\n---\n${content}\n`
|
|
69
|
+
await writeFile(file, text, 'utf-8')
|
|
70
|
+
return { name: slug, scope, file }
|
|
71
|
+
},
|
|
72
|
+
async forget(target) {
|
|
73
|
+
const memories = await this.list()
|
|
74
|
+
const memory = typeof target === 'string'
|
|
75
|
+
? memories.find((m) => m.name === target && !m.disabled)
|
|
76
|
+
: memories.find((m) => m.file === target.file)
|
|
77
|
+
if (!memory) throw new Error(`no memory named "${typeof target === 'string' ? target : target.name}"`)
|
|
78
|
+
await unlink(memory.file)
|
|
79
|
+
return { name: memory.name, scope: memory.scope }
|
|
80
|
+
},
|
|
81
|
+
async setDisabled(target, disabled) {
|
|
82
|
+
const memories = await this.list()
|
|
83
|
+
const memory = memories.find((m) => m.file === target.file)
|
|
84
|
+
if (!memory) throw new Error(`no memory named "${target.name}"`)
|
|
85
|
+
if (memory.disabled === disabled) return memory
|
|
86
|
+
const filename = basename(memory.file)
|
|
87
|
+
const destination = join(dirname(memory.file), disabled ? `_${filename}` : filename.slice(1))
|
|
88
|
+
try {
|
|
89
|
+
await access(destination)
|
|
90
|
+
throw new Error(`cannot ${disabled ? 'disable' : 'enable'} "${memory.name}": ${basename(destination)} already exists`)
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (err.code !== 'ENOENT') throw err
|
|
93
|
+
}
|
|
94
|
+
await rename(memory.file, destination)
|
|
95
|
+
return { ...memory, file: destination, disabled }
|
|
96
|
+
},
|
|
97
|
+
async recall(name) {
|
|
98
|
+
const memories = await this.list()
|
|
99
|
+
const active = memories.filter((m) => !m.disabled)
|
|
100
|
+
const memory = active.find((m) => m.name === name)
|
|
101
|
+
if (!memory) {
|
|
102
|
+
const known = active.map((m) => m.name).join(', ') || 'none'
|
|
103
|
+
throw new Error(`no memory named "${name}"; known memories: ${known}`)
|
|
104
|
+
}
|
|
105
|
+
return { name: memory.name, scope: memory.scope, file: memory.file, content: memory.body }
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function memoryIndex(memories, root) {
|
|
111
|
+
const active = memories.filter((m) => !m.disabled)
|
|
112
|
+
if (active.length === 0) {
|
|
113
|
+
return `You have no saved memories yet. This index lists them when you save durable facts with the remember tool; answer questions about your memories from this index alone, without searching the filesystem.`
|
|
114
|
+
}
|
|
115
|
+
const lines = active.map((m) => `- ${m.name} (${m.scope}): ${m.description}`)
|
|
116
|
+
return [
|
|
117
|
+
`Memories you have saved. This index is complete: answer questions about your memories from it directly, load one with the recall tool when its content is relevant, and never search the filesystem for memories. The files live in ${projectMemoryDir(root)} and ${globalMemoryDir()} and can be edited or deleted with ordinary tools when asked to curate them.`,
|
|
118
|
+
...lines,
|
|
119
|
+
].join('\n')
|
|
120
|
+
}
|