Skip to content

Commit 4987d6f

Browse files
Merge pull request #40 from priyanshujain/feat-progressive-skill-loading
Progressive skill loading: slim SKILL.md + REFERENCE.md
2 parents a48d4e9 + 91acdaa commit 4987d6f

File tree

19 files changed

+414
-245
lines changed

19 files changed

+414
-245
lines changed

agent/tools/load_skills.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,13 @@ func (l *LoadSkillsTool) Execute(_ context.Context, input json.RawMessage) (stri
4040

4141
var parts []string
4242
for _, name := range in.Names {
43-
skillPath := filepath.Join(skills.SkillsDir(), name, "SKILL.md")
44-
content, err := os.ReadFile(skillPath)
43+
// Prefer REFERENCE.md (full instructions), fall back to SKILL.md.
44+
refPath := filepath.Join(skills.SkillsDir(), name, "REFERENCE.md")
45+
content, err := os.ReadFile(refPath)
46+
if err != nil {
47+
skillPath := filepath.Join(skills.SkillsDir(), name, "SKILL.md")
48+
content, err = os.ReadFile(skillPath)
49+
}
4550
if err != nil {
4651
parts = append(parts, fmt.Sprintf("--- %s ---\nError: skill not found", name))
4752
continue

agent/tools/skills_test.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ func TestLoadSkills(t *testing.T) {
1515
tmp := t.TempDir()
1616
t.Setenv("OBK_CONFIG_DIR", tmp)
1717

18-
// Create test skill.
18+
// Create test skill with only SKILL.md (fallback path).
1919
skillDir := filepath.Join(tmp, "skills", "test-skill")
2020
if err := os.MkdirAll(skillDir, 0700); err != nil {
2121
t.Fatal(err)
@@ -38,6 +38,35 @@ func TestLoadSkills(t *testing.T) {
3838
}
3939
}
4040

41+
func TestLoadSkillsPrefersReferenceMD(t *testing.T) {
42+
tmp := t.TempDir()
43+
t.Setenv("OBK_CONFIG_DIR", tmp)
44+
45+
skillDir := filepath.Join(tmp, "skills", "test-skill")
46+
if err := os.MkdirAll(skillDir, 0700); err != nil {
47+
t.Fatal(err)
48+
}
49+
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: test-skill\n---\nSlim summary"), 0600); err != nil {
50+
t.Fatal(err)
51+
}
52+
if err := os.WriteFile(filepath.Join(skillDir, "REFERENCE.md"), []byte("Full reference content with schema and examples"), 0600); err != nil {
53+
t.Fatal(err)
54+
}
55+
56+
tool := &LoadSkillsTool{}
57+
input, _ := json.Marshal(map[string]any{"names": []string{"test-skill"}})
58+
result, err := tool.Execute(context.Background(), input)
59+
if err != nil {
60+
t.Fatalf("Execute: %v", err)
61+
}
62+
if !strings.Contains(result, "Full reference content") {
63+
t.Errorf("should prefer REFERENCE.md content, got: %q", result)
64+
}
65+
if strings.Contains(result, "Slim summary") {
66+
t.Errorf("should not contain SKILL.md content when REFERENCE.md exists, got: %q", result)
67+
}
68+
}
69+
4170
func TestLoadSkillsMissing(t *testing.T) {
4271
tmp := t.TempDir()
4372
t.Setenv("OBK_CONFIG_DIR", tmp)

assistant/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Skills are loaded from ~/.obk/skills/ — run `obk setup` to configure.
44

55
## How to access data
66

7-
Use the skills provided to query data via `sqlite3`, send messages via `obk`, or interact with Google Workspace via `gws`. Each skill contains the exact schema, query patterns, and command usage.
7+
Use the skills provided to query data via `sqlite3`, send messages via `obk`, or interact with Google Workspace via `gws`. Each skill's SKILL.md contains a summary. Before using a skill, read its REFERENCE.md file for the full schema, query patterns, and command usage.
88

99
## Messaging someone
1010

internal/skills/install.go

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -296,14 +296,20 @@ func resolveGWSSkills(cfg *config.Config) (map[string]SkillEntry, []string, erro
296296
}
297297
desired[name] = se
298298

299-
// Copy to skills dir.
299+
// Split into slim SKILL.md + full REFERENCE.md, then copy.
300+
slim, ref := splitSkillFile(content)
300301
destDir := filepath.Join(SkillsDir(), name)
301302
if err := os.MkdirAll(destDir, 0700); err != nil {
302303
return nil, nil, fmt.Errorf("create skill dir %s: %w", name, err)
303304
}
304-
if err := os.WriteFile(filepath.Join(destDir, "SKILL.md"), content, 0600); err != nil {
305+
if err := os.WriteFile(filepath.Join(destDir, "SKILL.md"), slim, 0600); err != nil {
305306
return nil, nil, fmt.Errorf("write skill %s: %w", name, err)
306307
}
308+
if len(ref) > 0 {
309+
if err := os.WriteFile(filepath.Join(destDir, "REFERENCE.md"), ref, 0600); err != nil {
310+
return nil, nil, fmt.Errorf("write reference %s: %w", name, err)
311+
}
312+
}
307313
}
308314

309315
return desired, skipped, nil
@@ -378,3 +384,45 @@ func gwsServiceFromSkillName(name string) string {
378384
parts := strings.SplitN(name, "-", 2)
379385
return parts[0]
380386
}
387+
388+
// splitSkillFile splits a full SKILL.md into a slim summary (frontmatter +
389+
// first paragraph + pointer) and a REFERENCE.md (remaining body).
390+
// If there is no body after frontmatter, ref is nil.
391+
func splitSkillFile(content []byte) (slim []byte, ref []byte) {
392+
text := string(content)
393+
394+
// Parse frontmatter (between --- markers).
395+
if !strings.HasPrefix(text, "---\n") {
396+
return content, nil
397+
}
398+
end := strings.Index(text[4:], "\n---\n")
399+
if end < 0 {
400+
return content, nil
401+
}
402+
frontmatter := text[:4+end+5] // includes both --- lines and trailing newline
403+
body := strings.TrimSpace(text[4+end+5:])
404+
if body == "" {
405+
return content, nil
406+
}
407+
408+
// Extract first paragraph (up to first blank line) as the summary.
409+
var summary, rest string
410+
if idx := strings.Index(body, "\n\n"); idx >= 0 {
411+
summary = body[:idx]
412+
rest = strings.TrimSpace(body[idx+2:])
413+
} else {
414+
summary = body
415+
}
416+
417+
// Build slim SKILL.md: frontmatter + summary + pointer.
418+
slimStr := frontmatter + "\n" + summary + "\n\nRead the REFERENCE.md in this skill's directory for full instructions.\n"
419+
slim = []byte(slimStr)
420+
421+
if rest == "" {
422+
// Only one paragraph — no reference needed, keep full body in SKILL.md.
423+
return content, nil
424+
}
425+
426+
ref = []byte(rest + "\n")
427+
return slim, ref
428+
}

internal/skills/install_test.go

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,15 @@ func TestInstallBuiltinSkillsNoAuth(t *testing.T) {
218218
t.Error("history-read SKILL.md is empty")
219219
}
220220

221+
// Verify REFERENCE.md was written alongside SKILL.md.
222+
refContent, err := os.ReadFile(filepath.Join(tmp, "skills", "history-read", "REFERENCE.md"))
223+
if err != nil {
224+
t.Fatalf("read history-read REFERENCE.md: %v", err)
225+
}
226+
if len(refContent) == 0 {
227+
t.Error("history-read REFERENCE.md is empty")
228+
}
229+
221230
// Verify schema.sql was written alongside SKILL.md.
222231
schemaContent, err := os.ReadFile(filepath.Join(tmp, "skills", "history-read", "schema.sql"))
223232
if err != nil {
@@ -227,14 +236,21 @@ func TestInstallBuiltinSkillsNoAuth(t *testing.T) {
227236
t.Error("history-read schema.sql is empty")
228237
}
229238

230-
// Verify memory-save SKILL.md was written.
239+
// Verify memory-save SKILL.md and REFERENCE.md were written.
231240
memorySaveContent, err := os.ReadFile(filepath.Join(tmp, "skills", "memory-save", "SKILL.md"))
232241
if err != nil {
233242
t.Fatalf("read memory-save SKILL.md: %v", err)
234243
}
235244
if len(memorySaveContent) == 0 {
236245
t.Error("memory-save SKILL.md is empty")
237246
}
247+
memorySaveRef, err := os.ReadFile(filepath.Join(tmp, "skills", "memory-save", "REFERENCE.md"))
248+
if err != nil {
249+
t.Fatalf("read memory-save REFERENCE.md: %v", err)
250+
}
251+
if len(memorySaveRef) == 0 {
252+
t.Error("memory-save REFERENCE.md is empty")
253+
}
238254

239255
// Verify manifest was written.
240256
m, err := LoadManifest()
@@ -423,6 +439,56 @@ func TestInstallRemovesRevokedSkills(t *testing.T) {
423439
}
424440
}
425441

442+
func TestSplitSkillFile(t *testing.T) {
443+
tests := []struct {
444+
name string
445+
input string
446+
wantSlim string
447+
wantRef string
448+
}{
449+
{
450+
name: "no frontmatter",
451+
input: "Just some content",
452+
wantSlim: "Just some content",
453+
wantRef: "",
454+
},
455+
{
456+
name: "frontmatter only",
457+
input: "---\nname: test\n---\n",
458+
wantSlim: "---\nname: test\n---\n",
459+
wantRef: "",
460+
},
461+
{
462+
name: "single paragraph body",
463+
input: "---\nname: test\n---\n\nJust a summary line.",
464+
wantSlim: "---\nname: test\n---\n\nJust a summary line.",
465+
wantRef: "",
466+
},
467+
{
468+
name: "multi paragraph body",
469+
input: "---\nname: test\ndescription: Test skill\n---\n\nFirst paragraph summary.\n\n## Commands\n\n```bash\nsome command\n```\n\n## Examples\n\nMore content here.",
470+
wantSlim: "---\nname: test\ndescription: Test skill\n---\n\nFirst paragraph summary.\n\n" +
471+
"Read the REFERENCE.md in this skill's directory for full instructions.\n",
472+
wantRef: "## Commands\n\n```bash\nsome command\n```\n\n## Examples\n\nMore content here.\n",
473+
},
474+
}
475+
476+
for _, tt := range tests {
477+
t.Run(tt.name, func(t *testing.T) {
478+
slim, ref := splitSkillFile([]byte(tt.input))
479+
if string(slim) != tt.wantSlim {
480+
t.Errorf("slim:\ngot: %q\nwant: %q", string(slim), tt.wantSlim)
481+
}
482+
if string(ref) != tt.wantRef {
483+
if tt.wantRef == "" && ref == nil {
484+
return // nil and "" are equivalent for empty ref
485+
}
486+
t.Errorf("ref:\ngot: %q\nwant: %q", string(ref), tt.wantRef)
487+
}
488+
})
489+
}
490+
}
491+
426492
func TestInstallIdempotent(t *testing.T) {
427493
t.Setenv("OBK_CONFIG_DIR", t.TempDir())
428494

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
## Schema
2+
3+
Full database schema: see schema.sql in this skill directory.
4+
5+
## Query patterns
6+
7+
```bash
8+
# Recent notes
9+
obk db applenotes "SELECT modified_at, folder, title FROM applenotes_notes ORDER BY modified_at DESC LIMIT 20;"
10+
11+
# Search by title
12+
obk db applenotes "SELECT modified_at, folder, title FROM applenotes_notes WHERE LOWER(title) LIKE '%keyword%' ORDER BY modified_at DESC LIMIT 20;"
13+
14+
# Full text search across title and body
15+
obk db applenotes "SELECT modified_at, folder, title, substr(body, 1, 200) FROM applenotes_notes WHERE LOWER(title) LIKE '%term%' OR LOWER(body) LIKE '%term%' ORDER BY modified_at DESC LIMIT 10;"
16+
17+
# Read full note
18+
obk db applenotes "SELECT title, folder, account, created_at, modified_at, body FROM applenotes_notes WHERE id = <id>;"
19+
20+
# Notes in a specific folder
21+
obk db applenotes "SELECT modified_at, title FROM applenotes_notes WHERE LOWER(folder) = 'notes' ORDER BY modified_at DESC LIMIT 20;"
22+
23+
# List all folders
24+
obk db applenotes "SELECT name, account, (SELECT COUNT(*) FROM applenotes_notes WHERE folder_id = f.apple_id) as note_count FROM applenotes_folders f ORDER BY name;"
25+
26+
# Notes by account
27+
obk db applenotes "SELECT account, COUNT(*) FROM applenotes_notes GROUP BY account;"
28+
29+
# Recently modified notes (last 7 days)
30+
obk db applenotes "SELECT modified_at, folder, title FROM applenotes_notes WHERE modified_at >= datetime('now', '-7 days') ORDER BY modified_at DESC;"
31+
```

skills/applenotes-read/SKILL.md

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,34 +4,6 @@ description: Search Apple Notes, find notes by title or content, browse notes by
44
allowed-tools: Bash(obk *)
55
---
66

7-
## Schema
7+
Query synced Apple Notes from `~/.obk/applenotes/data.db`.
88

9-
Full database schema: see schema.sql in this skill directory.
10-
11-
## Query patterns
12-
13-
```bash
14-
# Recent notes
15-
obk db applenotes "SELECT modified_at, folder, title FROM applenotes_notes ORDER BY modified_at DESC LIMIT 20;"
16-
17-
# Search by title
18-
obk db applenotes "SELECT modified_at, folder, title FROM applenotes_notes WHERE LOWER(title) LIKE '%keyword%' ORDER BY modified_at DESC LIMIT 20;"
19-
20-
# Full text search across title and body
21-
obk db applenotes "SELECT modified_at, folder, title, substr(body, 1, 200) FROM applenotes_notes WHERE LOWER(title) LIKE '%term%' OR LOWER(body) LIKE '%term%' ORDER BY modified_at DESC LIMIT 10;"
22-
23-
# Read full note
24-
obk db applenotes "SELECT title, folder, account, created_at, modified_at, body FROM applenotes_notes WHERE id = <id>;"
25-
26-
# Notes in a specific folder
27-
obk db applenotes "SELECT modified_at, title FROM applenotes_notes WHERE LOWER(folder) = 'notes' ORDER BY modified_at DESC LIMIT 20;"
28-
29-
# List all folders
30-
obk db applenotes "SELECT name, account, (SELECT COUNT(*) FROM applenotes_notes WHERE folder_id = f.apple_id) as note_count FROM applenotes_folders f ORDER BY name;"
31-
32-
# Notes by account
33-
obk db applenotes "SELECT account, COUNT(*) FROM applenotes_notes GROUP BY account;"
34-
35-
# Recently modified notes (last 7 days)
36-
obk db applenotes "SELECT modified_at, folder, title FROM applenotes_notes WHERE modified_at >= datetime('now', '-7 days') ORDER BY modified_at DESC;"
37-
```
9+
Read the REFERENCE.md in this skill's directory for the full schema and query patterns.

skills/email-read/REFERENCE.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
## Schema
2+
3+
Full database schema: see schema.sql in this skill directory.
4+
5+
## Query patterns
6+
7+
```bash
8+
# Recent emails
9+
obk db gmail "SELECT date, from_addr, subject FROM gmail_emails ORDER BY date DESC LIMIT 20;"
10+
11+
# Search by subject
12+
obk db gmail "SELECT date, from_addr, subject FROM gmail_emails WHERE LOWER(subject) LIKE '%keyword%' ORDER BY date DESC LIMIT 20;"
13+
14+
# Search by sender
15+
obk db gmail "SELECT date, from_addr, subject FROM gmail_emails WHERE LOWER(from_addr) LIKE '%name%' ORDER BY date DESC LIMIT 20;"
16+
17+
# Full text search across subject and body
18+
obk db gmail "SELECT date, from_addr, subject, substr(body, 1, 200) FROM gmail_emails WHERE LOWER(subject) LIKE '%term%' OR LOWER(body) LIKE '%term%' ORDER BY date DESC LIMIT 10;"
19+
20+
# Read full email
21+
obk db gmail "SELECT from_addr, to_addr, subject, date, body FROM gmail_emails WHERE id = <id>;"
22+
23+
# Emails with attachments
24+
obk db gmail "SELECT e.date, e.from_addr, e.subject, a.filename, a.mime_type FROM gmail_emails e JOIN gmail_attachments a ON a.email_id = e.id ORDER BY e.date DESC LIMIT 20;"
25+
26+
# Count by account
27+
obk db gmail "SELECT account, COUNT(*) FROM gmail_emails GROUP BY account;"
28+
```

skills/email-read/SKILL.md

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,6 @@ description: Search emails, check inbox, find messages, look up correspondence,
44
allowed-tools: Bash(obk *)
55
---
66

7-
## Schema
7+
Query synced Gmail emails from `~/.obk/gmail/data.db`.
88

9-
Full database schema: see schema.sql in this skill directory.
10-
11-
## Query patterns
12-
13-
```bash
14-
# Recent emails
15-
obk db gmail "SELECT date, from_addr, subject FROM gmail_emails ORDER BY date DESC LIMIT 20;"
16-
17-
# Search by subject
18-
obk db gmail "SELECT date, from_addr, subject FROM gmail_emails WHERE LOWER(subject) LIKE '%keyword%' ORDER BY date DESC LIMIT 20;"
19-
20-
# Search by sender
21-
obk db gmail "SELECT date, from_addr, subject FROM gmail_emails WHERE LOWER(from_addr) LIKE '%name%' ORDER BY date DESC LIMIT 20;"
22-
23-
# Full text search across subject and body
24-
obk db gmail "SELECT date, from_addr, subject, substr(body, 1, 200) FROM gmail_emails WHERE LOWER(subject) LIKE '%term%' OR LOWER(body) LIKE '%term%' ORDER BY date DESC LIMIT 10;"
25-
26-
# Read full email
27-
obk db gmail "SELECT from_addr, to_addr, subject, date, body FROM gmail_emails WHERE id = <id>;"
28-
29-
# Emails with attachments
30-
obk db gmail "SELECT e.date, e.from_addr, e.subject, a.filename, a.mime_type FROM gmail_emails e JOIN gmail_attachments a ON a.email_id = e.id ORDER BY e.date DESC LIMIT 20;"
31-
32-
# Count by account
33-
obk db gmail "SELECT account, COUNT(*) FROM gmail_emails GROUP BY account;"
34-
```
9+
Read the REFERENCE.md in this skill's directory for the full schema and query patterns.

0 commit comments

Comments
 (0)