Skip to content

Commit 64c75a9

Browse files
authored
feat(vscode): context injection engine and skill catalog
1 parent 83f0afb commit 64c75a9

25 files changed

Lines changed: 3540 additions & 7 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,6 @@ node_modules/
22
dist/
33
*.vsix
44
.DS_Store
5+
6+
# Auto-generated workspace context (Chalk)
7+
.chalk/context/

docs/context-injection-plan.md

Lines changed: 531 additions & 0 deletions
Large diffs are not rendered by default.

packages/vscode-extension/package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/vscode-extension/package.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@
8484
"command": "chalkSkills.autoIndex",
8585
"title": "Chalk Skills: Auto-Index Skills"
8686
},
87+
{
88+
"command": "chalkSkills.openCatalog",
89+
"title": "Chalk Skills: Open Skill Catalog"
90+
},
8791
{
8892
"command": "chalkSkills.init",
8993
"title": "Chalk Skills: Init — Scaffold Sample Skills"
@@ -106,6 +110,35 @@
106110
"default": 60,
107111
"description": "Minimum seconds between auto-recordings of the same skill"
108112
},
113+
"chalkSkills.context.mode": {
114+
"type": "string",
115+
"default": "on-demand",
116+
"enum": [
117+
"on-demand",
118+
"background",
119+
"off"
120+
],
121+
"enumDescriptions": [
122+
"Assemble context when a skill is activated",
123+
"Continuously refresh context in the background",
124+
"Disable context injection"
125+
],
126+
"description": "When to assemble workspace context for skills"
127+
},
128+
"chalkSkills.context.backgroundIntervalSeconds": {
129+
"type": "number",
130+
"default": 120,
131+
"minimum": 30,
132+
"maximum": 600,
133+
"description": "How often to refresh background context (in seconds)"
134+
},
135+
"chalkSkills.context.maxLines": {
136+
"type": "number",
137+
"default": 500,
138+
"minimum": 100,
139+
"maximum": 2000,
140+
"description": "Maximum lines in the generated context document"
141+
},
109142
"chalkSkills.animations.level": {
110143
"type": "string",
111144
"default": "full",

packages/vscode-extension/src/auto-recorder.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ import { ChalkSkill } from './types';
44

55
export interface AutoRecorderCallbacks {
66
onSkillUsed: (skillId: string, trigger: 'file-read' | 'artifact-change') => void;
7+
onContextNeeded?: (skill: ChalkSkill) => void;
78
}
89

910
export class AutoRecorder implements vscode.Disposable {
1011
private disposables: vscode.Disposable[] = [];
1112
private cooldowns = new Map<string, number>();
1213
private skillByPath = new Map<string, string>();
14+
private skillById = new Map<string, ChalkSkill>();
1315
private artifactWatchers: vscode.FileSystemWatcher[] = [];
1416

1517
constructor(
@@ -19,8 +21,10 @@ export class AutoRecorder implements vscode.Disposable {
1921
setSkills(skills: ChalkSkill[]) {
2022
// Rebuild path -> skillId lookup
2123
this.skillByPath.clear();
24+
this.skillById.clear();
2225
for (const skill of skills) {
2326
this.skillByPath.set(skill.filePath, skill.id);
27+
this.skillById.set(skill.id, skill);
2428
}
2529

2630
// Rebuild artifact watchers
@@ -77,6 +81,14 @@ export class AutoRecorder implements vscode.Disposable {
7781

7882
this.cooldowns.set(skillId, now);
7983
this.callbacks.onSkillUsed(skillId, trigger);
84+
85+
// Trigger context assembly if callback registered
86+
if (this.callbacks.onContextNeeded) {
87+
const skill = this.skillById.get(skillId);
88+
if (skill) {
89+
this.callbacks.onContextNeeded(skill);
90+
}
91+
}
8092
}
8193

8294
private isEnabled(): boolean {
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import * as fs from 'fs/promises';
2+
import * as path from 'path';
3+
import * as vscode from 'vscode';
4+
import { RegistrySkillEntry } from './skill-registry';
5+
6+
// ── Activation State ──
7+
8+
/** Persisted record of which skills the user has enabled */
9+
export interface CatalogState {
10+
version: 1;
11+
/** The bundle the user initially selected (for UI display) */
12+
selectedBundle: string | null;
13+
/** Explicit set of enabled skill IDs */
14+
enabledSkills: string[];
15+
/** Timestamp of last change */
16+
lastModified: number;
17+
}
18+
19+
const DEFAULT_STATE: CatalogState = {
20+
version: 1,
21+
selectedBundle: null,
22+
enabledSkills: [],
23+
lastModified: Date.now(),
24+
};
25+
26+
const STATE_KEY = 'chalk.catalogState';
27+
28+
// ── SkillActivation ──
29+
30+
export class SkillActivation {
31+
private state: CatalogState;
32+
private enabledSet: Set<string>;
33+
private _onDidChange = new vscode.EventEmitter<CatalogState>();
34+
35+
/** Fires when the activation state changes */
36+
readonly onDidChange = this._onDidChange.event;
37+
38+
constructor(
39+
private context: vscode.ExtensionContext,
40+
private workspaceRoot: string,
41+
) {
42+
this.state = this.loadState();
43+
this.enabledSet = new Set(this.state.enabledSkills);
44+
}
45+
46+
// ── State Persistence ──
47+
48+
private loadState(): CatalogState {
49+
const stored = this.context.workspaceState.get<CatalogState>(STATE_KEY);
50+
if (stored && stored.version === 1) return stored;
51+
return { ...DEFAULT_STATE };
52+
}
53+
54+
private async persistState(): Promise<void> {
55+
this.state.enabledSkills = Array.from(this.enabledSet).sort();
56+
this.state.lastModified = Date.now();
57+
await this.context.workspaceState.update(STATE_KEY, this.state);
58+
this._onDidChange.fire(this.state);
59+
}
60+
61+
// ── Queries ──
62+
63+
getState(): CatalogState {
64+
return { ...this.state, enabledSkills: Array.from(this.enabledSet).sort() };
65+
}
66+
67+
isEnabled(skillId: string): boolean {
68+
return this.enabledSet.has(skillId);
69+
}
70+
71+
getEnabledSkills(): string[] {
72+
return Array.from(this.enabledSet).sort();
73+
}
74+
75+
getSelectedBundle(): string | null {
76+
return this.state.selectedBundle;
77+
}
78+
79+
/** Whether the user has completed first-time setup */
80+
isOnboarded(): boolean {
81+
return this.state.selectedBundle !== null || this.enabledSet.size > 0;
82+
}
83+
84+
// ── Mutations ──
85+
86+
/** Enable a single skill */
87+
async enableSkill(skillId: string): Promise<void> {
88+
this.enabledSet.add(skillId);
89+
await this.persistState();
90+
await this.syncSkillToWorkspace(skillId, true);
91+
}
92+
93+
/** Disable a single skill */
94+
async disableSkill(skillId: string): Promise<void> {
95+
this.enabledSet.delete(skillId);
96+
await this.persistState();
97+
await this.syncSkillToWorkspace(skillId, false);
98+
}
99+
100+
/** Toggle a skill's enabled state */
101+
async toggleSkill(skillId: string): Promise<boolean> {
102+
const nowEnabled = !this.enabledSet.has(skillId);
103+
if (nowEnabled) {
104+
this.enabledSet.add(skillId);
105+
} else {
106+
this.enabledSet.delete(skillId);
107+
}
108+
await this.persistState();
109+
await this.syncSkillToWorkspace(skillId, nowEnabled);
110+
return nowEnabled;
111+
}
112+
113+
/** Apply a bundle — enables all skills in the bundle, records selection */
114+
async applyBundle(bundleId: string, skillIds: string[]): Promise<void> {
115+
this.state.selectedBundle = bundleId;
116+
// Start fresh with bundle skills
117+
this.enabledSet = new Set(skillIds);
118+
await this.persistState();
119+
await this.syncAllToWorkspace();
120+
}
121+
122+
/** Enable multiple skills at once */
123+
async enableMany(skillIds: string[]): Promise<void> {
124+
for (const id of skillIds) {
125+
this.enabledSet.add(id);
126+
}
127+
await this.persistState();
128+
await this.syncAllToWorkspace();
129+
}
130+
131+
/** Disable all skills */
132+
async clearAll(): Promise<void> {
133+
this.enabledSet.clear();
134+
this.state.selectedBundle = null;
135+
await this.persistState();
136+
await this.syncAllToWorkspace();
137+
}
138+
139+
// ── Workspace Sync ──
140+
141+
/**
142+
* Syncs a single skill's enabled-state marker in `.chalk/skills/`.
143+
* When a skill is enabled, we create a marker file to indicate it's active
144+
* in the current workspace. When disabled, we remove the marker.
145+
*/
146+
private async syncSkillToWorkspace(skillId: string, enabled: boolean): Promise<void> {
147+
const sourceDir = path.join(this.workspaceRoot, 'skills', skillId);
148+
149+
if (enabled) {
150+
// Skill exists in source — ensure it's accessible in .chalk/skills/
151+
try {
152+
await fs.access(sourceDir);
153+
} catch {
154+
return; // Source skill doesn't exist
155+
}
156+
157+
await this.ensureDir(path.join(this.workspaceRoot, '.chalk', 'skills'));
158+
159+
// Create a marker file that indicates this skill is enabled
160+
const markerPath = path.join(this.workspaceRoot, '.chalk', 'skills', `.${skillId}.enabled`);
161+
await fs.writeFile(markerPath, JSON.stringify({ skillId, enabledAt: Date.now() }), 'utf-8');
162+
} else {
163+
// Remove the enabled marker
164+
const markerPath = path.join(this.workspaceRoot, '.chalk', 'skills', `.${skillId}.enabled`);
165+
try {
166+
await fs.unlink(markerPath);
167+
} catch {
168+
// Marker doesn't exist, that's fine
169+
}
170+
}
171+
}
172+
173+
/** Full sync — reconcile .chalk/skills/ markers with enabled set */
174+
private async syncAllToWorkspace(): Promise<void> {
175+
const markerDir = path.join(this.workspaceRoot, '.chalk', 'skills');
176+
await this.ensureDir(markerDir);
177+
178+
// Clean up stale markers
179+
try {
180+
const entries = await fs.readdir(markerDir);
181+
for (const entry of entries) {
182+
if (entry.startsWith('.') && entry.endsWith('.enabled')) {
183+
const skillId = entry.slice(1, -'.enabled'.length);
184+
if (!this.enabledSet.has(skillId)) {
185+
await fs.unlink(path.join(markerDir, entry));
186+
}
187+
}
188+
}
189+
} catch {
190+
// Directory might not exist yet, that's fine
191+
}
192+
193+
// Create markers for all enabled skills
194+
for (const skillId of this.enabledSet) {
195+
const markerPath = path.join(markerDir, `.${skillId}.enabled`);
196+
try {
197+
await fs.access(markerPath);
198+
} catch {
199+
await fs.writeFile(markerPath, JSON.stringify({ skillId, enabledAt: Date.now() }), 'utf-8');
200+
}
201+
}
202+
}
203+
204+
private async ensureDir(dirPath: string): Promise<void> {
205+
await fs.mkdir(dirPath, { recursive: true });
206+
}
207+
208+
dispose(): void {
209+
this._onDidChange.dispose();
210+
}
211+
}

0 commit comments

Comments
 (0)