213 lines
7.4 KiB
JavaScript
213 lines
7.4 KiB
JavaScript
/**
|
|
* Superpowers bootstrap for DeepSeek Harness.
|
|
*
|
|
* Claude Code delivers `using-superpowers` through a SessionStart hook. DSH has
|
|
* no shell hook layer, so this module builds the same bootstrap text and the
|
|
* plugin injects it once per session from `agent/pre-step`. The marker at the
|
|
* top makes the injection idempotent and lets a later step re-inject after
|
|
* compaction (the old message is no longer in `session.surface.nodes`).
|
|
*/
|
|
|
|
import { createHash } from 'node:crypto'
|
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
import { homedir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
|
|
const DEFAULT_MAPPING = [
|
|
'Use the tools reported by the harness. In particular:',
|
|
'- read/write/edit/bash for files and commands;',
|
|
'- skill({ name }) to load a skill (no `superpowers:` namespace);',
|
|
'- todo_write for task tracking;',
|
|
'- subagent for asynchronous delegation, or workflow for synchronous fan-out.',
|
|
].join('\n')
|
|
|
|
const cache = new Map()
|
|
|
|
/** Remove a leading YAML frontmatter block, if present. */
|
|
export function stripFrontmatter(text) {
|
|
if (typeof text !== 'string') return ''
|
|
return text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trim()
|
|
}
|
|
|
|
/** Truncate to a UTF-8 byte budget, keeping a visible notice. */
|
|
export function truncateUtf8(text, maxBytes) {
|
|
const limit = Math.max(1024, Number(maxBytes) || 65_536)
|
|
if (Buffer.byteLength(text, 'utf8') <= limit) return text
|
|
const suffix = '\n\n[bootstrap truncated by dsh-harness maxBytes]\n'
|
|
const head = Buffer.from(text, 'utf8')
|
|
.subarray(0, Math.max(0, limit - Buffer.byteLength(suffix, 'utf8')))
|
|
.toString('utf8')
|
|
.replace(/\uFFFD+$/u, '')
|
|
return head + suffix
|
|
}
|
|
|
|
/** Sort dotted version directory names without a semver dependency. */
|
|
export function compareVersions(a, b) {
|
|
const pa = String(a).split(/[.-]/)
|
|
const pb = String(b).split(/[.-]/)
|
|
const n = Math.max(pa.length, pb.length)
|
|
for (let i = 0; i < n; i += 1) {
|
|
const x = pa[i] ?? ''
|
|
const y = pb[i] ?? ''
|
|
const nx = Number(x)
|
|
const ny = Number(y)
|
|
if (Number.isFinite(nx) && Number.isFinite(ny) && nx !== ny) return nx - ny
|
|
const c = String(x).localeCompare(String(y), undefined, { numeric: true })
|
|
if (c !== 0) return c
|
|
}
|
|
return 0
|
|
}
|
|
|
|
/** Candidate `using-superpowers/SKILL.md` paths, most specific first. */
|
|
export function superpowersCandidates({ home = homedir(), env = process.env } = {}) {
|
|
const out = []
|
|
if (env.DSH_SUPERPOWERS_SKILL) out.push(env.DSH_SUPERPOWERS_SKILL)
|
|
|
|
const root = join(home, '.claude', 'plugins', 'cache', 'claude-plugins-official', 'superpowers')
|
|
if (existsSync(root)) {
|
|
let entries = []
|
|
try {
|
|
entries = readdirSync(root, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => entry.name)
|
|
.sort(compareVersions)
|
|
.reverse()
|
|
} catch {
|
|
entries = []
|
|
}
|
|
for (const version of entries) {
|
|
out.push(join(root, version, 'skills', 'using-superpowers', 'SKILL.md'))
|
|
}
|
|
}
|
|
|
|
out.push(join(home, '.claude', 'skills', 'using-superpowers', 'SKILL.md'))
|
|
out.push(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'))
|
|
out.push(join(home, '.agents', 'skills', 'using-superpowers', 'SKILL.md'))
|
|
return out
|
|
}
|
|
|
|
/** Resolve the skill file: explicit config, env override, installed plugin cache, then skill roots. */
|
|
export function resolveBootstrapSkill({ configuredPath = '', env = process.env, home = homedir() } = {}) {
|
|
if (configuredPath && existsSync(configuredPath)) return configuredPath
|
|
return superpowersCandidates({ home, env }).find((path) => existsSync(path))
|
|
}
|
|
|
|
/** Build the exact text injected before the first model step. */
|
|
export function buildBootstrapText({ skillBody, mapping, marker }) {
|
|
return [
|
|
marker,
|
|
'<EXTREMELY_IMPORTANT>',
|
|
'You have superpowers.',
|
|
'',
|
|
"**Below is the full content of your 'using-superpowers' skill — your introduction to using skills. On this harness, load other skills with the `skill` tool. There is no `superpowers:` namespace; use the skill name, for example `brainstorming`.**",
|
|
'',
|
|
skillBody,
|
|
'',
|
|
'## DSH tool mapping',
|
|
'',
|
|
mapping,
|
|
'</EXTREMELY_IMPORTANT>',
|
|
'',
|
|
].join('\n')
|
|
}
|
|
|
|
function statKey(path) {
|
|
try {
|
|
const stat = statSync(path)
|
|
return `${stat.mtimeMs}:${stat.size}`
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build (or reuse) the bootstrap payload.
|
|
* @returns {{path: string, marker: string, text: string}|undefined}
|
|
*/
|
|
export function prepareBootstrap({
|
|
skillPath = '',
|
|
mappingPath = '',
|
|
maxBytes = 65_536,
|
|
logger,
|
|
env = process.env,
|
|
home = homedir(),
|
|
} = {}) {
|
|
const resolved = resolveBootstrapSkill({ configuredPath: skillPath, env, home })
|
|
if (!resolved) {
|
|
logger?.warn?.('[dsh-harness] superpowers bootstrap skill not found; skipping injection')
|
|
return undefined
|
|
}
|
|
|
|
const mappingFile = mappingPath && existsSync(mappingPath) ? mappingPath : ''
|
|
const key = `${resolved}|${mappingFile}|${maxBytes}`
|
|
const fingerprint = `${statKey(resolved)}|${mappingFile ? statKey(mappingFile) : 'default'}`
|
|
const hit = cache.get(key)
|
|
if (hit && hit.fingerprint === fingerprint) return hit.value
|
|
|
|
let skillText
|
|
try {
|
|
skillText = readFileSync(resolved, 'utf8')
|
|
} catch (error) {
|
|
logger?.warn?.('[dsh-harness] cannot read superpowers bootstrap %s: %s', resolved, error?.message ?? error)
|
|
return undefined
|
|
}
|
|
|
|
const skillBody = stripFrontmatter(skillText)
|
|
if (!skillBody) {
|
|
logger?.warn?.('[dsh-harness] superpowers bootstrap is empty: %s', resolved)
|
|
return undefined
|
|
}
|
|
|
|
let mapping = DEFAULT_MAPPING
|
|
if (mappingFile) {
|
|
try {
|
|
mapping = readFileSync(mappingFile, 'utf8').trim() || DEFAULT_MAPPING
|
|
} catch {
|
|
mapping = DEFAULT_MAPPING
|
|
}
|
|
}
|
|
|
|
const hash = createHash('sha256').update(skillBody).update('\0').update(mapping).digest('hex').slice(0, 16)
|
|
const marker = `<!-- DSH-HARNESS-BOOTSTRAP:${hash} -->`
|
|
const text = truncateUtf8(buildBootstrapText({ skillBody, mapping, marker }), maxBytes)
|
|
const value = { path: resolved, marker, text }
|
|
cache.set(key, { fingerprint, value })
|
|
return value
|
|
}
|
|
|
|
/** Read model-visible text from a session message or a session event payload. */
|
|
export function textOf(value) {
|
|
if (typeof value === 'string') return value
|
|
if (Array.isArray(value)) return value.map(textOf).join('\n')
|
|
if (!value || typeof value !== 'object') return ''
|
|
if (Array.isArray(value.content)) return value.content.map(textOf).join('\n')
|
|
if (typeof value.content === 'string') return value.content
|
|
if (typeof value.text === 'string') return value.text
|
|
return ''
|
|
}
|
|
|
|
/**
|
|
* Whether this exact bootstrap marker is still visible in the session.
|
|
* Messages under consideration by the current pre-step are checked first;
|
|
* then the durable visible surface, which drops a message after compaction.
|
|
*/
|
|
export function isBootstrapVisible(agent, messages, marker) {
|
|
if (!marker) return false
|
|
if (Array.isArray(messages) && messages.some((message) => textOf(message).includes(marker))) return true
|
|
|
|
const session = agent?.session
|
|
if (!session || typeof session.eventAt !== 'function') return false
|
|
const nodes = session.surface?.nodes
|
|
if (!nodes) return false
|
|
|
|
for (const seq of new Set(nodes)) {
|
|
try {
|
|
const event = session.eventAt(seq)
|
|
if (event?.type === 'user/message' && textOf(event.data).includes(marker)) return true
|
|
} catch {
|
|
// A seq outside the current surface is not evidence of visibility.
|
|
}
|
|
}
|
|
return false
|
|
}
|