pi-ui-extend 0.1.43 → 0.1.45
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/external/pi-tools-suite/README.md +2 -1
- package/external/pi-tools-suite/src/coding-discipline/index.ts +2 -1
- package/external/pi-tools-suite/src/dcp/compression-blocks.ts +78 -3
- package/external/pi-tools-suite/src/dcp/pruner-compression-blocks.ts +58 -42
- package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +14 -0
- package/external/pi-tools-suite/src/index.ts +1 -0
- package/external/pi-tools-suite/src/skill-installer/index.ts +333 -0
- package/package.json +1 -1
- package/skills/spec-lite/SKILL.md +140 -0
|
@@ -18,10 +18,11 @@ This package keeps shared Pi tools as ordinary source folders under `src/` and r
|
|
|
18
18
|
- `src/web-search` — `web_search` and `web_fetch` tools migrated from `@ollama/pi-web-search`; calls the local Ollama experimental web search/fetch APIs, honors `OLLAMA_HOST`, supports request timeouts via `timeout_ms` / `PI_WEB_SEARCH_TIMEOUT_MS`, and reports targeted `ollama signin`, unsupported-endpoint, invalid-response, timeout, DNS, and Ollama-not-running errors
|
|
19
19
|
- `src/dcp` — headless Dynamic Context Pruning ported from `opencode-dynamic-context-pruning` for the Pi SDK: explicit `compress` tool with range and message modes, `/dcp` commands (context, stats, sweep, manual, decompress, recompress, compress), same-call overlap validation, recoverable compressed-block rollups, grouped message-mode skip diagnostics, stable raw-message anchors when available, protected user/tool preservation, deduplication, error purging, and context nudges; visualization is left to `compress` tool responses and the renderer-owned context-percent click dialog
|
|
20
20
|
- `src/prompt-commands` — user slash-command builder: `/prompt-commands` opens a CRUD menu for saved prompt-backed slash commands, stores them under `promptCommands` in `~/.config/pi/pi-tools-suite.jsonc`, reloads after edits, and runs each saved prompt as a normal user message
|
|
21
|
+
- `src/skill-installer` — `/install-skill [name]` installs a personal skill folder from `~/.agents/local_skills` into the current project's `.pi/skills/` so it activates as a project-local skill, then automatically runs `/reload` so the new skill is picked up without a manual step; `/export-skill [name]` does the reverse, copying a project-local skill back to `~/.agents/local_skills/` for reuse in other projects (no reload, since the library lives outside the project); with no argument either command shows an interactive menu of available skills (folders containing `SKILL.md`), and the `<name>` form installs/exports it directly (headless-safe); existing destinations prompt to overwrite in the UI and are refused in headless mode; `.DS_Store` files are skipped
|
|
21
22
|
|
|
22
23
|
`index.ts` is intentionally only a thin auto-discovery shim that re-exports `src/index.ts`. There is no `pi.extensions` manifest here, so local Pi auto-discovery loads the suite once via `~/.pi/agent/extensions/pi-tools-suite/index.ts` and does not double-register tools.
|
|
23
24
|
|
|
24
|
-
Registration order is preserved in `src/index.ts`: coding-discipline, ast-grep, async-subagents, lsp, comment-checker, session-name, repo-discovery command/tool gate, antigravity-auth provider, todo, model-tools, usage, web-search, dcp, then
|
|
25
|
+
Registration order is preserved in `src/index.ts`: coding-discipline, ast-grep, async-subagents, lsp, comment-checker, session-name, repo-discovery command/tool gate, antigravity-auth provider, todo, model-tools, usage, web-search, dcp, prompt-commands, then skill-installer. Tool metadata and active model-specific tool sets have two modes: standard and repo-aware. When `.indexer-cli` enables `repo_*`, those tools stay active ahead of overlapping lower-level aliases so the indexed discovery surface has priority.
|
|
25
26
|
|
|
26
27
|
## Disabling modules
|
|
27
28
|
|
|
@@ -88,7 +88,8 @@ const QUALITY_DISCIPLINE_LINES = [
|
|
|
88
88
|
"- for bugs, prefer a failing repro first, then the minimal fix, then verify;",
|
|
89
89
|
"- high-risk changes (security, data/schema, public APIs, concurrency, irreversible) need a short spec first;",
|
|
90
90
|
"- handle edge cases, errors, cancellation, and async behavior; do not block UI/event loops;",
|
|
91
|
-
"- avoid duplicate state, duplicate prompts, and repeated side effects
|
|
91
|
+
"- avoid duplicate state, duplicate prompts, and repeated side effects;",
|
|
92
|
+
"- write code, identifiers, comments, and commit messages in English.",
|
|
92
93
|
];
|
|
93
94
|
|
|
94
95
|
const LOOKUP_DISCIPLINE_LINES = [
|
|
@@ -383,11 +383,86 @@ export function resolveIdToBoundary(
|
|
|
383
383
|
}
|
|
384
384
|
|
|
385
385
|
const meta = state.messageMetaSnapshot.get(id)
|
|
386
|
-
if (meta) return
|
|
386
|
+
if (meta) return resolveMetaBoundary(meta, field, state)
|
|
387
387
|
|
|
388
388
|
const ts = state.messageIdSnapshot.get(id)
|
|
389
|
-
if (ts
|
|
390
|
-
|
|
389
|
+
if (ts !== undefined) return { timestamp: ts }
|
|
390
|
+
|
|
391
|
+
// ── Stale mNNN fallback: direction-only clamp ────────────────────────
|
|
392
|
+
// When compression or pruning removes messages between context passes,
|
|
393
|
+
// positional mNNN IDs shift (e.g. an end boundary m145 becomes m123
|
|
394
|
+
// after 22 messages are removed). Clamp to the closest valid ID, but
|
|
395
|
+
// ONLY in a direction that preserves the range's semantics:
|
|
396
|
+
// - start boundary: clamp upward to the first available ID at or after
|
|
397
|
+
// the requested number. The start must never move backwards into
|
|
398
|
+
// older content; if no such ID exists, the requested start is gone
|
|
399
|
+
// and we cannot safely compress — throw.
|
|
400
|
+
// - end boundary: clamp downward to the last available ID at or before
|
|
401
|
+
// the requested number. The end must never move forwards into newer
|
|
402
|
+
// content; if no such ID exists, throw.
|
|
403
|
+
// The previous implementation fell back to the highest available ID in
|
|
404
|
+
// both "no match" cases, which could clamp e.g. m010..m010 over a
|
|
405
|
+
// snapshot of m001..m003 to a single-message block over m003 — silently
|
|
406
|
+
// compressing the wrong content.
|
|
407
|
+
const mMatch = id.match(/^m(\d+)$/i)
|
|
408
|
+
if (mMatch && state.messageIdSnapshot.size > 0) {
|
|
409
|
+
const requestedNum = parseInt(mMatch[1]!, 10)
|
|
410
|
+
const allIds = sortIds([...state.messageIdSnapshot.keys()])
|
|
411
|
+
const allNums = allIds
|
|
412
|
+
.map((mid) => {
|
|
413
|
+
const n = mid.match(/^m(\d+)$/i)
|
|
414
|
+
return n ? { id: mid, num: parseInt(n[1]!, 10) } : null
|
|
415
|
+
})
|
|
416
|
+
.filter((entry): entry is { id: string; num: number } => entry !== null)
|
|
417
|
+
.sort((a, b) => a.num - b.num)
|
|
418
|
+
|
|
419
|
+
if (allNums.length > 0) {
|
|
420
|
+
let clamped: { id: string; num: number } | undefined
|
|
421
|
+
if (field === "startTimestamp") {
|
|
422
|
+
clamped = allNums.find((entry) => entry.num >= requestedNum)
|
|
423
|
+
} else {
|
|
424
|
+
for (let i = allNums.length - 1; i >= 0; i--) {
|
|
425
|
+
if (allNums[i]!.num <= requestedNum) {
|
|
426
|
+
clamped = allNums[i]
|
|
427
|
+
break
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (clamped) {
|
|
432
|
+
const clampedMeta = state.messageMetaSnapshot.get(clamped.id)
|
|
433
|
+
if (clampedMeta) return resolveMetaBoundary(clampedMeta, field, state)
|
|
434
|
+
const clampedTs = state.messageIdSnapshot.get(clamped.id)
|
|
435
|
+
if (clampedTs !== undefined) return { timestamp: clampedTs }
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
throw unknownCompressionIdError(id, state)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Resolve a `MessageIdMeta` to a compression boundary. When the meta is a
|
|
445
|
+
* synthetic placeholder for an active compression block (`meta.blockId` is
|
|
446
|
+
* set), resolve to that block's stored boundary so the caller rolls the
|
|
447
|
+
* block up instead of nesting a new block on top of the placeholder. This
|
|
448
|
+
* matters for both exact-match and clamped mNNN IDs: a model-visible mNNN
|
|
449
|
+
* may itself represent a previously compressed section.
|
|
450
|
+
*/
|
|
451
|
+
function resolveMetaBoundary(
|
|
452
|
+
meta: MessageIdMeta,
|
|
453
|
+
field: "startTimestamp" | "endTimestamp",
|
|
454
|
+
state: DcpState,
|
|
455
|
+
): ResolvedCompressionBoundary {
|
|
456
|
+
if (meta.blockId !== undefined) {
|
|
457
|
+
const block = state.compressionBlocks.find((b) => b.id === meta.blockId && b.active)
|
|
458
|
+
if (block) {
|
|
459
|
+
return {
|
|
460
|
+
timestamp: block[field],
|
|
461
|
+
stableId: field === "startTimestamp" ? block.startMessageId : block.endMessageId,
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return { timestamp: meta.timestamp, stableId: meta.stableId }
|
|
391
466
|
}
|
|
392
467
|
|
|
393
468
|
/**
|
|
@@ -13,60 +13,76 @@ function findBoundaryIndex(messages: any[], stableId: string | undefined, timest
|
|
|
13
13
|
return messages.findIndex((m, index) => messageMatchesBoundary(m, index, stableId, timestamp));
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
function collectToolCallIds(messages: any[]): Set<string> {
|
|
17
|
-
const ids = new Set<string>();
|
|
18
|
-
for (const msg of messages) {
|
|
19
|
-
if (typeof msg?.toolCallId === "string") ids.add(msg.toolCallId);
|
|
20
|
-
if (msg?.role !== "assistant" || !Array.isArray(msg.content)) continue;
|
|
21
|
-
for (const block of msg.content) {
|
|
22
|
-
if (block?.type === "toolCall" && typeof block.id === "string") ids.add(block.id);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
return ids;
|
|
26
|
-
}
|
|
27
16
|
|
|
28
17
|
export function syncCompressionBlocks(messages: any[], state: DcpState, config: DcpConfig): void {
|
|
29
18
|
if (state.compressionBlocks.length === 0) return;
|
|
30
19
|
|
|
31
|
-
|
|
20
|
+
// Determine the conversation's timestamp range so we can tell whether a
|
|
21
|
+
// block's range is genuinely outside the current session history (which
|
|
22
|
+
// warrants deactivation) vs merely having its boundary messages pruned
|
|
23
|
+
// (which is normal and should NOT cause deactivation).
|
|
24
|
+
let conversationMinTs = Infinity;
|
|
25
|
+
let conversationMaxTs = -Infinity;
|
|
26
|
+
for (const msg of messages) {
|
|
27
|
+
const ts = msg?.timestamp;
|
|
28
|
+
if (typeof ts === "number" && Number.isFinite(ts)) {
|
|
29
|
+
if (ts < conversationMinTs) conversationMinTs = ts;
|
|
30
|
+
if (ts > conversationMaxTs) conversationMaxTs = ts;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
32
33
|
|
|
33
34
|
for (const block of state.compressionBlocks) {
|
|
34
35
|
if (!block.active || block.deactivatedByUser) continue;
|
|
35
36
|
|
|
37
|
+
// ── Skip the missing-origin-compress-call check ────────────────────
|
|
38
|
+
// The compress tool-call that *created* this block is not the block's
|
|
39
|
+
// content — once the block exists the tool-call is irrelevant and will
|
|
40
|
+
// naturally be pruned by tool-output pruning, session compaction, or
|
|
41
|
+
// nested compression. Deactivating a valid block because its creation
|
|
42
|
+
// tool-call was pruned silently loses the compressed summary and forces
|
|
43
|
+
// the original (larger) messages to be re-sent, which also invalidates
|
|
44
|
+
// the provider's prompt prefix cache.
|
|
45
|
+
|
|
46
|
+
// ── Boundary validation ────────────────────────────────────────────
|
|
47
|
+
// Only deactivate when the block's timestamp range falls entirely
|
|
48
|
+
// outside the conversation's time range, meaning the session genuinely
|
|
49
|
+
// does not contain that history (e.g. after a branch switch to a
|
|
50
|
+
// completely different conversation). When boundary messages are merely
|
|
51
|
+
// absent because they were themselves pruned, compressed, or have
|
|
52
|
+
// fragile timestamp-based stable IDs, the block is still valid —
|
|
53
|
+
// applyCompressionBlocks already handles missing boundaries gracefully
|
|
54
|
+
// by skipping splicing when findBoundaryIndex returns -1.
|
|
36
55
|
if (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
56
|
+
Number.isFinite(block.startTimestamp) &&
|
|
57
|
+
Number.isFinite(block.endTimestamp) &&
|
|
58
|
+
Number.isFinite(conversationMinTs) &&
|
|
59
|
+
Number.isFinite(conversationMaxTs)
|
|
40
60
|
) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
reason: "missing-origin-compress-call",
|
|
46
|
-
topic: block.topic,
|
|
47
|
-
createdByToolCallId: block.createdByToolCallId,
|
|
48
|
-
activeBlocksAfter: state.compressionBlocks.filter((b) => b.active).length,
|
|
49
|
-
});
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
61
|
+
// Block range is entirely before or entirely after the conversation
|
|
62
|
+
const blockEntirelyOutside =
|
|
63
|
+
block.endTimestamp < conversationMinTs ||
|
|
64
|
+
block.startTimestamp > conversationMaxTs;
|
|
52
65
|
|
|
53
|
-
|
|
54
|
-
|
|
66
|
+
if (blockEntirelyOutside) {
|
|
67
|
+
// Before deactivating, check if boundary messages can still be
|
|
68
|
+
// found by their stable ID — timestamps can change across
|
|
69
|
+
// session reloads while the underlying message persists.
|
|
70
|
+
const startFound = findBoundaryIndex(messages, block.startMessageId, block.startTimestamp) !== -1;
|
|
71
|
+
const endFound = findBoundaryIndex(messages, block.endMessageId, block.endTimestamp) !== -1;
|
|
55
72
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
});
|
|
73
|
+
if (!startFound && !endFound) {
|
|
74
|
+
block.active = false;
|
|
75
|
+
block.deactivatedReason = "outside-conversation-range";
|
|
76
|
+
writeDcpDebugLog(config, "block.auto_deactivated", {
|
|
77
|
+
blockId: `b${block.id}`,
|
|
78
|
+
reason: "outside-conversation-range",
|
|
79
|
+
topic: block.topic,
|
|
80
|
+
blockRange: [block.startTimestamp, block.endTimestamp],
|
|
81
|
+
conversationRange: [conversationMinTs, conversationMaxTs],
|
|
82
|
+
activeBlocksAfter: state.compressionBlocks.filter((b) => b.active).length,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
70
86
|
}
|
|
71
87
|
}
|
|
72
88
|
}
|
|
@@ -57,6 +57,20 @@ export const DEFAULT_PI_TOOLS_SUITE_CONFIG_JSONC = String.raw`{
|
|
|
57
57
|
"maxContextPercent": "30%"
|
|
58
58
|
}
|
|
59
59
|
},
|
|
60
|
+
// glm-5.2 reports a ~1M-token window. Even zai/* 16%/30% = 160K/300K is
|
|
61
|
+
// above the ~15% (~150K) point where long sessions degrade, and an
|
|
62
|
+
// observed 14h/273K-token session never crossed 16%. Lower ONLY glm-5.2
|
|
63
|
+
// within the zai family: 8%/15% (~80K/150K) so nudging starts early and
|
|
64
|
+
// auto-compress fires at the observed degradation point. Other zai/*
|
|
65
|
+
// models keep 16%/30%.
|
|
66
|
+
"zai/glm-5.2": {
|
|
67
|
+
"compress": {
|
|
68
|
+
"minContextPercent": "8%",
|
|
69
|
+
"maxContextPercent": "15%",
|
|
70
|
+
"autoCandidates": { "minContextPercent": 0.08 },
|
|
71
|
+
"messageMode": { "minContextPercent": 0.08 }
|
|
72
|
+
}
|
|
73
|
+
},
|
|
60
74
|
"antigravity/*sonnet*": {
|
|
61
75
|
"compress": {
|
|
62
76
|
"minContextPercent": "22%",
|
|
@@ -25,6 +25,7 @@ const MODULES: Array<{ name: string; load: () => Promise<ExtensionModule> }> = [
|
|
|
25
25
|
{ name: "web-search", load: () => import("./web-search/index") },
|
|
26
26
|
{ name: "dcp", load: () => import("./dcp/index") },
|
|
27
27
|
{ name: "prompt-commands", load: () => import("./prompt-commands/index") },
|
|
28
|
+
{ name: "skill-installer", load: () => import("./skill-installer/index") },
|
|
28
29
|
{ name: "telegram-mirror", load: () => import("./telegram-mirror/index") },
|
|
29
30
|
];
|
|
30
31
|
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, join, relative } from "node:path";
|
|
4
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import { ignoreStaleExtensionContextError } from "../context-usage.js";
|
|
7
|
+
|
|
8
|
+
const INSTALL_COMMAND = "install-skill";
|
|
9
|
+
const EXPORT_COMMAND = "export-skill";
|
|
10
|
+
const LOCAL_SKILLS_DIR = join(homedir(), ".agents", "local_skills");
|
|
11
|
+
const SKILL_FILE = "SKILL.md";
|
|
12
|
+
const PROJECT_DIR = ".pi";
|
|
13
|
+
const PROJECT_SKILLS_SUBDIR = "skills";
|
|
14
|
+
const SKIP_NAMES = new Set([".DS_Store"]);
|
|
15
|
+
const DESC_MAX = 90;
|
|
16
|
+
|
|
17
|
+
type SkillEntry = {
|
|
18
|
+
name: string;
|
|
19
|
+
path: string;
|
|
20
|
+
description: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Direction-aware transfer plan shared by install and export. */
|
|
24
|
+
type Transfer = {
|
|
25
|
+
srcDir: string;
|
|
26
|
+
destDir: string;
|
|
27
|
+
/** Human label for the destination root (with trailing slash); name is appended per skill. */
|
|
28
|
+
destRootLabel: string;
|
|
29
|
+
commandName: string;
|
|
30
|
+
direction: "install" | "export";
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function localSkillsLabel(): string {
|
|
34
|
+
const home = homedir();
|
|
35
|
+
return LOCAL_SKILLS_DIR === home ? LOCAL_SKILLS_DIR : LOCAL_SKILLS_DIR.replace(home, "~");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function projectSkillsDir(ctx: ExtensionContext): string {
|
|
39
|
+
return join(ctx.cwd, PROJECT_DIR, PROJECT_SKILLS_SUBDIR);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function projectSkillsLabel(ctx: ExtensionContext): string {
|
|
43
|
+
return displayPath(ctx, projectSkillsDir(ctx));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function displayPath(ctx: ExtensionContext, absPath: string): string {
|
|
47
|
+
const rel = relative(ctx.cwd, absPath);
|
|
48
|
+
return rel && !rel.startsWith("..") ? rel : absPath;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function notify(ctx: ExtensionContext, message: string, type: "info" | "warning" | "error" = "info"): void {
|
|
52
|
+
if (ctx.hasUI) ctx.ui.notify(message, type);
|
|
53
|
+
else console.log(message);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function truncate(value: string, maxLength: number): string {
|
|
57
|
+
const collapsed = value.replace(/\s+/g, " ").trim();
|
|
58
|
+
return collapsed.length <= maxLength ? collapsed : `${collapsed.slice(0, Math.max(0, maxLength - 1))}…`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Extract the `description` scalar from SKILL.md YAML frontmatter. Returns "" when absent/unparseable. */
|
|
62
|
+
function parseFrontmatterDescription(content: string): string {
|
|
63
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
64
|
+
if (!match) return "";
|
|
65
|
+
const front = match[1] ?? "";
|
|
66
|
+
const lines = front.split(/\r?\n/);
|
|
67
|
+
const idx = lines.findIndex((line) => /^\s*description:\s*/.test(line));
|
|
68
|
+
if (idx === -1) return "";
|
|
69
|
+
const headerLine = lines[idx];
|
|
70
|
+
if (!headerLine) return "";
|
|
71
|
+
const after = headerLine.replace(/^\s*description:\s*/, "");
|
|
72
|
+
|
|
73
|
+
// Folded/literal block scalar (>, >-, |, |-)
|
|
74
|
+
if (/^[>|]/.test(after)) {
|
|
75
|
+
const block: string[] = [];
|
|
76
|
+
for (let i = idx + 1; i < lines.length; i += 1) {
|
|
77
|
+
const line = lines[i] ?? "";
|
|
78
|
+
if (line === "") {
|
|
79
|
+
block.push(" ");
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (/^\s+/.test(line)) {
|
|
83
|
+
block.push(line.replace(/^\s+/, ""));
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
return block.join(" ").trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Plain or quoted scalar
|
|
92
|
+
let value = after.trim();
|
|
93
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
94
|
+
value = value.slice(1, -1);
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function readSkillDescription(skillDir: string): Promise<string> {
|
|
100
|
+
try {
|
|
101
|
+
const content = await fs.readFile(join(skillDir, SKILL_FILE), "utf-8");
|
|
102
|
+
return parseFrontmatterDescription(content);
|
|
103
|
+
} catch {
|
|
104
|
+
return "";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
109
|
+
try {
|
|
110
|
+
await fs.access(path);
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Scan a skills directory: every subdirectory that contains SKILL.md. Returns ENOENT as empty. */
|
|
118
|
+
async function scanSkills(dir: string): Promise<SkillEntry[]> {
|
|
119
|
+
let entries: import("node:fs").Dirent[];
|
|
120
|
+
try {
|
|
121
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const skills: SkillEntry[] = [];
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
if (!entry.isDirectory() || SKIP_NAMES.has(entry.name)) continue;
|
|
130
|
+
const skillPath = join(dir, entry.name);
|
|
131
|
+
if (!(await pathExists(join(skillPath, SKILL_FILE)))) continue;
|
|
132
|
+
skills.push({ name: entry.name, path: skillPath, description: await readSkillDescription(skillPath) });
|
|
133
|
+
}
|
|
134
|
+
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function listLocalSkills(): Promise<SkillEntry[]> {
|
|
138
|
+
return scanSkills(LOCAL_SKILLS_DIR);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function listProjectSkills(ctx: ExtensionContext): Promise<SkillEntry[]> {
|
|
142
|
+
return scanSkills(projectSkillsDir(ctx));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function skillLabel(skill: SkillEntry): string {
|
|
146
|
+
const description = skill.description.replace(/\s+/g, " ").trim();
|
|
147
|
+
return description ? `${skill.name} — ${truncate(description, DESC_MAX)}` : skill.name;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function copySkill(source: string, dest: string): Promise<void> {
|
|
151
|
+
await fs.mkdir(join(dest, ".."), { recursive: true });
|
|
152
|
+
await fs.cp(source, dest, {
|
|
153
|
+
recursive: true,
|
|
154
|
+
filter: (src) => !SKIP_NAMES.has(basename(src)),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Copy a skill folder from srcDir/name → destDir/name with overwrite-confirm (UI) / refuse (headless). */
|
|
159
|
+
async function transferSkill(ctx: ExtensionCommandContext, name: string, t: Transfer): Promise<void> {
|
|
160
|
+
const src = join(t.srcDir, name);
|
|
161
|
+
const dest = join(t.destDir, name);
|
|
162
|
+
|
|
163
|
+
if (await pathExists(dest)) {
|
|
164
|
+
if (!ctx.hasUI) {
|
|
165
|
+
notify(ctx, `Skill "${name}" already exists at ${t.destRootLabel}${name}. Remove it first or run /${t.commandName} ${name} interactively to overwrite.`, "error");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
let confirmed = false;
|
|
169
|
+
try {
|
|
170
|
+
confirmed = await ctx.ui.confirm("Skill already exists", `"${name}" already exists in ${t.destRootLabel}. Overwrite it?`);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
ignoreStaleExtensionContextError(error);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (!confirmed) {
|
|
176
|
+
notify(ctx, `Cancelled. "${name}" left unchanged.`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
await fs.rm(dest, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
await copySkill(src, dest);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
186
|
+
notify(ctx, `Failed to ${t.direction} skill "${name}": ${message}`, "error");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (t.direction === "install") {
|
|
191
|
+
notify(ctx, `Installed skill "${name}" → ${t.destRootLabel}${name}. Reloading to activate…`);
|
|
192
|
+
try {
|
|
193
|
+
await ctx.reload();
|
|
194
|
+
} catch (error) {
|
|
195
|
+
ignoreStaleExtensionContextError(error);
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
notify(ctx, `Exported skill "${name}" → ${t.destRootLabel}${name}. Available for /install-skill in other projects.`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function installTransfer(ctx: ExtensionCommandContext): Transfer {
|
|
204
|
+
return {
|
|
205
|
+
srcDir: LOCAL_SKILLS_DIR,
|
|
206
|
+
destDir: projectSkillsDir(ctx),
|
|
207
|
+
destRootLabel: `${projectSkillsLabel(ctx)}/`,
|
|
208
|
+
commandName: INSTALL_COMMAND,
|
|
209
|
+
direction: "install",
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function exportTransfer(ctx: ExtensionCommandContext): Transfer {
|
|
214
|
+
return {
|
|
215
|
+
srcDir: projectSkillsDir(ctx),
|
|
216
|
+
destDir: LOCAL_SKILLS_DIR,
|
|
217
|
+
destRootLabel: `${localSkillsLabel()}/`,
|
|
218
|
+
commandName: EXPORT_COMMAND,
|
|
219
|
+
direction: "export",
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// --- install ---
|
|
224
|
+
|
|
225
|
+
async function showInstallMenu(ctx: ExtensionCommandContext): Promise<void> {
|
|
226
|
+
const skills = await listLocalSkills();
|
|
227
|
+
if (skills.length === 0) {
|
|
228
|
+
notify(ctx, `No skills found in ${localSkillsLabel()} (each skill is a folder containing ${SKILL_FILE}).`, "warning");
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!ctx.hasUI) {
|
|
233
|
+
notify(ctx, `Available skills in ${localSkillsLabel()}:\n${skills.map((s) => s.name).join("\n")}\n\nUse /${INSTALL_COMMAND} <name> to install one.`, "warning");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const t = installTransfer(ctx);
|
|
238
|
+
const labels = skills.map(skillLabel);
|
|
239
|
+
const labelToName = new Map(labels.map((label, index) => [label, skills[index]!.name]));
|
|
240
|
+
let selected: string | undefined;
|
|
241
|
+
try {
|
|
242
|
+
selected = await ctx.ui.select(`Install skill into ${projectSkillsLabel(ctx)}/ (${localSkillsLabel()})`, labels);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
ignoreStaleExtensionContextError(error);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (!selected) return;
|
|
248
|
+
|
|
249
|
+
const name = labelToName.get(selected);
|
|
250
|
+
if (!name) return;
|
|
251
|
+
await transferSkill(ctx, name, t);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function installByName(ctx: ExtensionCommandContext, name: string): Promise<void> {
|
|
255
|
+
const skills = await listLocalSkills();
|
|
256
|
+
const match = skills.find((entry) => entry.name === name);
|
|
257
|
+
if (!match) {
|
|
258
|
+
const available = skills.length > 0 ? skills.map((s) => s.name).join(", ") : "(none)";
|
|
259
|
+
notify(ctx, `No skill named "${name}" in ${localSkillsLabel()}. Available: ${available}`, "error");
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
await transferSkill(ctx, match.name, installTransfer(ctx));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// --- export ---
|
|
266
|
+
|
|
267
|
+
async function showExportMenu(ctx: ExtensionCommandContext): Promise<void> {
|
|
268
|
+
const skills = await listProjectSkills(ctx);
|
|
269
|
+
if (skills.length === 0) {
|
|
270
|
+
notify(ctx, `No skills found in ${projectSkillsLabel(ctx)}/. Install one first with /${INSTALL_COMMAND}.`, "warning");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (!ctx.hasUI) {
|
|
275
|
+
notify(ctx, `Installed skills in ${projectSkillsLabel(ctx)}/:\n${skills.map((s) => s.name).join("\n")}\n\nUse /${EXPORT_COMMAND} <name> to export one to ${localSkillsLabel()}.`, "warning");
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const t = exportTransfer(ctx);
|
|
280
|
+
const labels = skills.map(skillLabel);
|
|
281
|
+
const labelToName = new Map(labels.map((label, index) => [label, skills[index]!.name]));
|
|
282
|
+
let selected: string | undefined;
|
|
283
|
+
try {
|
|
284
|
+
selected = await ctx.ui.select(`Export skill from ${projectSkillsLabel(ctx)}/ to ${localSkillsLabel()}`, labels);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
ignoreStaleExtensionContextError(error);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (!selected) return;
|
|
290
|
+
|
|
291
|
+
const name = labelToName.get(selected);
|
|
292
|
+
if (!name) return;
|
|
293
|
+
await transferSkill(ctx, name, t);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function exportByName(ctx: ExtensionCommandContext, name: string): Promise<void> {
|
|
297
|
+
const skills = await listProjectSkills(ctx);
|
|
298
|
+
const match = skills.find((entry) => entry.name === name);
|
|
299
|
+
if (!match) {
|
|
300
|
+
const available = skills.length > 0 ? skills.map((s) => s.name).join(", ") : "(none)";
|
|
301
|
+
notify(ctx, `No skill named "${name}" in ${projectSkillsLabel(ctx)}/. Installed: ${available}`, "error");
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
await transferSkill(ctx, match.name, exportTransfer(ctx));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export default function skillInstaller(pi: ExtensionAPI): void {
|
|
308
|
+
pi.registerCommand(INSTALL_COMMAND, {
|
|
309
|
+
description: `Install a skill from ${localSkillsLabel()} into the current project's ${PROJECT_DIR}/${PROJECT_SKILLS_SUBDIR}`,
|
|
310
|
+
handler: async (args: string, ctx) => {
|
|
311
|
+
const name = args.trim();
|
|
312
|
+
try {
|
|
313
|
+
if (name) await installByName(ctx, name);
|
|
314
|
+
else await showInstallMenu(ctx);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
ignoreStaleExtensionContextError(error);
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
pi.registerCommand(EXPORT_COMMAND, {
|
|
322
|
+
description: `Export a skill from the current project's ${PROJECT_DIR}/${PROJECT_SKILLS_SUBDIR} to ${localSkillsLabel()} for reuse in other projects`,
|
|
323
|
+
handler: async (args: string, ctx) => {
|
|
324
|
+
const name = args.trim();
|
|
325
|
+
try {
|
|
326
|
+
if (name) await exportByName(ctx, name);
|
|
327
|
+
else await showExportMenu(ctx);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
ignoreStaleExtensionContextError(error);
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: spec-lite
|
|
3
|
+
description: Use this skill for creating, updating, or checking lightweight specs for existing code, risky changes, public behavior, bug fixes, or spec-code drift. Use it before or during non-trivial changes that touch security, data, persistence, public APIs, CLI/UI contracts, integrations, concurrency, architecture, or user-visible behavior with edge cases. Do not use it for trivial local edits.
|
|
4
|
+
argument-hint: "behavior, change, bug, or spec path to document/check"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Spec Lite
|
|
8
|
+
|
|
9
|
+
Use the lightest spec process that preserves correctness. The goal is to make behavior and risk explicit without turning every change into a heavyweight design process.
|
|
10
|
+
|
|
11
|
+
## Core rules
|
|
12
|
+
|
|
13
|
+
- Do not change production code unless the user explicitly asks for implementation.
|
|
14
|
+
- Specs describe either current behavior or intended behavior; label which one clearly.
|
|
15
|
+
- Do not invent behavior. If evidence is missing or ambiguous, mark uncertainty explicitly.
|
|
16
|
+
- Prefer compact specs over exhaustive documents.
|
|
17
|
+
- Load only relevant files, tests, schemas, docs, migrations, entrypoints, and existing specs.
|
|
18
|
+
- Do not load all specs by default.
|
|
19
|
+
- Keep the spec close to the codebase convention when a specs directory, ADR folder, or feature-doc pattern already exists.
|
|
20
|
+
|
|
21
|
+
## When to create or update a spec
|
|
22
|
+
|
|
23
|
+
Create or update a lightweight spec when the work affects any of these areas:
|
|
24
|
+
|
|
25
|
+
- security, privacy, auth, or permissions;
|
|
26
|
+
- data, schemas, migrations, persistence, or file formats;
|
|
27
|
+
- public APIs, CLI behavior, UI contracts, events, or other external contracts;
|
|
28
|
+
- external integrations;
|
|
29
|
+
- payments, destructive operations, or irreversible workflows;
|
|
30
|
+
- background jobs, concurrency, cancellation, retries, or async behavior;
|
|
31
|
+
- cross-cutting architecture;
|
|
32
|
+
- user-visible behavior with non-trivial edge cases;
|
|
33
|
+
- bug fixes where expected behavior, compatibility, or regression risk is unclear;
|
|
34
|
+
- suspected drift between existing specs/docs/tests and code.
|
|
35
|
+
|
|
36
|
+
Do not require a spec for trivial local edits, mechanical renames, formatting-only changes, or obvious one-file fixes with no broader behavior impact.
|
|
37
|
+
|
|
38
|
+
## As-is spec workflow
|
|
39
|
+
|
|
40
|
+
Use this workflow for existing code or spec-code drift checks:
|
|
41
|
+
|
|
42
|
+
1. Identify the narrow behavior or contract to document.
|
|
43
|
+
2. Inspect relevant code, tests, docs, schemas, migrations, and entrypoints.
|
|
44
|
+
3. Identify current behavior and known gaps.
|
|
45
|
+
4. Mark claims using evidence labels:
|
|
46
|
+
- confirmed by code;
|
|
47
|
+
- confirmed by tests;
|
|
48
|
+
- confirmed by docs;
|
|
49
|
+
- inferred;
|
|
50
|
+
- unknown.
|
|
51
|
+
5. Create or update a compact as-is spec.
|
|
52
|
+
6. If drift is found, report it separately from confirmed behavior.
|
|
53
|
+
|
|
54
|
+
## Change spec workflow
|
|
55
|
+
|
|
56
|
+
Use this workflow before risky implementation:
|
|
57
|
+
|
|
58
|
+
1. Define the goal.
|
|
59
|
+
2. Define scope and non-goals.
|
|
60
|
+
3. State expected behavior.
|
|
61
|
+
4. List affected contracts.
|
|
62
|
+
5. List risks, compatibility concerns, and migration concerns if any.
|
|
63
|
+
6. Define a verification path.
|
|
64
|
+
7. Then implement only the scoped change, if implementation was requested.
|
|
65
|
+
|
|
66
|
+
## Spec template
|
|
67
|
+
|
|
68
|
+
Use this template by default. Remove sections that are genuinely irrelevant, but keep `Type`, `Goal`, `Behavior`, `Related files`, `Verification`, and `Evidence` when possible.
|
|
69
|
+
|
|
70
|
+
```markdown
|
|
71
|
+
# Spec: <name>
|
|
72
|
+
|
|
73
|
+
## Type
|
|
74
|
+
|
|
75
|
+
As-is | Change
|
|
76
|
+
|
|
77
|
+
## Goal
|
|
78
|
+
|
|
79
|
+
...
|
|
80
|
+
|
|
81
|
+
## Scope
|
|
82
|
+
|
|
83
|
+
...
|
|
84
|
+
|
|
85
|
+
## Non-goals
|
|
86
|
+
|
|
87
|
+
...
|
|
88
|
+
|
|
89
|
+
## Behavior
|
|
90
|
+
|
|
91
|
+
...
|
|
92
|
+
|
|
93
|
+
## Contracts
|
|
94
|
+
|
|
95
|
+
Inputs, outputs, APIs, CLI, UI, events, files, schemas.
|
|
96
|
+
|
|
97
|
+
## Invariants
|
|
98
|
+
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
## Edge cases
|
|
102
|
+
|
|
103
|
+
...
|
|
104
|
+
|
|
105
|
+
## Side effects
|
|
106
|
+
|
|
107
|
+
...
|
|
108
|
+
|
|
109
|
+
## Related files
|
|
110
|
+
|
|
111
|
+
...
|
|
112
|
+
|
|
113
|
+
## Verification
|
|
114
|
+
|
|
115
|
+
...
|
|
116
|
+
|
|
117
|
+
## Risks / unknowns
|
|
118
|
+
|
|
119
|
+
...
|
|
120
|
+
|
|
121
|
+
## Evidence
|
|
122
|
+
|
|
123
|
+
- Confirmed by code:
|
|
124
|
+
- Confirmed by tests:
|
|
125
|
+
- Confirmed by docs:
|
|
126
|
+
- Inferred:
|
|
127
|
+
- Unknown:
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Output guidance
|
|
131
|
+
|
|
132
|
+
When the user asks only for a spec, write or update the spec and summarize:
|
|
133
|
+
|
|
134
|
+
- spec path;
|
|
135
|
+
- whether it is as-is or change-oriented;
|
|
136
|
+
- strongest evidence sources;
|
|
137
|
+
- important unknowns or drift;
|
|
138
|
+
- whether production code was left untouched.
|
|
139
|
+
|
|
140
|
+
When the user asks for implementation too, keep the spec scoped and practical, then implement only after the expected behavior and verification path are clear.
|