@pikiloom/kernel 0.1.5 → 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.
@@ -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
+ }
@@ -0,0 +1,130 @@
1
+ import path from 'node:path';
2
+ function splitKey(sessionKey) {
3
+ const i = sessionKey.indexOf(':');
4
+ return i < 0 ? { agent: '', sessionId: sessionKey } : { agent: sessionKey.slice(0, i), sessionId: sessionKey.slice(i + 1) };
5
+ }
6
+ function ts(iso) {
7
+ if (!iso)
8
+ return 0;
9
+ const n = Date.parse(iso);
10
+ return Number.isNaN(n) ? 0 : n;
11
+ }
12
+ function newer(a, b) {
13
+ return ts(b) > ts(a) ? b : a;
14
+ }
15
+ function managedToInfo(agent, rec) {
16
+ return {
17
+ sessionKey: `${agent}:${rec.sessionId}`,
18
+ agent,
19
+ sessionId: rec.sessionId,
20
+ title: rec.title ?? null,
21
+ preview: rec.preview ?? null,
22
+ workdir: rec.workdir ?? null,
23
+ model: rec.model ?? null,
24
+ effort: rec.effort ?? null,
25
+ runState: rec.runState ?? null,
26
+ running: rec.runState === 'running',
27
+ source: 'managed',
28
+ createdAt: rec.createdAt ?? null,
29
+ updatedAt: rec.updatedAt ?? null,
30
+ };
31
+ }
32
+ function nativeToInfo(agent, n) {
33
+ return {
34
+ sessionKey: `${agent}:${n.sessionId}`,
35
+ agent,
36
+ sessionId: n.sessionId,
37
+ title: n.title,
38
+ preview: n.preview,
39
+ workdir: n.cwd,
40
+ model: n.model,
41
+ effort: null,
42
+ runState: n.running ? 'running' : 'completed',
43
+ running: n.running,
44
+ source: 'native',
45
+ createdAt: n.createdAt,
46
+ updatedAt: n.updatedAt,
47
+ };
48
+ }
49
+ export class SessionsManager {
50
+ deps;
51
+ constructor(deps) {
52
+ this.deps = deps;
53
+ }
54
+ async list(opts = {}) {
55
+ const scope = opts.scope ?? 'all';
56
+ const workdir = path.resolve(opts.workdir || this.deps.defaultWorkdir);
57
+ const includeNative = opts.includeNative !== false;
58
+ const drivers = this.deps.drivers();
59
+ const agentIds = opts.agent ? [opts.agent] : [...drivers.keys()];
60
+ const byKey = new Map();
61
+ // Managed sessions (the kernel's own store).
62
+ for (const agent of agentIds) {
63
+ let records = [];
64
+ try {
65
+ records = await this.deps.store.list(agent);
66
+ }
67
+ catch (e) {
68
+ this.deps.log?.(`[sessions] store.list(${agent}) failed: ${e?.message || e}`);
69
+ }
70
+ for (const rec of records) {
71
+ if (scope === 'workspace') {
72
+ if (!rec.workdir || path.resolve(rec.workdir) !== workdir)
73
+ continue;
74
+ }
75
+ byKey.set(`${agent}:${rec.sessionId}`, managedToInfo(agent, rec));
76
+ }
77
+ }
78
+ // Native sessions (the agents' own stores) — inherently per-workdir.
79
+ if (includeNative) {
80
+ for (const agent of agentIds) {
81
+ const driver = drivers.get(agent);
82
+ if (!driver?.listNativeSessions)
83
+ continue;
84
+ let natives = [];
85
+ try {
86
+ natives = await driver.listNativeSessions({ workdir, limit: opts.limit });
87
+ }
88
+ catch (e) {
89
+ this.deps.log?.(`[sessions] ${agent}.listNativeSessions failed: ${e?.message || e}`);
90
+ }
91
+ for (const n of natives) {
92
+ const key = `${agent}:${n.sessionId}`;
93
+ const existing = byKey.get(key);
94
+ if (existing) {
95
+ // Same identity discovered both ways: managed record wins, but adopt the newer
96
+ // timestamp and backfill any fields the managed record never captured.
97
+ existing.updatedAt = newer(existing.updatedAt, n.updatedAt);
98
+ existing.preview = existing.preview ?? n.preview;
99
+ existing.title = existing.title ?? n.title;
100
+ existing.model = existing.model ?? n.model;
101
+ existing.running = existing.running || n.running;
102
+ }
103
+ else {
104
+ byKey.set(key, nativeToInfo(agent, n));
105
+ }
106
+ }
107
+ }
108
+ }
109
+ const out = [...byKey.values()].sort((a, b) => ts(b.updatedAt) - ts(a.updatedAt));
110
+ return typeof opts.limit === 'number' ? out.slice(0, Math.max(0, opts.limit)) : out;
111
+ }
112
+ async search(opts) {
113
+ const q = (opts.query || '').trim().toLowerCase();
114
+ const all = await this.list({ ...opts, limit: undefined });
115
+ const matched = q
116
+ ? all.filter(s => [s.title, s.preview, s.sessionId, s.model, s.agent].some(v => v != null && v.toLowerCase().includes(q)))
117
+ : all;
118
+ return typeof opts.limit === 'number' ? matched.slice(0, Math.max(0, opts.limit)) : matched;
119
+ }
120
+ async get(sessionKey, opts = {}) {
121
+ const { agent, sessionId } = splitKey(sessionKey);
122
+ if (!agent)
123
+ return null;
124
+ const rec = await this.deps.store.get(agent, sessionId).catch(() => null);
125
+ if (rec)
126
+ return managedToInfo(agent, rec);
127
+ const list = await this.list({ agent, workdir: opts.workdir });
128
+ return list.find(s => s.sessionKey === sessionKey) ?? null;
129
+ }
130
+ }
@@ -0,0 +1,44 @@
1
+ import type { LoomPaths, LoomScope } from './paths.js';
2
+ export interface SkillInfo {
3
+ name: string;
4
+ label: string | null;
5
+ description: string | null;
6
+ scope: LoomScope;
7
+ path: string;
8
+ mcpRequires?: string[];
9
+ }
10
+ export interface SkillSearchResult {
11
+ name: string;
12
+ description: string | null;
13
+ source: string;
14
+ author?: string | null;
15
+ homepage?: string | null;
16
+ version?: string | null;
17
+ }
18
+ export interface SkillsManagerOptions {
19
+ paths: LoomPaths;
20
+ /** Agent skills dirs (relative to the scope's agentHome) to symlink to the canonical dir. */
21
+ agentSkillDirs?: string[];
22
+ log?: (msg: string) => void;
23
+ }
24
+ /** Make `linkPath` a symlink to `targetDir` (idempotent; replaces a stale link/dir). */
25
+ export declare function ensureDirSymlink(linkPath: string, targetDir: string): void;
26
+ export declare class SkillsManager {
27
+ private readonly paths;
28
+ private readonly agentSkillDirs;
29
+ private readonly log?;
30
+ constructor(opts: SkillsManagerOptions);
31
+ /** The canonical skills dir for a scope, e.g. <workdir>/.pikiloom/skills or ~/.pikiloom/skills. */
32
+ canonicalDir(scope: LoomScope, workdir?: string): string;
33
+ /** Absolute paths of the per-agent skill dirs that link to the canonical dir for a scope. */
34
+ agentLinkPaths(scope: LoomScope, workdir?: string): string[];
35
+ /** Ensure the canonical dir exists and each agent skills dir symlinks to it. */
36
+ ensureLinks(scope: LoomScope, workdir?: string): void;
37
+ /** List skills. scope 'all' (default) = workspace (if workdir) then global, project wins on name clash. */
38
+ list(opts?: {
39
+ workdir?: string;
40
+ scope?: LoomScope | 'all';
41
+ }): SkillInfo[];
42
+ /** Search installable skills on the npm registry (best-effort; [] on failure). */
43
+ search(query: string, limit?: number): Promise<SkillSearchResult[]>;
44
+ }