@pikiloom/kernel 0.1.4 → 0.2.1
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 +35 -0
- package/dist/contracts/driver.d.ts +15 -0
- package/dist/contracts/ports.d.ts +2 -0
- package/dist/drivers/claude.d.ts +5 -1
- package/dist/drivers/claude.js +21 -0
- package/dist/drivers/codex.d.ts +22 -1
- package/dist/drivers/codex.js +87 -7
- package/dist/drivers/gemini.d.ts +5 -1
- package/dist/drivers/gemini.js +4 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -0
- package/dist/ports/defaults.js +7 -1
- package/dist/runtime/loom.d.ts +10 -0
- package/dist/runtime/loom.js +18 -2
- package/dist/workspace/index.d.ts +5 -0
- package/dist/workspace/index.js +7 -0
- package/dist/workspace/mcp.d.ts +44 -0
- package/dist/workspace/mcp.js +82 -0
- package/dist/workspace/native.d.ts +14 -0
- package/dist/workspace/native.js +340 -0
- package/dist/workspace/paths.d.ts +33 -0
- package/dist/workspace/paths.js +40 -0
- package/dist/workspace/sessions.d.ts +49 -0
- package/dist/workspace/sessions.js +130 -0
- package/dist/workspace/skills.d.ts +44 -0
- package/dist/workspace/skills.js +173 -0
- package/llms.txt +23 -1
- package/package.json +1 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const BUILTIN_RECOMMENDED = [
|
|
2
|
+
{ id: 'filesystem', name: 'Filesystem', description: 'Read/write files under allowed directories.', category: 'core', transport: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem'] }, homepage: 'https://github.com/modelcontextprotocol/servers' },
|
|
3
|
+
{ id: 'github', name: 'GitHub', description: 'GitHub repos, issues, and PRs.', category: 'dev', brand: 'github', transport: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] }, envKeys: ['GITHUB_PERSONAL_ACCESS_TOKEN'], homepage: 'https://github.com/modelcontextprotocol/servers' },
|
|
4
|
+
{ id: 'fetch', name: 'Fetch', description: 'Fetch and convert web pages to markdown.', category: 'web', transport: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-fetch'] }, homepage: 'https://github.com/modelcontextprotocol/servers' },
|
|
5
|
+
{ id: 'git', name: 'Git', description: 'Local git repository operations.', category: 'dev', transport: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-git'] }, homepage: 'https://github.com/modelcontextprotocol/servers' },
|
|
6
|
+
{ id: 'memory', name: 'Memory', description: 'A knowledge-graph memory store.', category: 'core', transport: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-memory'] }, homepage: 'https://github.com/modelcontextprotocol/servers' },
|
|
7
|
+
{ id: 'playwright', name: 'Playwright', description: 'Drive a real browser for automation.', category: 'web', brand: 'playwright', transport: { type: 'stdio', command: 'npx', args: ['-y', '@playwright/mcp@latest'] }, homepage: 'https://github.com/microsoft/playwright-mcp' },
|
|
8
|
+
{ id: 'context7', name: 'Context7', description: 'Up-to-date library docs & code examples.', category: 'docs', transport: { type: 'http', url: 'https://mcp.context7.com/mcp' }, homepage: 'https://context7.com' },
|
|
9
|
+
];
|
|
10
|
+
export class McpRegistry {
|
|
11
|
+
_recommended;
|
|
12
|
+
fetchImpl;
|
|
13
|
+
log;
|
|
14
|
+
constructor(opts = {}) {
|
|
15
|
+
this._recommended = opts.recommended?.length ? opts.recommended : BUILTIN_RECOMMENDED;
|
|
16
|
+
this.fetchImpl = opts.fetchImpl ?? (typeof fetch === 'function' ? fetch : undefined);
|
|
17
|
+
this.log = opts.log;
|
|
18
|
+
}
|
|
19
|
+
/** The curated catalog of well-known servers. */
|
|
20
|
+
recommended() {
|
|
21
|
+
return this._recommended.map(e => ({ ...e }));
|
|
22
|
+
}
|
|
23
|
+
/** Turn a catalog entry into a kernel McpServerSpec ready to hand to a driver/plugin. */
|
|
24
|
+
toServerSpec(entry, env) {
|
|
25
|
+
if (entry.transport.type === 'http') {
|
|
26
|
+
return { name: entry.id, type: 'http', url: entry.transport.url };
|
|
27
|
+
}
|
|
28
|
+
return { name: entry.id, type: 'stdio', command: entry.transport.command, args: entry.transport.args, env };
|
|
29
|
+
}
|
|
30
|
+
/** Search the public MCP registry, falling back to npm. Best-effort; [] on failure. */
|
|
31
|
+
async search(query, limit = 20) {
|
|
32
|
+
const q = (query || '').trim();
|
|
33
|
+
const n = Math.max(1, Math.min(50, limit));
|
|
34
|
+
const fetchImpl = this.fetchImpl;
|
|
35
|
+
if (!fetchImpl)
|
|
36
|
+
return [];
|
|
37
|
+
// 1) Official MCP registry.
|
|
38
|
+
try {
|
|
39
|
+
const url = `https://registry.modelcontextprotocol.io/v0/servers?search=${encodeURIComponent(q)}&limit=${n}`;
|
|
40
|
+
const res = await fetchImpl(url, { headers: { accept: 'application/json' } });
|
|
41
|
+
if (res.ok) {
|
|
42
|
+
const data = await res.json();
|
|
43
|
+
const servers = Array.isArray(data?.servers) ? data.servers : Array.isArray(data?.data) ? data.data : [];
|
|
44
|
+
const mapped = servers.map((s) => ({
|
|
45
|
+
name: String(s?.name ?? s?.id ?? ''),
|
|
46
|
+
description: s?.description ?? null,
|
|
47
|
+
source: 'registry',
|
|
48
|
+
npmPackage: s?.packages?.[0]?.identifier ?? s?.npm ?? null,
|
|
49
|
+
homepage: s?.repository?.url ?? s?.homepage ?? null,
|
|
50
|
+
})).filter(s => s.name);
|
|
51
|
+
if (mapped.length)
|
|
52
|
+
return mapped.slice(0, n);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
this.log?.(`[mcp] registry search failed: ${e?.message || e}`);
|
|
57
|
+
}
|
|
58
|
+
// 2) npm fallback.
|
|
59
|
+
try {
|
|
60
|
+
const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(`mcp server ${q}`.trim())}&size=${n}`;
|
|
61
|
+
const res = await fetchImpl(url, { headers: { accept: 'application/json' } });
|
|
62
|
+
if (!res.ok)
|
|
63
|
+
return [];
|
|
64
|
+
const data = await res.json();
|
|
65
|
+
const objects = Array.isArray(data?.objects) ? data.objects : [];
|
|
66
|
+
return objects.map((o) => {
|
|
67
|
+
const pkg = o?.package ?? {};
|
|
68
|
+
return {
|
|
69
|
+
name: String(pkg.name ?? ''),
|
|
70
|
+
description: pkg.description ?? null,
|
|
71
|
+
source: 'npm',
|
|
72
|
+
npmPackage: pkg.name ?? null,
|
|
73
|
+
homepage: pkg.links?.homepage ?? pkg.links?.npm ?? null,
|
|
74
|
+
};
|
|
75
|
+
}).filter(s => s.name).slice(0, n);
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
this.log?.(`[mcp] npm search failed: ${e?.message || e}`);
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { NativeSessionInfo } from '../contracts/driver.js';
|
|
2
|
+
export type { NativeSessionInfo } from '../contracts/driver.js';
|
|
3
|
+
export interface DiscoverOptions {
|
|
4
|
+
home?: string;
|
|
5
|
+
limit?: number;
|
|
6
|
+
/** A session whose file changed within this window is treated as "running". */
|
|
7
|
+
runningThresholdMs?: number;
|
|
8
|
+
}
|
|
9
|
+
export declare const NATIVE_SESSION_RUNNING_THRESHOLD_MS = 10000;
|
|
10
|
+
/** Claude encodes a workdir into a project dir name by replacing non-alphanumerics with '-'. */
|
|
11
|
+
export declare function encodeClaudeProjectDir(workdir: string): string;
|
|
12
|
+
export declare function discoverClaudeNativeSessions(workdir: string, opts?: DiscoverOptions): NativeSessionInfo[];
|
|
13
|
+
export declare function discoverCodexNativeSessions(workdir: string, opts?: DiscoverOptions): NativeSessionInfo[];
|
|
14
|
+
export declare function discoverGeminiNativeSessions(workdir: string, opts?: DiscoverOptions): NativeSessionInfo[];
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export const NATIVE_SESSION_RUNNING_THRESHOLD_MS = 10_000;
|
|
5
|
+
function homeOf(opts) {
|
|
6
|
+
return opts?.home || os.homedir();
|
|
7
|
+
}
|
|
8
|
+
function cleanTitle(raw, max = 120) {
|
|
9
|
+
if (!raw)
|
|
10
|
+
return null;
|
|
11
|
+
const s = raw.replace(/\s+/g, ' ').trim();
|
|
12
|
+
if (!s)
|
|
13
|
+
return null;
|
|
14
|
+
return s.length <= max ? s : `${s.slice(0, max - 3).trimEnd()}...`;
|
|
15
|
+
}
|
|
16
|
+
function statSafe(p) {
|
|
17
|
+
try {
|
|
18
|
+
return fs.statSync(p);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// ---- Claude: ~/.claude/projects/<encoded-workdir>/<sessionId>.jsonl ----
|
|
25
|
+
/** Claude encodes a workdir into a project dir name by replacing non-alphanumerics with '-'. */
|
|
26
|
+
export function encodeClaudeProjectDir(workdir) {
|
|
27
|
+
return path.resolve(workdir).replace(/[^a-zA-Z0-9]/g, '-');
|
|
28
|
+
}
|
|
29
|
+
/** Extract plain text from a Claude `message.content` (string or block array). */
|
|
30
|
+
function claudeText(content) {
|
|
31
|
+
if (typeof content === 'string')
|
|
32
|
+
return content;
|
|
33
|
+
if (!Array.isArray(content))
|
|
34
|
+
return '';
|
|
35
|
+
const parts = [];
|
|
36
|
+
for (const block of content) {
|
|
37
|
+
if (block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
|
|
38
|
+
parts.push(block.text);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return parts.join(' ');
|
|
42
|
+
}
|
|
43
|
+
function readClaudeHead(filePath, size) {
|
|
44
|
+
let title = null;
|
|
45
|
+
let model = null;
|
|
46
|
+
let lastUser = null;
|
|
47
|
+
let lastAssistant = null;
|
|
48
|
+
let turns = 0;
|
|
49
|
+
let head = '';
|
|
50
|
+
try {
|
|
51
|
+
const fd = fs.openSync(filePath, 'r');
|
|
52
|
+
const buf = Buffer.alloc(Math.min(256 * 1024, Math.max(65536, size)));
|
|
53
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
54
|
+
fs.closeSync(fd);
|
|
55
|
+
head = buf.toString('utf8', 0, n);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return { title: null, model: null, preview: null, turns: 0 };
|
|
59
|
+
}
|
|
60
|
+
for (const line of head.split('\n')) {
|
|
61
|
+
if (!line || line[0] !== '{')
|
|
62
|
+
continue;
|
|
63
|
+
let ev;
|
|
64
|
+
try {
|
|
65
|
+
ev = JSON.parse(line);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (ev.type === 'user' && ev.isMeta !== true) {
|
|
71
|
+
const text = claudeText(ev.message?.content).trim();
|
|
72
|
+
if (text && !text.startsWith('<') && !text.startsWith('[Image:')) {
|
|
73
|
+
if (!title)
|
|
74
|
+
title = cleanTitle(text);
|
|
75
|
+
lastUser = text;
|
|
76
|
+
turns++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else if (ev.type === 'assistant') {
|
|
80
|
+
if (!model && ev.message?.model && ev.message.model !== '<synthetic>')
|
|
81
|
+
model = ev.message.model;
|
|
82
|
+
const text = claudeText(ev.message?.content).trim();
|
|
83
|
+
if (text)
|
|
84
|
+
lastAssistant = text;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const preview = cleanTitle(lastAssistant || lastUser, 200);
|
|
88
|
+
return { title, model, preview, turns };
|
|
89
|
+
}
|
|
90
|
+
export function discoverClaudeNativeSessions(workdir, opts = {}) {
|
|
91
|
+
const home = homeOf(opts);
|
|
92
|
+
const projectDir = path.join(home, '.claude', 'projects', encodeClaudeProjectDir(workdir));
|
|
93
|
+
let entries;
|
|
94
|
+
try {
|
|
95
|
+
entries = fs.readdirSync(projectDir, { withFileTypes: true });
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
const threshold = opts.runningThresholdMs ?? NATIVE_SESSION_RUNNING_THRESHOLD_MS;
|
|
101
|
+
const files = [];
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
|
|
104
|
+
continue;
|
|
105
|
+
const filePath = path.join(projectDir, entry.name);
|
|
106
|
+
const stat = statSafe(filePath);
|
|
107
|
+
if (stat)
|
|
108
|
+
files.push({ sessionId: entry.name.slice(0, -6), filePath, stat });
|
|
109
|
+
}
|
|
110
|
+
files.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
|
111
|
+
const selected = typeof opts.limit === 'number' ? files.slice(0, Math.max(0, opts.limit)) : files;
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
return selected.map(({ sessionId, filePath, stat }) => {
|
|
114
|
+
const { title, model, preview, turns } = readClaudeHead(filePath, stat.size);
|
|
115
|
+
return {
|
|
116
|
+
sessionId,
|
|
117
|
+
title,
|
|
118
|
+
preview,
|
|
119
|
+
cwd: path.resolve(workdir),
|
|
120
|
+
model,
|
|
121
|
+
createdAt: stat.birthtime.toISOString(),
|
|
122
|
+
updatedAt: stat.mtime.toISOString(),
|
|
123
|
+
running: now - stat.mtimeMs < threshold,
|
|
124
|
+
messageCount: turns || null,
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
// ---- Codex: ~/.codex/sessions/**/rollout-*.jsonl (filtered by cwd) ----
|
|
129
|
+
function loadCodexTitleIndex(home) {
|
|
130
|
+
const indexPath = path.join(home, '.codex', 'session_index.jsonl');
|
|
131
|
+
const map = new Map();
|
|
132
|
+
let data;
|
|
133
|
+
try {
|
|
134
|
+
data = fs.readFileSync(indexPath, 'utf8');
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return map;
|
|
138
|
+
}
|
|
139
|
+
for (const line of data.split('\n')) {
|
|
140
|
+
if (!line.trim())
|
|
141
|
+
continue;
|
|
142
|
+
try {
|
|
143
|
+
const entry = JSON.parse(line);
|
|
144
|
+
if (entry.id)
|
|
145
|
+
map.set(entry.id, { threadName: entry.thread_name || '', updatedAt: entry.updated_at || '' });
|
|
146
|
+
}
|
|
147
|
+
catch { /* skip */ }
|
|
148
|
+
}
|
|
149
|
+
return map;
|
|
150
|
+
}
|
|
151
|
+
function readCodexHead(filePath) {
|
|
152
|
+
try {
|
|
153
|
+
const fd = fs.openSync(filePath, 'r');
|
|
154
|
+
const buf = Buffer.alloc(8 * 1024);
|
|
155
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
156
|
+
fs.closeSync(fd);
|
|
157
|
+
const head = buf.toString('utf8', 0, n);
|
|
158
|
+
if (!head.includes('"session_meta"'))
|
|
159
|
+
return null;
|
|
160
|
+
const id = head.match(/"id"\s*:\s*"([^"]+)"/);
|
|
161
|
+
const cwd = head.match(/"cwd"\s*:\s*"([^"]+)"/);
|
|
162
|
+
const ts = head.match(/"timestamp"\s*:\s*"([^"]+)"/);
|
|
163
|
+
if (!id || !cwd)
|
|
164
|
+
return null;
|
|
165
|
+
return {
|
|
166
|
+
sessionId: id[1],
|
|
167
|
+
cwd: cwd[1],
|
|
168
|
+
timestamp: ts?.[1] || null,
|
|
169
|
+
isSubagent: /"source"\s*:\s*\{\s*"subagent"\s*:/.test(head) || /"thread_spawn"\s*:/.test(head),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export function discoverCodexNativeSessions(workdir, opts = {}) {
|
|
177
|
+
const home = homeOf(opts);
|
|
178
|
+
const sessionsDir = path.join(home, '.codex', 'sessions');
|
|
179
|
+
const resolvedWorkdir = path.resolve(workdir);
|
|
180
|
+
const titleIndex = loadCodexTitleIndex(home);
|
|
181
|
+
const threshold = opts.runningThresholdMs ?? NATIVE_SESSION_RUNNING_THRESHOLD_MS;
|
|
182
|
+
const files = [];
|
|
183
|
+
const walk = (dir) => {
|
|
184
|
+
let entries;
|
|
185
|
+
try {
|
|
186
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
for (const entry of entries) {
|
|
192
|
+
const full = path.join(dir, entry.name);
|
|
193
|
+
if (entry.isDirectory()) {
|
|
194
|
+
walk(full);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (!entry.name.startsWith('rollout-') || !entry.name.endsWith('.jsonl'))
|
|
198
|
+
continue;
|
|
199
|
+
const stat = statSafe(full);
|
|
200
|
+
if (stat)
|
|
201
|
+
files.push({ filePath: full, stat });
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
walk(sessionsDir);
|
|
205
|
+
files.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
|
206
|
+
const out = [];
|
|
207
|
+
const seen = new Set();
|
|
208
|
+
for (const { filePath, stat } of files) {
|
|
209
|
+
const meta = readCodexHead(filePath);
|
|
210
|
+
if (!meta || meta.isSubagent || path.resolve(meta.cwd) !== resolvedWorkdir)
|
|
211
|
+
continue;
|
|
212
|
+
if (seen.has(meta.sessionId))
|
|
213
|
+
continue;
|
|
214
|
+
seen.add(meta.sessionId);
|
|
215
|
+
const idx = titleIndex.get(meta.sessionId);
|
|
216
|
+
const updatedAt = idx?.updatedAt || stat.mtime.toISOString();
|
|
217
|
+
out.push({
|
|
218
|
+
sessionId: meta.sessionId,
|
|
219
|
+
title: cleanTitle(idx?.threadName || null),
|
|
220
|
+
preview: null,
|
|
221
|
+
cwd: meta.cwd,
|
|
222
|
+
model: null,
|
|
223
|
+
createdAt: meta.timestamp || stat.birthtime.toISOString(),
|
|
224
|
+
updatedAt,
|
|
225
|
+
running: Date.now() - Date.parse(updatedAt) < threshold,
|
|
226
|
+
messageCount: null,
|
|
227
|
+
});
|
|
228
|
+
if (typeof opts.limit === 'number' && out.length >= opts.limit)
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
// ---- Gemini: ~/.gemini/projects.json -> tmp/<projectName>/chats/session-*.json[l] ----
|
|
234
|
+
function geminiProjectName(home, workdir) {
|
|
235
|
+
const projectsPath = path.join(home, '.gemini', 'projects.json');
|
|
236
|
+
try {
|
|
237
|
+
const data = JSON.parse(fs.readFileSync(projectsPath, 'utf8'));
|
|
238
|
+
const projects = data?.projects;
|
|
239
|
+
if (!projects || typeof projects !== 'object')
|
|
240
|
+
return null;
|
|
241
|
+
const resolved = path.resolve(workdir);
|
|
242
|
+
if (projects[resolved])
|
|
243
|
+
return projects[resolved];
|
|
244
|
+
for (const [dir, name] of Object.entries(projects)) {
|
|
245
|
+
if (path.resolve(dir) === resolved)
|
|
246
|
+
return name;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch { /* none */ }
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
function geminiText(content) {
|
|
253
|
+
if (typeof content === 'string')
|
|
254
|
+
return content;
|
|
255
|
+
if (Array.isArray(content)) {
|
|
256
|
+
const parts = [];
|
|
257
|
+
for (const block of content) {
|
|
258
|
+
if (typeof block === 'string')
|
|
259
|
+
parts.push(block);
|
|
260
|
+
else if (block && typeof block === 'object' && typeof block.text === 'string')
|
|
261
|
+
parts.push(block.text);
|
|
262
|
+
}
|
|
263
|
+
return parts.join(' ');
|
|
264
|
+
}
|
|
265
|
+
return '';
|
|
266
|
+
}
|
|
267
|
+
export function discoverGeminiNativeSessions(workdir, opts = {}) {
|
|
268
|
+
const home = homeOf(opts);
|
|
269
|
+
const projectName = geminiProjectName(home, workdir);
|
|
270
|
+
if (!projectName)
|
|
271
|
+
return [];
|
|
272
|
+
const chatsDir = path.join(home, '.gemini', 'tmp', projectName, 'chats');
|
|
273
|
+
let entries;
|
|
274
|
+
try {
|
|
275
|
+
entries = fs.readdirSync(chatsDir, { withFileTypes: true });
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
const threshold = opts.runningThresholdMs ?? NATIVE_SESSION_RUNNING_THRESHOLD_MS;
|
|
281
|
+
const byId = new Map();
|
|
282
|
+
for (const entry of entries) {
|
|
283
|
+
if (!entry.isFile() || !entry.name.startsWith('session-'))
|
|
284
|
+
continue;
|
|
285
|
+
if (!entry.name.endsWith('.json') && !entry.name.endsWith('.jsonl'))
|
|
286
|
+
continue;
|
|
287
|
+
const filePath = path.join(chatsDir, entry.name);
|
|
288
|
+
const stat = statSafe(filePath);
|
|
289
|
+
if (!stat)
|
|
290
|
+
continue;
|
|
291
|
+
let data;
|
|
292
|
+
try {
|
|
293
|
+
data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const sessionId = data?.sessionId ? String(data.sessionId) : null;
|
|
299
|
+
if (!sessionId)
|
|
300
|
+
continue;
|
|
301
|
+
const messages = Array.isArray(data.messages) ? data.messages : [];
|
|
302
|
+
let title = null;
|
|
303
|
+
let lastUser = null;
|
|
304
|
+
let lastAssistant = null;
|
|
305
|
+
let turns = 0;
|
|
306
|
+
for (const msg of messages) {
|
|
307
|
+
if (msg?.type === 'user') {
|
|
308
|
+
const t = geminiText(msg.content).trim();
|
|
309
|
+
if (t) {
|
|
310
|
+
if (!title)
|
|
311
|
+
title = cleanTitle(t);
|
|
312
|
+
lastUser = t;
|
|
313
|
+
turns++;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
else if (msg?.type === 'model' || msg?.type === 'assistant' || msg?.type === 'gemini') {
|
|
317
|
+
const t = geminiText(msg.content).trim();
|
|
318
|
+
if (t)
|
|
319
|
+
lastAssistant = t;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const updatedAt = data.lastUpdated || data.startTime || data.createdAt || stat.mtime.toISOString();
|
|
323
|
+
const existing = byId.get(sessionId);
|
|
324
|
+
if (existing && existing.updatedAt && updatedAt && Date.parse(updatedAt) <= Date.parse(existing.updatedAt))
|
|
325
|
+
continue;
|
|
326
|
+
byId.set(sessionId, {
|
|
327
|
+
sessionId,
|
|
328
|
+
title,
|
|
329
|
+
preview: cleanTitle(lastAssistant || lastUser, 200),
|
|
330
|
+
cwd: path.resolve(workdir),
|
|
331
|
+
model: null,
|
|
332
|
+
createdAt: data.startTime || data.createdAt || null,
|
|
333
|
+
updatedAt,
|
|
334
|
+
running: data.lastUpdated ? Date.now() - Date.parse(data.lastUpdated) < threshold : false,
|
|
335
|
+
messageCount: turns || null,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
const out = [...byId.values()].sort((a, b) => Date.parse(b.updatedAt || '') - Date.parse(a.updatedAt || ''));
|
|
339
|
+
return typeof opts.limit === 'number' ? out.slice(0, Math.max(0, opts.limit)) : out;
|
|
340
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type LoomScope = 'global' | 'workspace';
|
|
2
|
+
export interface LoomPaths {
|
|
3
|
+
/** The bare name (no leading dot), e.g. 'pikiloom'. */
|
|
4
|
+
readonly stateDirName: string;
|
|
5
|
+
/** The dotted directory name, e.g. '.pikiloom'. */
|
|
6
|
+
readonly dirName: string;
|
|
7
|
+
readonly home: string;
|
|
8
|
+
/** ~/.<stateDirName> */
|
|
9
|
+
readonly globalRoot: string;
|
|
10
|
+
/** <workdir>/.<stateDirName> */
|
|
11
|
+
workspaceRoot(workdir: string): string;
|
|
12
|
+
/** The root for a scope (workspace requires a workdir). */
|
|
13
|
+
root(scope: LoomScope, workdir?: string): string;
|
|
14
|
+
/** <root>/sessions */
|
|
15
|
+
sessionsDir(scope: LoomScope, workdir?: string): string;
|
|
16
|
+
/** <root>/skills — the canonical skills registry that agent dirs symlink to. */
|
|
17
|
+
skillsDir(scope: LoomScope, workdir?: string): string;
|
|
18
|
+
/** <root>/mcp.json — the unified MCP server config for a scope. */
|
|
19
|
+
mcpConfigPath(scope: LoomScope, workdir?: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* The base under which an agent keeps its own dotfiles for a scope:
|
|
22
|
+
* global -> ~ (so ~/.claude, ~/.agents)
|
|
23
|
+
* workspace -> <workdir> (so <workdir>/.claude, <workdir>/.agents)
|
|
24
|
+
* Used to resolve symlink targets for native agents.
|
|
25
|
+
*/
|
|
26
|
+
agentHome(scope: LoomScope, workdir?: string): string;
|
|
27
|
+
}
|
|
28
|
+
/** Normalize a state dir name to its bare form: strip a leading dot, trim. */
|
|
29
|
+
export declare function normalizeStateDirName(name: string | null | undefined): string;
|
|
30
|
+
export declare function resolveLoomPaths(opts?: {
|
|
31
|
+
stateDirName?: string | null;
|
|
32
|
+
home?: string;
|
|
33
|
+
}): LoomPaths;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/** Normalize a state dir name to its bare form: strip a leading dot, trim. */
|
|
4
|
+
export function normalizeStateDirName(name) {
|
|
5
|
+
const trimmed = (name ?? '').trim().replace(/^\.+/, '');
|
|
6
|
+
return trimmed || 'pikiloom';
|
|
7
|
+
}
|
|
8
|
+
export function resolveLoomPaths(opts = {}) {
|
|
9
|
+
const stateDirName = normalizeStateDirName(opts.stateDirName);
|
|
10
|
+
const dirName = `.${stateDirName}`;
|
|
11
|
+
const home = opts.home || os.homedir();
|
|
12
|
+
const globalRoot = path.join(home, dirName);
|
|
13
|
+
const workspaceRoot = (workdir) => path.join(path.resolve(workdir), dirName);
|
|
14
|
+
const root = (scope, workdir) => {
|
|
15
|
+
if (scope === 'global')
|
|
16
|
+
return globalRoot;
|
|
17
|
+
if (!workdir)
|
|
18
|
+
throw new Error('workspace scope requires a workdir');
|
|
19
|
+
return workspaceRoot(workdir);
|
|
20
|
+
};
|
|
21
|
+
const agentHome = (scope, workdir) => {
|
|
22
|
+
if (scope === 'global')
|
|
23
|
+
return home;
|
|
24
|
+
if (!workdir)
|
|
25
|
+
throw new Error('workspace scope requires a workdir');
|
|
26
|
+
return path.resolve(workdir);
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
stateDirName,
|
|
30
|
+
dirName,
|
|
31
|
+
home,
|
|
32
|
+
globalRoot,
|
|
33
|
+
workspaceRoot,
|
|
34
|
+
root,
|
|
35
|
+
agentHome,
|
|
36
|
+
sessionsDir: (scope, workdir) => path.join(root(scope, workdir), 'sessions'),
|
|
37
|
+
skillsDir: (scope, workdir) => path.join(root(scope, workdir), 'skills'),
|
|
38
|
+
mcpConfigPath: (scope, workdir) => path.join(root(scope, workdir), 'mcp.json'),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { AgentDriver } from '../contracts/driver.js';
|
|
2
|
+
import type { SessionStore } from '../contracts/ports.js';
|
|
3
|
+
import type { LoomScope } from './paths.js';
|
|
4
|
+
export type SessionSource = 'managed' | 'native';
|
|
5
|
+
export interface ManagedSessionInfo {
|
|
6
|
+
sessionKey: string;
|
|
7
|
+
agent: string;
|
|
8
|
+
sessionId: string;
|
|
9
|
+
title: string | null;
|
|
10
|
+
preview: string | null;
|
|
11
|
+
workdir: string | null;
|
|
12
|
+
model: string | null;
|
|
13
|
+
effort: string | null;
|
|
14
|
+
runState: 'running' | 'completed' | 'incomplete' | null;
|
|
15
|
+
running: boolean;
|
|
16
|
+
source: SessionSource;
|
|
17
|
+
createdAt: string | null;
|
|
18
|
+
updatedAt: string | null;
|
|
19
|
+
}
|
|
20
|
+
export interface ListSessionsOptions {
|
|
21
|
+
/**
|
|
22
|
+
* 'workspace' = only sessions for `workdir` (managed-with-matching-cwd + native for the cwd).
|
|
23
|
+
* 'global' = all managed sessions across every workdir, plus native for `workdir`.
|
|
24
|
+
* 'all' = global, the default.
|
|
25
|
+
*/
|
|
26
|
+
scope?: LoomScope | 'all';
|
|
27
|
+
workdir?: string;
|
|
28
|
+
agent?: string;
|
|
29
|
+
includeNative?: boolean;
|
|
30
|
+
limit?: number;
|
|
31
|
+
}
|
|
32
|
+
export interface SearchSessionsOptions extends ListSessionsOptions {
|
|
33
|
+
query: string;
|
|
34
|
+
}
|
|
35
|
+
export interface SessionsManagerDeps {
|
|
36
|
+
store: SessionStore;
|
|
37
|
+
drivers: () => Map<string, AgentDriver>;
|
|
38
|
+
defaultWorkdir: string;
|
|
39
|
+
log?: (msg: string) => void;
|
|
40
|
+
}
|
|
41
|
+
export declare class SessionsManager {
|
|
42
|
+
private readonly deps;
|
|
43
|
+
constructor(deps: SessionsManagerDeps);
|
|
44
|
+
list(opts?: ListSessionsOptions): Promise<ManagedSessionInfo[]>;
|
|
45
|
+
search(opts: SearchSessionsOptions): Promise<ManagedSessionInfo[]>;
|
|
46
|
+
get(sessionKey: string, opts?: {
|
|
47
|
+
workdir?: string;
|
|
48
|
+
}): Promise<ManagedSessionInfo | null>;
|
|
49
|
+
}
|