@kodrunhq/opencode-autopilot 1.4.0 → 1.6.0
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/assets/commands/brainstorm.md +7 -0
- package/assets/commands/stocktake.md +7 -0
- package/assets/commands/tdd.md +7 -0
- package/assets/commands/update-docs.md +7 -0
- package/assets/commands/write-plan.md +7 -0
- package/assets/skills/brainstorming/SKILL.md +295 -0
- package/assets/skills/code-review/SKILL.md +241 -0
- package/assets/skills/e2e-testing/SKILL.md +266 -0
- package/assets/skills/git-worktrees/SKILL.md +296 -0
- package/assets/skills/go-patterns/SKILL.md +240 -0
- package/assets/skills/plan-executing/SKILL.md +258 -0
- package/assets/skills/plan-writing/SKILL.md +278 -0
- package/assets/skills/python-patterns/SKILL.md +255 -0
- package/assets/skills/rust-patterns/SKILL.md +293 -0
- package/assets/skills/strategic-compaction/SKILL.md +217 -0
- package/assets/skills/systematic-debugging/SKILL.md +299 -0
- package/assets/skills/tdd-workflow/SKILL.md +311 -0
- package/assets/skills/typescript-patterns/SKILL.md +278 -0
- package/assets/skills/verification/SKILL.md +240 -0
- package/bin/configure-tui.ts +1 -1
- package/package.json +1 -1
- package/src/config.ts +76 -14
- package/src/index.ts +43 -2
- package/src/memory/capture.ts +205 -0
- package/src/memory/constants.ts +26 -0
- package/src/memory/database.ts +103 -0
- package/src/memory/decay.ts +94 -0
- package/src/memory/index.ts +24 -0
- package/src/memory/injector.ts +85 -0
- package/src/memory/project-key.ts +5 -0
- package/src/memory/repository.ts +217 -0
- package/src/memory/retrieval.ts +260 -0
- package/src/memory/schemas.ts +34 -0
- package/src/memory/types.ts +12 -0
- package/src/orchestrator/skill-injection.ts +38 -0
- package/src/review/sanitize.ts +1 -1
- package/src/skills/adaptive-injector.ts +122 -0
- package/src/skills/dependency-resolver.ts +88 -0
- package/src/skills/linter.ts +113 -0
- package/src/skills/loader.ts +88 -0
- package/src/templates/skill-template.ts +4 -0
- package/src/tools/configure.ts +1 -1
- package/src/tools/create-skill.ts +12 -0
- package/src/tools/memory-status.ts +164 -0
- package/src/tools/stocktake.ts +170 -0
- package/src/tools/update-docs.ts +116 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptive skill injection: project stack detection, skill filtering, and
|
|
3
|
+
* multi-skill context building with dependency ordering and token budget.
|
|
4
|
+
*
|
|
5
|
+
* Complements detectStackTags (which works on file paths from git diff)
|
|
6
|
+
* by checking the project root for manifest files. This enables skill
|
|
7
|
+
* filtering even before any git diff is available.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { access } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { sanitizeTemplateContent } from "../review/sanitize";
|
|
13
|
+
import { resolveDependencyOrder } from "./dependency-resolver";
|
|
14
|
+
import type { LoadedSkill } from "./loader";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TOKEN_BUDGET = 8000;
|
|
17
|
+
/** Rough estimate: 1 token ~ 4 chars */
|
|
18
|
+
const CHARS_PER_TOKEN = 4;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Manifest files that indicate project stack.
|
|
22
|
+
* Checks project root for these files to detect the stack.
|
|
23
|
+
*/
|
|
24
|
+
const MANIFEST_TAGS: Readonly<Record<string, readonly string[]>> = Object.freeze({
|
|
25
|
+
"package.json": Object.freeze(["javascript"]),
|
|
26
|
+
"tsconfig.json": Object.freeze(["typescript"]),
|
|
27
|
+
"bunfig.toml": Object.freeze(["bun", "typescript"]),
|
|
28
|
+
"bun.lockb": Object.freeze(["bun"]),
|
|
29
|
+
"go.mod": Object.freeze(["go"]),
|
|
30
|
+
"Cargo.toml": Object.freeze(["rust"]),
|
|
31
|
+
"pyproject.toml": Object.freeze(["python"]),
|
|
32
|
+
"requirements.txt": Object.freeze(["python"]),
|
|
33
|
+
Pipfile: Object.freeze(["python"]),
|
|
34
|
+
Gemfile: Object.freeze(["ruby"]),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Detect project stack tags from manifest files in the project root.
|
|
39
|
+
* Complements detectStackTags (which works on file paths from git diff).
|
|
40
|
+
*/
|
|
41
|
+
export async function detectProjectStackTags(projectRoot: string): Promise<readonly string[]> {
|
|
42
|
+
const results = await Promise.all(
|
|
43
|
+
Object.entries(MANIFEST_TAGS).map(async ([manifest, manifestTags]) => {
|
|
44
|
+
try {
|
|
45
|
+
await access(join(projectRoot, manifest));
|
|
46
|
+
return [...manifestTags];
|
|
47
|
+
} catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return [...new Set(results.flat())];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Filter skills by detected stack tags.
|
|
58
|
+
* Skills with empty stacks are ALWAYS included (methodology skills).
|
|
59
|
+
* Skills with non-empty stacks are included only if at least one tag matches.
|
|
60
|
+
*/
|
|
61
|
+
export function filterSkillsByStack(
|
|
62
|
+
skills: ReadonlyMap<string, LoadedSkill>,
|
|
63
|
+
tags: readonly string[],
|
|
64
|
+
): ReadonlyMap<string, LoadedSkill> {
|
|
65
|
+
const tagSet = new Set(tags);
|
|
66
|
+
const filtered = new Map<string, LoadedSkill>();
|
|
67
|
+
|
|
68
|
+
for (const [name, skill] of skills) {
|
|
69
|
+
if (skill.frontmatter.stacks.length === 0) {
|
|
70
|
+
filtered.set(name, skill);
|
|
71
|
+
} else if (skill.frontmatter.stacks.some((s) => tagSet.has(s))) {
|
|
72
|
+
filtered.set(name, skill);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return filtered;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build multi-skill context string with dependency ordering and token budget.
|
|
81
|
+
* Skills are ordered by dependency (prerequisites first), then concatenated
|
|
82
|
+
* until the token budget is exhausted.
|
|
83
|
+
*/
|
|
84
|
+
export function buildMultiSkillContext(
|
|
85
|
+
skills: ReadonlyMap<string, LoadedSkill>,
|
|
86
|
+
tokenBudget: number = DEFAULT_TOKEN_BUDGET,
|
|
87
|
+
): string {
|
|
88
|
+
if (skills.size === 0) return "";
|
|
89
|
+
|
|
90
|
+
// Resolve dependency order
|
|
91
|
+
const depMap = new Map(
|
|
92
|
+
[...skills.entries()].map(([name, skill]) => [name, { requires: skill.frontmatter.requires }]),
|
|
93
|
+
);
|
|
94
|
+
const { ordered, cycles } = resolveDependencyOrder(depMap);
|
|
95
|
+
|
|
96
|
+
// Skip cycle participants (graceful degradation)
|
|
97
|
+
const cycleSet = new Set(cycles);
|
|
98
|
+
const validOrder = ordered.filter((name) => !cycleSet.has(name));
|
|
99
|
+
|
|
100
|
+
// Build context with token budget enforcement
|
|
101
|
+
const charBudget = tokenBudget * CHARS_PER_TOKEN;
|
|
102
|
+
let totalChars = 0;
|
|
103
|
+
const sections: string[] = [];
|
|
104
|
+
|
|
105
|
+
for (const name of validOrder) {
|
|
106
|
+
const skill = skills.get(name);
|
|
107
|
+
if (!skill) continue;
|
|
108
|
+
|
|
109
|
+
const collapsed = skill.content.replace(/[\r\n]+/g, " ");
|
|
110
|
+
const header = `[Skill: ${name}]\n`;
|
|
111
|
+
const separator = sections.length > 0 ? 2 : 0; // "\n\n"
|
|
112
|
+
const sectionCost = collapsed.length + header.length + separator;
|
|
113
|
+
if (totalChars + sectionCost > charBudget) break;
|
|
114
|
+
|
|
115
|
+
const sanitized = sanitizeTemplateContent(collapsed);
|
|
116
|
+
sections.push(`${header}${sanitized}`);
|
|
117
|
+
totalChars += sectionCost;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (sections.length === 0) return "";
|
|
121
|
+
return `\n\nSkills context (follow these conventions and methodologies):\n${sections.join("\n\n")}`;
|
|
122
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Topological sort with cycle detection for skill dependencies.
|
|
3
|
+
*
|
|
4
|
+
* Uses iterative DFS-based topological ordering. Skills not in the map are
|
|
5
|
+
* silently skipped (graceful degradation). All cycle participants are reported
|
|
6
|
+
* in the `cycles` array so callers can exclude them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface DependencyNode {
|
|
10
|
+
readonly requires: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ResolutionResult {
|
|
14
|
+
readonly ordered: readonly string[];
|
|
15
|
+
readonly cycles: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Hard cap on skill count to prevent DoS via crafted dependency chains. */
|
|
19
|
+
const MAX_SKILLS = 500;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Iterative topological sort with cycle detection.
|
|
23
|
+
* Skills not in the map are silently skipped (graceful degradation).
|
|
24
|
+
* All nodes participating in a cycle are reported (not just the re-entry point).
|
|
25
|
+
*/
|
|
26
|
+
export function resolveDependencyOrder(
|
|
27
|
+
skills: ReadonlyMap<string, DependencyNode>,
|
|
28
|
+
): ResolutionResult {
|
|
29
|
+
if (skills.size > MAX_SKILLS) {
|
|
30
|
+
return { ordered: [], cycles: [...skills.keys()] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const visited = new Set<string>();
|
|
34
|
+
const inStack = new Set<string>();
|
|
35
|
+
const stackArr: string[] = [];
|
|
36
|
+
const ordered: string[] = [];
|
|
37
|
+
const cycleSet = new Set<string>();
|
|
38
|
+
|
|
39
|
+
for (const startName of skills.keys()) {
|
|
40
|
+
if (visited.has(startName)) continue;
|
|
41
|
+
|
|
42
|
+
// Iterative DFS using explicit stack
|
|
43
|
+
const dfsStack: Array<{ name: string; depIndex: number }> = [{ name: startName, depIndex: 0 }];
|
|
44
|
+
inStack.add(startName);
|
|
45
|
+
visited.add(startName);
|
|
46
|
+
stackArr.push(startName);
|
|
47
|
+
|
|
48
|
+
while (dfsStack.length > 0) {
|
|
49
|
+
const frame = dfsStack[dfsStack.length - 1];
|
|
50
|
+
const skill = skills.get(frame.name);
|
|
51
|
+
const deps = skill ? skill.requires : [];
|
|
52
|
+
|
|
53
|
+
if (frame.depIndex < deps.length) {
|
|
54
|
+
const dep = deps[frame.depIndex];
|
|
55
|
+
frame.depIndex++;
|
|
56
|
+
|
|
57
|
+
if (!skills.has(dep)) continue; // skip unknown deps
|
|
58
|
+
|
|
59
|
+
if (inStack.has(dep)) {
|
|
60
|
+
// Cycle detected — record all nodes in the cycle path
|
|
61
|
+
const cycleStart = stackArr.indexOf(dep);
|
|
62
|
+
for (let i = cycleStart; i < stackArr.length; i++) {
|
|
63
|
+
cycleSet.add(stackArr[i]);
|
|
64
|
+
}
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!visited.has(dep)) {
|
|
69
|
+
visited.add(dep);
|
|
70
|
+
inStack.add(dep);
|
|
71
|
+
stackArr.push(dep);
|
|
72
|
+
dfsStack.push({ name: dep, depIndex: 0 });
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
// All deps processed — pop this node
|
|
76
|
+
dfsStack.pop();
|
|
77
|
+
inStack.delete(frame.name);
|
|
78
|
+
stackArr.pop();
|
|
79
|
+
ordered.push(frame.name);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return Object.freeze({
|
|
85
|
+
ordered: Object.freeze(ordered),
|
|
86
|
+
cycles: Object.freeze([...cycleSet]),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { parse } from "yaml";
|
|
2
|
+
|
|
3
|
+
interface LintResult {
|
|
4
|
+
readonly valid: boolean;
|
|
5
|
+
readonly errors: readonly string[];
|
|
6
|
+
readonly warnings: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Parse YAML frontmatter from markdown content. Supports LF and CRLF. */
|
|
10
|
+
function extractFrontmatter(content: string): Record<string, unknown> | null {
|
|
11
|
+
const match = content.match(/^---\r?\n([\s\S]*?\r?\n)?---/);
|
|
12
|
+
if (!match) return null;
|
|
13
|
+
try {
|
|
14
|
+
const parsed = parse(match[1] ?? "");
|
|
15
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
16
|
+
return parsed as Record<string, unknown>;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Freeze a LintResult including nested arrays. */
|
|
23
|
+
function freezeResult(valid: boolean, errors: string[], warnings: string[]): LintResult {
|
|
24
|
+
return Object.freeze({
|
|
25
|
+
valid,
|
|
26
|
+
errors: Object.freeze(errors),
|
|
27
|
+
warnings: Object.freeze(warnings),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Lint a skill SKILL.md file for valid YAML frontmatter and required fields. */
|
|
32
|
+
export function lintSkill(content: string): LintResult {
|
|
33
|
+
const errors: string[] = [];
|
|
34
|
+
const warnings: string[] = [];
|
|
35
|
+
|
|
36
|
+
const fm = extractFrontmatter(content);
|
|
37
|
+
if (!fm) {
|
|
38
|
+
return freezeResult(false, ["Missing YAML frontmatter"], []);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Required fields
|
|
42
|
+
if (typeof fm.name !== "string" || fm.name.length === 0) {
|
|
43
|
+
errors.push("Missing required field: name");
|
|
44
|
+
}
|
|
45
|
+
if (typeof fm.description !== "string" || fm.description.length === 0) {
|
|
46
|
+
errors.push("Missing required field: description");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Optional but recommended fields
|
|
50
|
+
if (!Array.isArray(fm.stacks)) {
|
|
51
|
+
warnings.push(
|
|
52
|
+
"Missing recommended field: stacks (add stacks: [] for methodology skills or stacks: [lang] for language skills)",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (!Array.isArray(fm.requires)) {
|
|
56
|
+
warnings.push("Missing recommended field: requires (add requires: [] if no dependencies)");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Validate stacks entries are strings
|
|
60
|
+
if (Array.isArray(fm.stacks) && fm.stacks.some((s: unknown) => typeof s !== "string")) {
|
|
61
|
+
errors.push("stacks must contain only strings");
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(fm.requires) && fm.requires.some((s: unknown) => typeof s !== "string")) {
|
|
64
|
+
errors.push("requires must contain only strings");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Content validation (CRLF-safe)
|
|
68
|
+
const body = content.replace(/^---\r?\n(?:[\s\S]*?\r?\n)?---/, "").trim();
|
|
69
|
+
if (body.length === 0) {
|
|
70
|
+
warnings.push("Skill has no content after frontmatter");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return freezeResult(errors.length === 0, errors, warnings);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Lint a command markdown file for valid YAML frontmatter and required fields. */
|
|
77
|
+
export function lintCommand(content: string): LintResult {
|
|
78
|
+
const errors: string[] = [];
|
|
79
|
+
const warnings: string[] = [];
|
|
80
|
+
|
|
81
|
+
const fm = extractFrontmatter(content);
|
|
82
|
+
if (!fm) {
|
|
83
|
+
return freezeResult(false, ["Missing YAML frontmatter"], []);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (typeof fm.description !== "string" || fm.description.length === 0) {
|
|
87
|
+
errors.push("Missing required field: description");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const body = content.replace(/^---\r?\n(?:[\s\S]*?\r?\n)?---/, "").trim();
|
|
91
|
+
if (body.length === 0) {
|
|
92
|
+
warnings.push("Command has no content after frontmatter");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return freezeResult(errors.length === 0, errors, warnings);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Lint an agent markdown file for valid YAML frontmatter and required fields. */
|
|
99
|
+
export function lintAgent(content: string): LintResult {
|
|
100
|
+
const errors: string[] = [];
|
|
101
|
+
const warnings: string[] = [];
|
|
102
|
+
|
|
103
|
+
const fm = extractFrontmatter(content);
|
|
104
|
+
if (!fm) {
|
|
105
|
+
return freezeResult(false, ["Missing YAML frontmatter"], []);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (typeof fm.name !== "string" || fm.name.length === 0) {
|
|
109
|
+
errors.push("Missing required field: name");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return freezeResult(errors.length === 0, errors, warnings);
|
|
113
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill frontmatter parser and file loader.
|
|
3
|
+
*
|
|
4
|
+
* Loads SKILL.md files from the global skills directory, parses their
|
|
5
|
+
* YAML frontmatter, and returns structured skill metadata + content.
|
|
6
|
+
* Uses the `yaml` package for parsing (not regex — per "Don't Hand-Roll" guideline).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { parse } from "yaml";
|
|
12
|
+
import { isEnoentError } from "../utils/fs-helpers";
|
|
13
|
+
|
|
14
|
+
export interface SkillFrontmatter {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly description: string;
|
|
17
|
+
readonly stacks: readonly string[];
|
|
18
|
+
readonly requires: readonly string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface LoadedSkill {
|
|
22
|
+
readonly frontmatter: SkillFrontmatter;
|
|
23
|
+
readonly content: string;
|
|
24
|
+
readonly path: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse YAML frontmatter from SKILL.md content.
|
|
29
|
+
* Returns null if no valid frontmatter block is found or parsing fails.
|
|
30
|
+
*/
|
|
31
|
+
export function parseSkillFrontmatter(content: string): SkillFrontmatter | null {
|
|
32
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
33
|
+
if (!match) return null;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const parsed = parse(match[1]);
|
|
37
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
38
|
+
const fm = parsed as Record<string, unknown>;
|
|
39
|
+
return {
|
|
40
|
+
name: typeof fm.name === "string" ? fm.name : "",
|
|
41
|
+
description: typeof fm.description === "string" ? fm.description : "",
|
|
42
|
+
stacks: Array.isArray(fm.stacks)
|
|
43
|
+
? fm.stacks.filter((s): s is string => typeof s === "string")
|
|
44
|
+
: [],
|
|
45
|
+
requires: Array.isArray(fm.requires)
|
|
46
|
+
? fm.requires.filter((s): s is string => typeof s === "string")
|
|
47
|
+
: [],
|
|
48
|
+
};
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Load all skills from a base directory (e.g., ~/.config/opencode/skills/).
|
|
56
|
+
* Returns a map of skill name -> LoadedSkill. Best-effort: skips invalid skills.
|
|
57
|
+
*/
|
|
58
|
+
export async function loadAllSkills(skillsDir: string): Promise<ReadonlyMap<string, LoadedSkill>> {
|
|
59
|
+
const skills = new Map<string, LoadedSkill>();
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const entries = await readdir(skillsDir, { withFileTypes: true });
|
|
63
|
+
await Promise.all(
|
|
64
|
+
entries
|
|
65
|
+
.filter((e) => e.isDirectory() && e.name !== ".gitkeep")
|
|
66
|
+
.map(async (dir) => {
|
|
67
|
+
try {
|
|
68
|
+
const skillPath = join(skillsDir, dir.name, "SKILL.md");
|
|
69
|
+
const content = await readFile(skillPath, "utf-8");
|
|
70
|
+
const fm = parseSkillFrontmatter(content);
|
|
71
|
+
if (fm?.name) {
|
|
72
|
+
skills.set(fm.name, { frontmatter: fm, content, path: skillPath });
|
|
73
|
+
}
|
|
74
|
+
} catch (error: unknown) {
|
|
75
|
+
// Skip ENOENT/IO errors (expected for invalid skills)
|
|
76
|
+
if (!isEnoentError(error) && !(error instanceof SyntaxError)) {
|
|
77
|
+
const errObj = error as { code?: unknown };
|
|
78
|
+
if (typeof errObj?.code !== "string") throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
} catch (error: unknown) {
|
|
84
|
+
if (!isEnoentError(error)) throw error;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return skills;
|
|
88
|
+
}
|
|
@@ -5,12 +5,16 @@ export interface SkillTemplateInput {
|
|
|
5
5
|
readonly description: string;
|
|
6
6
|
readonly license?: string;
|
|
7
7
|
readonly compatibility?: string;
|
|
8
|
+
readonly stacks?: readonly string[];
|
|
9
|
+
readonly requires?: readonly string[];
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
export function generateSkillMarkdown(input: SkillTemplateInput): string {
|
|
11
13
|
const frontmatter: Record<string, unknown> = {
|
|
12
14
|
name: input.name,
|
|
13
15
|
description: input.description,
|
|
16
|
+
stacks: input.stacks ?? [],
|
|
17
|
+
requires: input.requires ?? [],
|
|
14
18
|
...(input.license !== undefined && { license: input.license }),
|
|
15
19
|
...(input.compatibility !== undefined && { compatibility: input.compatibility }),
|
|
16
20
|
};
|
package/src/tools/configure.ts
CHANGED
|
@@ -314,7 +314,7 @@ async function handleCommit(configPath?: string): Promise<string> {
|
|
|
314
314
|
}
|
|
315
315
|
const newConfig = {
|
|
316
316
|
...currentConfig,
|
|
317
|
-
version:
|
|
317
|
+
version: 5 as const,
|
|
318
318
|
configured: true,
|
|
319
319
|
groups: groupsRecord,
|
|
320
320
|
overrides: currentConfig.overrides ?? {},
|
|
@@ -11,6 +11,8 @@ interface CreateSkillArgs {
|
|
|
11
11
|
readonly description: string;
|
|
12
12
|
readonly license?: string;
|
|
13
13
|
readonly compatibility?: string;
|
|
14
|
+
readonly stacks?: readonly string[];
|
|
15
|
+
readonly requires?: readonly string[];
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export async function createSkillCore(args: CreateSkillArgs, baseDir: string): Promise<string> {
|
|
@@ -27,6 +29,8 @@ export async function createSkillCore(args: CreateSkillArgs, baseDir: string): P
|
|
|
27
29
|
description: args.description,
|
|
28
30
|
license: args.license,
|
|
29
31
|
compatibility: args.compatibility,
|
|
32
|
+
stacks: args.stacks,
|
|
33
|
+
requires: args.requires,
|
|
30
34
|
});
|
|
31
35
|
|
|
32
36
|
try {
|
|
@@ -67,6 +71,14 @@ export const ocCreateSkill = tool({
|
|
|
67
71
|
.max(64)
|
|
68
72
|
.optional()
|
|
69
73
|
.describe("Compatibility (e.g., 'opencode')"),
|
|
74
|
+
stacks: tool.schema
|
|
75
|
+
.array(tool.schema.string())
|
|
76
|
+
.optional()
|
|
77
|
+
.describe("Stack tags for adaptive loading (e.g., ['typescript', 'bun'])"),
|
|
78
|
+
requires: tool.schema
|
|
79
|
+
.array(tool.schema.string())
|
|
80
|
+
.optional()
|
|
81
|
+
.describe("Required skill dependencies (e.g., ['coding-standards'])"),
|
|
70
82
|
},
|
|
71
83
|
async execute(args) {
|
|
72
84
|
return createSkillCore(args, getGlobalConfigDir());
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oc_memory_status tool — inspect memory system state.
|
|
3
|
+
*
|
|
4
|
+
* Shows observation counts, storage size, recent observations,
|
|
5
|
+
* preferences, and per-type breakdowns. Follows the *Core + tool()
|
|
6
|
+
* pattern from create-agent.ts.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Database } from "bun:sqlite";
|
|
12
|
+
import { statSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { tool } from "@opencode-ai/plugin";
|
|
15
|
+
import { DB_FILE, MEMORY_DIR, OBSERVATION_TYPES } from "../memory/constants";
|
|
16
|
+
import { getMemoryDb } from "../memory/database";
|
|
17
|
+
import { getAllPreferences } from "../memory/repository";
|
|
18
|
+
import { getGlobalConfigDir } from "../utils/paths";
|
|
19
|
+
|
|
20
|
+
interface MemoryStatusResult {
|
|
21
|
+
readonly stats: {
|
|
22
|
+
readonly totalObservations: number;
|
|
23
|
+
readonly totalProjects: number;
|
|
24
|
+
readonly totalPreferences: number;
|
|
25
|
+
readonly storageSizeKb: number;
|
|
26
|
+
readonly observationsByType: Record<string, number>;
|
|
27
|
+
} | null;
|
|
28
|
+
readonly recentObservations: readonly {
|
|
29
|
+
readonly type: string;
|
|
30
|
+
readonly summary: string;
|
|
31
|
+
readonly createdAt: string;
|
|
32
|
+
readonly confidence: number;
|
|
33
|
+
}[];
|
|
34
|
+
readonly preferences: readonly {
|
|
35
|
+
readonly key: string;
|
|
36
|
+
readonly value: string;
|
|
37
|
+
readonly confidence: number;
|
|
38
|
+
}[];
|
|
39
|
+
readonly error?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Core function for memory status inspection.
|
|
44
|
+
* Accepts a Database instance for testability (or uses the singleton).
|
|
45
|
+
*/
|
|
46
|
+
export function memoryStatusCore(
|
|
47
|
+
_args: { readonly detail?: "summary" | "full" },
|
|
48
|
+
dbOrPath?: Database | string,
|
|
49
|
+
): MemoryStatusResult {
|
|
50
|
+
let ownedDb: Database | null = null;
|
|
51
|
+
try {
|
|
52
|
+
if (typeof dbOrPath === "string") {
|
|
53
|
+
ownedDb = new Database(dbOrPath);
|
|
54
|
+
}
|
|
55
|
+
const db = dbOrPath instanceof Database ? dbOrPath : (ownedDb ?? getMemoryDb());
|
|
56
|
+
|
|
57
|
+
// Count observations
|
|
58
|
+
const obsCountRow = db.query("SELECT COUNT(*) as cnt FROM observations").get() as {
|
|
59
|
+
cnt: number;
|
|
60
|
+
};
|
|
61
|
+
const totalObservations = obsCountRow.cnt;
|
|
62
|
+
|
|
63
|
+
// Count by type
|
|
64
|
+
const typeRows = db
|
|
65
|
+
.query("SELECT type, COUNT(*) as cnt FROM observations GROUP BY type")
|
|
66
|
+
.all() as Array<{ type: string; cnt: number }>;
|
|
67
|
+
|
|
68
|
+
const observationsByType: Record<string, number> = {};
|
|
69
|
+
for (const t of OBSERVATION_TYPES) {
|
|
70
|
+
observationsByType[t] = 0;
|
|
71
|
+
}
|
|
72
|
+
for (const row of typeRows) {
|
|
73
|
+
observationsByType[row.type] = row.cnt;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Count projects
|
|
77
|
+
const projCountRow = db.query("SELECT COUNT(*) as cnt FROM projects").get() as {
|
|
78
|
+
cnt: number;
|
|
79
|
+
};
|
|
80
|
+
const totalProjects = projCountRow.cnt;
|
|
81
|
+
|
|
82
|
+
// Count preferences
|
|
83
|
+
const prefCountRow = db.query("SELECT COUNT(*) as cnt FROM preferences").get() as {
|
|
84
|
+
cnt: number;
|
|
85
|
+
};
|
|
86
|
+
const totalPreferences = prefCountRow.cnt;
|
|
87
|
+
|
|
88
|
+
// Storage size — derive from actual DB path, not always the global default
|
|
89
|
+
let storageSizeKb = 0;
|
|
90
|
+
try {
|
|
91
|
+
const statPath =
|
|
92
|
+
typeof dbOrPath === "string" && dbOrPath !== ":memory:"
|
|
93
|
+
? dbOrPath
|
|
94
|
+
: join(getGlobalConfigDir(), MEMORY_DIR, DB_FILE);
|
|
95
|
+
const stat = statSync(statPath);
|
|
96
|
+
storageSizeKb = Math.round(stat.size / 1024);
|
|
97
|
+
} catch {
|
|
98
|
+
// DB might be in-memory or path doesn't exist
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Recent observations (last 10)
|
|
102
|
+
const recentRows = db
|
|
103
|
+
.query(
|
|
104
|
+
"SELECT type, summary, created_at, confidence FROM observations ORDER BY created_at DESC LIMIT 10",
|
|
105
|
+
)
|
|
106
|
+
.all() as Array<{
|
|
107
|
+
type: string;
|
|
108
|
+
summary: string;
|
|
109
|
+
created_at: string;
|
|
110
|
+
confidence: number;
|
|
111
|
+
}>;
|
|
112
|
+
|
|
113
|
+
const recentObservations = recentRows.map((row) => ({
|
|
114
|
+
type: row.type,
|
|
115
|
+
summary: row.summary,
|
|
116
|
+
createdAt: row.created_at,
|
|
117
|
+
confidence: row.confidence,
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
// All preferences
|
|
121
|
+
const allPrefs = getAllPreferences(db);
|
|
122
|
+
const preferences = allPrefs.map((p) => ({
|
|
123
|
+
key: p.key,
|
|
124
|
+
value: p.value,
|
|
125
|
+
confidence: p.confidence,
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
stats: {
|
|
130
|
+
totalObservations,
|
|
131
|
+
totalProjects,
|
|
132
|
+
totalPreferences,
|
|
133
|
+
storageSizeKb,
|
|
134
|
+
observationsByType,
|
|
135
|
+
},
|
|
136
|
+
recentObservations,
|
|
137
|
+
preferences,
|
|
138
|
+
};
|
|
139
|
+
} catch (err) {
|
|
140
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
141
|
+
return {
|
|
142
|
+
stats: null,
|
|
143
|
+
recentObservations: [],
|
|
144
|
+
preferences: [],
|
|
145
|
+
error: `Memory system error: ${detail}`,
|
|
146
|
+
};
|
|
147
|
+
} finally {
|
|
148
|
+
ownedDb?.close();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const ocMemoryStatus = tool({
|
|
153
|
+
description:
|
|
154
|
+
"Show memory system status: observation counts, recent memories, preferences, and storage size.",
|
|
155
|
+
args: {
|
|
156
|
+
detail: tool.schema
|
|
157
|
+
.enum(["summary", "full"])
|
|
158
|
+
.default("summary")
|
|
159
|
+
.describe("Level of detail to show"),
|
|
160
|
+
},
|
|
161
|
+
async execute(args) {
|
|
162
|
+
return JSON.stringify(memoryStatusCore(args), null, 2);
|
|
163
|
+
},
|
|
164
|
+
});
|