@lythos/cold-pool 0.9.28 → 0.9.30

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/cold-pool",
3
- "version": "0.9.28",
3
+ "version": "0.9.30",
4
4
  "description": "Cold pool service layer — dedicated resource holder for skill repositories with intent/plan/execute primitives. Single owner of git side-effects; consumed by deck/curator/arena.",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -2,9 +2,12 @@ import { describe, expect, test } from 'bun:test'
2
2
  import { mkdirSync, mkdtempSync, writeFileSync, existsSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
- import { ColdPool, DEFAULT_COLD_POOL_PATH } from './cold-pool'
5
+ import { ColdPool, DEFAULT_COLD_POOL_PATH, buildListPlan, type DirEntry } from './cold-pool'
6
6
  import { parseLocator } from './parse-locator'
7
7
 
8
+ function dir(relPath: string): DirEntry { return { relPath, isDirectory: true } }
9
+ function file(relPath: string): DirEntry { return { relPath, isDirectory: false } }
10
+
8
11
  describe('ColdPool — constructor', () => {
9
12
  test('default path when none given', () => {
10
13
  const pool = new ColdPool()
@@ -100,6 +103,113 @@ describe('ColdPool — fs-backed read accessors', () => {
100
103
  })
101
104
  })
102
105
 
106
+ describe('buildListPlan — pure classification (no IO)', () => {
107
+ const root = '/pool'
108
+
109
+ test('canonical 3-segment: host/owner/repo', () => {
110
+ const entries: DirEntry[] = [
111
+ dir('github.com'),
112
+ dir('github.com/owner'),
113
+ dir('github.com/owner/repo-a'),
114
+ ]
115
+ const plan = buildListPlan(root, entries)
116
+ expect(plan.entries).toEqual([
117
+ { path: '/pool/github.com/owner/repo-a', kind: 'canonical' },
118
+ ])
119
+ })
120
+
121
+ test('skips hidden dirs', () => {
122
+ const entries: DirEntry[] = [
123
+ dir('.git'),
124
+ dir('github.com'),
125
+ dir('github.com/owner'),
126
+ dir('github.com/owner/repo-a'),
127
+ dir('github.com/owner/.DS_Store'),
128
+ ]
129
+ const plan = buildListPlan(root, entries)
130
+ expect(plan.entries).toEqual([
131
+ { path: '/pool/github.com/owner/repo-a', kind: 'canonical' },
132
+ ])
133
+ })
134
+
135
+ test('legacy depth-1: host/SKILL.md (missing owner+repo)', () => {
136
+ const entries: DirEntry[] = [
137
+ dir('legacy-skill'),
138
+ file('legacy-skill/SKILL.md'),
139
+ ]
140
+ const plan = buildListPlan(root, entries)
141
+ expect(plan.entries).toEqual([
142
+ { path: '/pool/legacy-skill', kind: 'legacy-depth1' },
143
+ ])
144
+ })
145
+
146
+ test('legacy depth-2: localhost/name/SKILL.md (missing repo)', () => {
147
+ const entries: DirEntry[] = [
148
+ dir('localhost'),
149
+ dir('localhost/legacy-name'),
150
+ file('localhost/legacy-name/SKILL.md'),
151
+ ]
152
+ const plan = buildListPlan(root, entries)
153
+ expect(plan.entries).toEqual([
154
+ { path: '/pool/localhost/legacy-name', kind: 'legacy-depth2' },
155
+ ])
156
+ })
157
+
158
+ test('mixed canonical + legacy in same pool', () => {
159
+ const entries: DirEntry[] = [
160
+ dir('github.com'),
161
+ dir('github.com/owner'),
162
+ dir('github.com/owner/repo-a'),
163
+ dir('localhost'),
164
+ dir('localhost/old-skill'),
165
+ file('localhost/old-skill/SKILL.md'),
166
+ ]
167
+ const plan = buildListPlan(root, entries)
168
+ expect(plan.entries).toHaveLength(2)
169
+ expect(plan.entries).toContainEqual({ path: '/pool/github.com/owner/repo-a', kind: 'canonical' })
170
+ expect(plan.entries).toContainEqual({ path: '/pool/localhost/old-skill', kind: 'legacy-depth2' })
171
+ })
172
+
173
+ test('multiple repos under same owner', () => {
174
+ const entries: DirEntry[] = [
175
+ dir('github.com'),
176
+ dir('github.com/owner'),
177
+ dir('github.com/owner/repo-a'),
178
+ dir('github.com/owner/repo-b'),
179
+ dir('github.com/owner/repo-c'),
180
+ ]
181
+ const plan = buildListPlan(root, entries)
182
+ expect(plan.entries).toHaveLength(3)
183
+ expect(plan.entries.every(e => e.kind === 'canonical')).toBe(true)
184
+ })
185
+
186
+ test('multiple hosts', () => {
187
+ const entries: DirEntry[] = [
188
+ dir('github.com'),
189
+ dir('github.com/a'),
190
+ dir('github.com/a/r1'),
191
+ dir('gitlab.com'),
192
+ dir('gitlab.com/b'),
193
+ dir('gitlab.com/b/r2'),
194
+ ]
195
+ const plan = buildListPlan(root, entries)
196
+ expect(plan.entries).toHaveLength(2)
197
+ expect(plan.entries.map(e => e.path).sort()).toEqual([
198
+ '/pool/github.com/a/r1',
199
+ '/pool/gitlab.com/b/r2',
200
+ ])
201
+ })
202
+
203
+ test('returns empty when root has no entries', () => {
204
+ expect(buildListPlan(root, []).entries).toEqual([])
205
+ })
206
+
207
+ test('returns empty for hidden-only entries', () => {
208
+ const entries: DirEntry[] = [dir('.git'), dir('.hidden/.nested')]
209
+ expect(buildListPlan(root, entries).entries).toEqual([])
210
+ })
211
+ })
212
+
103
213
  describe('ColdPool.metadata — MetadataDB integration', () => {
104
214
  const root = mkdtempSync(join(tmpdir(), 'cold-pool-meta-test-'))
105
215
  const pool = new ColdPool(root)
package/src/cold-pool.ts CHANGED
@@ -1,20 +1,91 @@
1
- /**
2
- * ColdPool — dedicated resource holder.
3
- *
4
- * Owns the cold-pool path, exposes read-only accessors. Operations
5
- * (fetch, validate, reconcile) are external functions that take a
6
- * ColdPool and return Plan-shaped data, per the intent/plan/execute
7
- * pattern.
8
- */
9
1
  import { existsSync, readdirSync } from 'node:fs'
10
2
  import { homedir } from 'node:os'
11
- import { join } from 'node:path'
3
+ import { join, relative } from 'node:path'
12
4
  import type { Locator } from './types.js'
13
5
  import { MetadataDB } from './metadata-db.js'
14
6
 
15
7
  export const DEFAULT_COLD_POOL_PATH = process.env.LYTHOS_COLD_POOL
16
8
  ?? join(homedir(), '.agents/skill-repos')
17
9
 
10
+ // ── DirEntry — pure, injectable fs entry ─────────────────────────────────────
11
+
12
+ export interface DirEntry {
13
+ /** Path relative to cold-pool root (e.g. "github.com/owner/repo") */
14
+ relPath: string
15
+ isDirectory: boolean
16
+ }
17
+
18
+ // ── List plan: pure classification logic ────────────────────────────────────
19
+
20
+ export interface ListPlanEntry {
21
+ path: string
22
+ kind: 'canonical' | 'legacy-depth2' | 'legacy-depth1'
23
+ }
24
+
25
+ export interface ListPlan {
26
+ entries: ListPlanEntry[]
27
+ }
28
+
29
+ /**
30
+ * Pure: given a cold-pool root path and a flat list of all fs entries
31
+ * (with relPath), classify every terminal repo directory.
32
+ *
33
+ * Canonical: <pool>/<host>/<owner>/<repo> (3 segments, no SKILL.md mid-tree)
34
+ * Legacy: <pool>/<host>/SKILL.md (depth 1 — no owner/repo)
35
+ * <pool>/<host>/<name>/SKILL.md (depth 2 — no repo segment)
36
+ */
37
+ export function buildListPlan(rootPath: string, allEntries: DirEntry[]): ListPlan {
38
+ const plan: ListPlanEntry[] = []
39
+ const dirSet = new Set(allEntries.filter(e => e.isDirectory).map(e => e.relPath))
40
+
41
+ // Determine whether a dir is terminal (no child dirs)
42
+ function isTerminal(relPath: string): boolean {
43
+ const prefix = relPath + '/'
44
+ for (const d of dirSet) {
45
+ if (d.startsWith(prefix) && d !== relPath) return false
46
+ }
47
+ return true
48
+ }
49
+
50
+ // Check whether a dir directly contains SKILL.md
51
+ function hasSkillMd(dirRel: string): boolean {
52
+ return allEntries.some(e => e.relPath === `${dirRel}/SKILL.md` && !e.isDirectory)
53
+ }
54
+
55
+ // Walk only terminal dirs — they are the leaves (repo-level entries)
56
+ for (const d of dirSet) {
57
+ if (d.startsWith('.') || d.split('/').some(s => s.startsWith('.'))) continue
58
+ if (!isTerminal(d)) continue
59
+
60
+ const segments = d.split('/')
61
+
62
+ // Legacy depth-1: <host>/SKILL.md — terminal dir with SKILL.md, depth=1
63
+ if (segments.length === 1 && hasSkillMd(d)) {
64
+ plan.push({ path: join(rootPath, d), kind: 'legacy-depth1' })
65
+ continue
66
+ }
67
+
68
+ // Legacy depth-2: <host>/<name>/SKILL.md — terminal dir with SKILL.md, depth=2
69
+ if (segments.length === 2 && hasSkillMd(d)) {
70
+ plan.push({ path: join(rootPath, d), kind: 'legacy-depth2' })
71
+ continue
72
+ }
73
+
74
+ // Canonical: <host>/<owner>/<repo> — terminal dir at depth 3, no SKILL.md mid-tree
75
+ if (segments.length === 3) {
76
+ // Verify no SKILL.md at intermediate levels (depth 1 or 2)
77
+ const parent = segments.slice(0, 2).join('/')
78
+ if (!hasSkillMd(segments[0]) && !hasSkillMd(parent)) {
79
+ plan.push({ path: join(rootPath, d), kind: 'canonical' })
80
+ }
81
+ }
82
+ }
83
+
84
+ return { entries: plan }
85
+ }
86
+
87
+ // ── ColdPool ─────────────────────────────────────────────────────────────────
88
+
18
89
  export class ColdPool {
19
90
  readonly path: string
20
91
  readonly metadata: MetadataDB
@@ -24,67 +95,38 @@ export class ColdPool {
24
95
  this.metadata = new MetadataDB(join(this.path, '.cold-pool-meta.db'))
25
96
  }
26
97
 
27
- /**
28
- * Compute the cold-pool directory for a locator. No fs check.
29
- *
30
- * Layout invariant: `<pool>/<host>/<owner>/<repo>` for ALL locators
31
- * including localhost. No special-case branching — "directory layers
32
- * = FQ locator segments" (per user 2026-05-07). Skill subpath
33
- * extends within the repo dir (resolveDir returns the repo dir).
34
- */
35
98
  resolveDir(locator: Locator): string {
36
99
  return join(this.path, locator.host, locator.owner, locator.repo)
37
100
  }
38
101
 
39
- /** Whether a locator's repo directory exists in the pool. */
40
102
  has(locator: Locator): boolean {
41
103
  return existsSync(this.resolveDir(locator))
42
104
  }
43
105
 
44
- /**
45
- * Enumerate cold-pool entries.
46
- *
47
- * Uniform layout: `<pool>/<host>/<owner>/<repo>`. localhost is just
48
- * another host. No localhost special-case.
49
- *
50
- * Legacy drift: `<pool>/<x>/SKILL.md` (depth 2 with SKILL.md) or
51
- * `<pool>/localhost/<name>/SKILL.md` (depth 3 with SKILL.md, missing
52
- * owner/repo) are non-canonical state from older agents that bypassed
53
- * FQ-only enforcement. Surface them so prune can offer cleanup.
54
- */
106
+ /** Enumerate cold-pool entries. Delegates classification to pure buildListPlan. */
55
107
  list(): string[] {
56
108
  if (!existsSync(this.path)) return []
57
- const repos: string[] = []
58
-
59
- for (const host of readdirSync(this.path, { withFileTypes: true })) {
60
- if (!host.isDirectory() || host.name.startsWith('.')) continue
61
- const hostPath = join(this.path, host.name)
62
109
 
63
- // Legacy drift: top-level dir with SKILL.md (not canonical 3-segment)
64
- if (existsSync(join(hostPath, 'SKILL.md'))) {
65
- repos.push(hostPath)
66
- continue
110
+ const poolPath = this.path
111
+ const allEntries: DirEntry[] = []
112
+ function collectRecursive(dir: string): void {
113
+ let dirents: ReturnType<typeof readdirSync>
114
+ try {
115
+ dirents = readdirSync(dir, { withFileTypes: true })
116
+ } catch {
117
+ return
67
118
  }
68
-
69
- // Canonical: <host>/<owner>/<repo>
70
- for (const owner of readdirSync(hostPath, { withFileTypes: true })) {
71
- if (!owner.isDirectory() || owner.name.startsWith('.')) continue
72
- const ownerPath = join(hostPath, owner.name)
73
-
74
- // Legacy drift: <host>/<x>/SKILL.md (depth 2 missing repo level —
75
- // typically `localhost/<name>/SKILL.md` from older agents)
76
- if (existsSync(join(ownerPath, 'SKILL.md'))) {
77
- repos.push(ownerPath)
78
- continue
79
- }
80
-
81
- for (const repo of readdirSync(ownerPath, { withFileTypes: true })) {
82
- if (!repo.isDirectory() || repo.name.startsWith('.')) continue
83
- repos.push(join(ownerPath, repo.name))
119
+ for (const d of dirents) {
120
+ const rel = relative(poolPath, join(dir, d.name))
121
+ allEntries.push({ relPath: rel, isDirectory: d.isDirectory() })
122
+ if (d.isDirectory() && !d.name.startsWith('.')) {
123
+ collectRecursive(join(dir, d.name))
84
124
  }
85
125
  }
86
126
  }
127
+ collectRecursive(poolPath)
87
128
 
88
- return repos
129
+ const plan = buildListPlan(poolPath, allEntries)
130
+ return plan.entries.map(e => e.path)
89
131
  }
90
132
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Shared SQLite helpers — lazy-open, schema versioning, DRY wrappers.
3
+ *
4
+ * Used by MetadataDB (cold-pool) and curator's catalog DB.
5
+ * Consumers subclass and call super(dbPath) + define initSchema().
6
+ */
7
+ import { Database } from 'bun:sqlite'
8
+ import { mkdirSync } from 'node:fs'
9
+ import { dirname } from 'node:path'
10
+
11
+ export abstract class SqliteDb {
12
+ protected abstract initSchema(): void
13
+
14
+ private dbPath: string
15
+ private _db: Database | null = null
16
+
17
+ constructor(dbPath: string) {
18
+ this.dbPath = dbPath
19
+ }
20
+
21
+ /** Lazy-open: first DB access triggers creation. */
22
+ protected get db(): Database {
23
+ if (this._db == null) {
24
+ try {
25
+ mkdirSync(dirname(this.dbPath), { recursive: true })
26
+ } catch {
27
+ // Parent dir may be read-only (e.g. test paths).
28
+ }
29
+ this._db = new Database(this.dbPath, { create: true })
30
+ this.initSchema()
31
+ }
32
+ return this._db
33
+ }
34
+
35
+ protected exec(sql: string, params?: Record<string, unknown>): void {
36
+ const stmt = this.db.query(sql)
37
+ stmt.run(params ?? {})
38
+ stmt.finalize()
39
+ }
40
+
41
+ protected queryOne<T>(sql: string, params?: Record<string, unknown>): T | null {
42
+ return this.db.query(sql).get(params ?? {}) as T | null
43
+ }
44
+
45
+ protected queryAll<T>(sql: string, params?: Record<string, unknown>): T[] {
46
+ return this.db.query(sql).all(params ?? {}) as T[]
47
+ }
48
+
49
+ /** Schema version tracking: call from initSchema() to auto-migrate. */
50
+ protected migrateSchema(currentVersion: number, migrations: Array<{ version: number; sql: string }>): void {
51
+ this.exec(`CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER NOT NULL)`)
52
+ let row = this.queryOne<{ version: number }>(`SELECT version FROM _schema_version`)
53
+ let dbVersion = row?.version ?? 0
54
+
55
+ for (const m of migrations.sort((a, b) => a.version - b.version)) {
56
+ if (m.version > dbVersion) {
57
+ try {
58
+ this.db.query(m.sql).run()
59
+ } catch {
60
+ // Migration may fail if column already exists — fine for idempotent ADD COLUMN.
61
+ }
62
+ dbVersion = m.version
63
+ }
64
+ }
65
+
66
+ if (dbVersion !== (row?.version ?? 0)) {
67
+ this.exec(`DELETE FROM _schema_version`)
68
+ this.exec(`INSERT INTO _schema_version (version) VALUES ($v)`, { $v: dbVersion })
69
+ }
70
+ }
71
+
72
+ close(): void {
73
+ this._db?.close()
74
+ this._db = null
75
+ }
76
+ }
@@ -11,6 +11,9 @@ beforeAll(async () => {
11
11
  repoDir = mkdtempSync(join(tmpdir(), 'lythos-git-hash-test-'))
12
12
  const git = simpleGit(repoDir)
13
13
  await git.init(['--initial-branch=main'])
14
+ // CI runners often lack git identity; set local config so commit works.
15
+ await git.addConfig('user.name', 'test')
16
+ await git.addConfig('user.email', 'test@test.com')
14
17
  writeFileSync(join(repoDir, 'SKILL.md'), '# Test Skill\n')
15
18
  mkdirSync(join(repoDir, 'skills', 'pdf'), { recursive: true })
16
19
  writeFileSync(join(repoDir, 'skills', 'pdf', 'SKILL.md'), '# PDF Skill\n> renders PDF\n')
package/src/index.ts CHANGED
@@ -16,8 +16,10 @@ export type {
16
16
  } from './types.js'
17
17
 
18
18
  export { parseLocator, formatLocator } from './parse-locator.js'
19
- export { ColdPool, DEFAULT_COLD_POOL_PATH } from './cold-pool.js'
19
+ export type { DirEntry, ListPlan, ListPlanEntry } from './cold-pool.js'
20
+ export { ColdPool, DEFAULT_COLD_POOL_PATH, buildListPlan } from './cold-pool.js'
20
21
 
22
+ export { SqliteDb } from './db-helpers.js'
21
23
  export type { RepoRef, SkillHash, DeckReference } from './metadata-db.js'
22
24
  export { MetadataDB } from './metadata-db.js'
23
25
 
@@ -2,6 +2,7 @@
2
2
  * Cold-pool metadata layer — SQLite-backed.
3
3
  *
4
4
  * Per ADR-20260507143241493: git-native hash, local-only trust, SQLite storage.
5
+ * Extends shared SqliteDb base for DRY SQLite wrappers + schema versioning.
5
6
  *
6
7
  * Three tables:
7
8
  * repos — per-repo HEAD ref tracking
@@ -9,9 +10,7 @@
9
10
  * deck_refs — cross-deck reference index
10
11
  */
11
12
 
12
- import { Database } from 'bun:sqlite'
13
- import { mkdirSync } from 'node:fs'
14
- import { dirname } from 'node:path'
13
+ import { SqliteDb } from './db-helpers.js'
15
14
 
16
15
  export interface RepoRef {
17
16
  host: string
@@ -37,32 +36,14 @@ export interface DeckReference {
37
36
  declaredAlias: string | null
38
37
  }
39
38
 
40
- export class MetadataDB {
41
- private dbPath: string
42
- private _db: Database | null = null
39
+ const CURRENT_SCHEMA = 2
43
40
 
41
+ export class MetadataDB extends SqliteDb {
44
42
  constructor(dbPath: string) {
45
- this.dbPath = dbPath
43
+ super(dbPath)
46
44
  }
47
45
 
48
- /** Lazy-open: first DB access triggers creation. Allows ColdPool
49
- * to be instantiated with read-only / non-existent paths (e.g. test
50
- * fixtures) without failing on construction. */
51
- private get db(): Database {
52
- if (this._db == null) {
53
- try {
54
- mkdirSync(dirname(this.dbPath), { recursive: true })
55
- } catch {
56
- // Parent dir may be read-only (e.g. test paths like /cold).
57
- // SQLite { create: true } will fail below with a clearer error.
58
- }
59
- this._db = new Database(this.dbPath, { create: true })
60
- this.initSchema()
61
- }
62
- return this._db
63
- }
64
-
65
- private initSchema(): void {
46
+ protected initSchema(): void {
66
47
  this.exec(`
67
48
  CREATE TABLE IF NOT EXISTS repos (
68
49
  host TEXT NOT NULL,
@@ -100,25 +81,15 @@ export class MetadataDB {
100
81
  this.exec(`CREATE INDEX IF NOT EXISTS idx_deck_refs_deck ON deck_refs(deck_path)`)
101
82
  this.exec(`CREATE INDEX IF NOT EXISTS idx_deck_refs_locator ON deck_refs(skill_locator)`)
102
83
  this.exec(`CREATE INDEX IF NOT EXISTS idx_skills_repo ON skills(host, owner, repo)`)
103
- }
104
-
105
- // ── Small db util wrappers (no ORM, just DRY) ────────────────
106
-
107
- /** Execute a statement that returns no rows. Auto-finalize. */
108
- private exec(sql: string, params?: Record<string, unknown>): void {
109
- const stmt = this.db.query(sql)
110
- stmt.run(params ?? {})
111
- stmt.finalize()
112
- }
113
84
 
114
- /** Query zero or one row. */
115
- private queryOne<T>(sql: string, params?: Record<string, unknown>): T | null {
116
- return this.db.query(sql).get(params ?? {}) as T | null
117
- }
118
-
119
- /** Query many rows. */
120
- private queryAll<T>(sql: string, params?: Record<string, unknown>): T[] {
121
- return this.db.query(sql).all(params ?? {}) as T[]
85
+ // Schema migrations
86
+ this.migrateSchema(CURRENT_SCHEMA, [
87
+ { version: 1, sql: `CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER NOT NULL)` },
88
+ {
89
+ version: 2,
90
+ sql: `ALTER TABLE skills ADD COLUMN git_blob_hash TEXT`,
91
+ },
92
+ ])
122
93
  }
123
94
 
124
95
  private now(): string {
@@ -228,9 +199,4 @@ export class MetadataDB {
228
199
  insert.finalize()
229
200
  })()
230
201
  }
231
-
232
- close(): void {
233
- this._db?.close()
234
- this._db = null
235
- }
236
202
  }