akm-opencode 0.9.9202609021827 → 0.10.261002-beta

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.
@@ -1,120 +0,0 @@
1
- /**
2
- * AKM 0.9 ref extraction and local bundle validation.
3
- *
4
- * Session checkpoints can contain command bodies, heredocs, and serialized
5
- * tool output. Extraction is deliberately permissive, then candidates are
6
- * retained only when their concept ID resolves inside a provided bundle root.
7
- * This keeps the hook subprocess-free and drops ref-shaped string literals.
8
- */
9
-
10
- import { existsSync, statSync } from "node:fs";
11
- import path from "node:path";
12
-
13
- // Passive extraction is intentionally narrower than AKM's explicit ref parser:
14
- // only standard concept roots are observed. This prevents ordinary paths such
15
- // as src/app.ts from becoming automatic feedback targets.
16
- //
17
- // The concept-root list is the exact set of directories `akm bundle create`
18
- // scaffolds in 0.9 (agents commands env facts instructions knowledge lessons
19
- // memories scripts secrets sessions skills tasks workflows), which matches the
20
- // `assetTypes` array reported by `akm info --format json` modulo pluralization.
21
- // `wikis` was dropped for 0.9 — it is neither an asset type nor a scaffolded
22
- // directory — and facts/instructions/sessions were added. Keep this list byte
23
- // for byte in sync with opencode/index.ts and evals/tier2/metrics/feedback.ts.
24
- const REF_PATTERN =
25
- /(?<![A-Za-z0-9@._+/:=-])(?:[A-Za-z0-9@._+-]+\/\/)?(?:agents|commands|env|facts|instructions|knowledge|lessons|memories|scripts|secrets|sessions|skills|tasks|workflows)\/[A-Za-z0-9._/-]+(?:#[A-Za-z0-9._~!$&'()*+,;=:@%/?-]+)?(?![A-Za-z0-9@._+/#$=-])/g;
26
- const AKM_REF_STRICT =
27
- /^(?:[A-Za-z0-9@._+-]+\/\/)?(?:agents|commands|env|facts|instructions|knowledge|lessons|memories|scripts|secrets|sessions|skills|tasks|workflows)\/[A-Za-z0-9._/-]+(?:#[A-Za-z0-9._~!$&'()*+,;=:@%/?-]+)?$/;
28
- const EDGE_PUNCTUATION = new Set([".", ",", ";", ":", "!", "?", "(", ")", "[", "]", "{", "}", "'", "\"", "`"]);
29
-
30
- function normalizeToken(token: string): string {
31
- let start = 0;
32
- let end = token.length;
33
- while (start < end && EDGE_PUNCTUATION.has(token[start] ?? "")) start += 1;
34
- while (end > start && EDGE_PUNCTUATION.has(token[end - 1] ?? "")) end -= 1;
35
- return token.slice(start, end);
36
- }
37
-
38
- /** Return all ref-shaped tokens in first-occurrence order, deduplicated. */
39
- export function extractAllRefs(text: string): string[] {
40
- if (!text) return [];
41
- const refs = new Set<string>();
42
- for (const match of text.match(REF_PATTERN) ?? []) {
43
- const normalized = normalizeToken(match);
44
- if (AKM_REF_STRICT.test(normalized)) refs.add(normalized);
45
- }
46
- return [...refs];
47
- }
48
-
49
- /** Return whitespace-delimited tokens that are complete AKM refs. */
50
- export function extractAkmRefsFromString(text: string): string[] {
51
- const refs = new Set<string>();
52
- for (const token of text.split(/\s+/)) {
53
- const normalized = normalizeToken(token);
54
- if (normalized && AKM_REF_STRICT.test(normalized)) refs.add(normalized);
55
- }
56
- return [...refs];
57
- }
58
-
59
- interface NormalizedRef {
60
- canonical: string;
61
- conceptId: string;
62
- }
63
-
64
- function normalizeCandidate(candidate: string): NormalizedRef | null {
65
- const canonical = normalizeToken(candidate);
66
- if (!AKM_REF_STRICT.test(canonical)) return null;
67
-
68
- const withoutFragment = canonical.split("#", 1)[0] ?? "";
69
- const separator = withoutFragment.indexOf("//");
70
- const conceptId = separator === -1 ? withoutFragment : withoutFragment.slice(separator + 2);
71
- const segments = conceptId.split("/");
72
- if (segments.some((segment) => !segment || segment === "." || segment === "..")) return null;
73
-
74
- return { canonical, conceptId };
75
- }
76
-
77
- function isFile(file: string): boolean {
78
- try {
79
- return existsSync(file) && statSync(file).isFile();
80
- } catch {
81
- return false;
82
- }
83
- }
84
-
85
- /** Resolve a concept ID under any local bundle root without invoking AKM. */
86
- function conceptExistsInAnyBundle(conceptId: string, bundleRoots: readonly string[]): boolean {
87
- for (const root of bundleRoots) {
88
- if (!root) continue;
89
- const resolvedRoot = path.resolve(root);
90
- const directPath = path.resolve(resolvedRoot, conceptId);
91
- if (directPath !== resolvedRoot && !directPath.startsWith(`${resolvedRoot}${path.sep}`)) continue;
92
-
93
- if (isFile(directPath) || isFile(`${directPath}.md`)) return true;
94
- if (isFile(path.join(directPath, "SKILL.md"))) return true;
95
- if (conceptId.startsWith("memories/") && isFile(`${directPath}.derived.md`)) return true;
96
- }
97
- return false;
98
- }
99
-
100
- /** Extract and retain only refs whose concept IDs exist in a local bundle. */
101
- export function validateLiveRefs(text: string, bundleRoots: readonly string[]): string[] {
102
- return validateRefCandidates(extractAllRefs(text), bundleRoots);
103
- }
104
-
105
- /**
106
- * Validate pre-extracted refs against local bundle roots. Bundle qualifiers and
107
- * fragments are preserved in the result but do not alter local path lookup.
108
- */
109
- export function validateRefCandidates(candidates: readonly string[], bundleRoots: readonly string[]): string[] {
110
- if (!candidates || candidates.length === 0) return [];
111
- const roots = bundleRoots.filter(Boolean);
112
- if (roots.length === 0) return [];
113
-
114
- const refs = new Set<string>();
115
- for (const candidate of candidates) {
116
- const normalized = normalizeCandidate(candidate);
117
- if (normalized && conceptExistsInAnyBundle(normalized.conceptId, roots)) refs.add(normalized.canonical);
118
- }
119
- return [...refs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
120
- }
@@ -1,98 +0,0 @@
1
- /**
2
- * Shared helpers for the append-only state files both plugins keep under their
3
- * harness state dir (session.log, feedback.log, memory.log, quality-cache.tsv,
4
- * sessions/<sid>.md, extract.log, events.jsonl).
5
- *
6
- * This module is the single copy of three primitives that were previously
7
- * duplicated near-verbatim in claude/hooks/akm-hook.ts and ./memory-events.ts:
8
- *
9
- * chmodSafe() best-effort owner-only hardening that never throws
10
- * atomicWriteFileSync() write-temp-then-rename, with temp cleanup on failure
11
- * rotateIfOversized() size cap that keeps the newest half of the file
12
- *
13
- * Constraints this module must keep satisfying:
14
- * - Zero third-party imports. claude/hooks/akm-hook.ts runs as a bare Bun
15
- * script with no node_modules at hook-execution time, so only node:* builtins
16
- * are importable here.
17
- * - It is vendored into the published OpenCode tarball by
18
- * opencode/scripts/vendor-shared.mjs, which copies every *.ts in
19
- * claude/shared/ — no registration step is needed for a new file.
20
- */
21
-
22
- import { chmodSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
23
-
24
- /**
25
- * Best-effort chmod. State files can hold prompt fragments, ref names and
26
- * (despite redaction) sensitive contextual data, so they are locked to
27
- * owner-only. Filesystems and platforms without POSIX mode (Windows, FAT, some
28
- * FUSE mounts) silently skip: a hardening attempt must never crash a hook.
29
- */
30
- export function chmodSafe(target: string, mode: number): void {
31
- try {
32
- chmodSync(target, mode)
33
- } catch {
34
- // Intentionally ignored — see the doc comment above.
35
- }
36
- }
37
-
38
- /**
39
- * Write a file atomically: temp file in the same directory, then rename over
40
- * the target. rename(2) is atomic on POSIX (and on Windows via Node's
41
- * implementation), so a concurrent reader always observes either the old
42
- * content in full or the new content in full — never a torn/partial write.
43
- *
44
- * A failed rename removes the temp file (nothing else ever prunes it) and then
45
- * rethrows, so a caller keeps its own error semantics — rotateIfOversized(),
46
- * the only caller today, swallows it.
47
- */
48
- export function atomicWriteFileSync(filePath: string, content: string, mode?: number): void {
49
- const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`
50
- writeFileSync(tmpPath, content)
51
- if (mode !== undefined) chmodSafe(tmpPath, mode)
52
- try {
53
- renameSync(tmpPath, filePath)
54
- } catch (error) {
55
- // Don't orphan the temp file when the swap fails (EXDEV, permissions, a
56
- // concurrent unlink of the target dir, ...).
57
- try {
58
- rmSync(tmpPath, { force: true })
59
- } catch {}
60
- throw error
61
- }
62
- }
63
-
64
- // State-file rotation/caps (release-0.9.0 review §2 "Unbounded state growth"):
65
- // every append-only file under the harness state dir previously grew without
66
- // bound for the lifetime of the machine. AKM_PLUGIN_MAX_LOG_BYTES (default
67
- // 1 MiB) is the shared cap for all of them.
68
- const MAX_LOG_BYTES = (() => {
69
- const raw = Number(process.env.AKM_PLUGIN_MAX_LOG_BYTES)
70
- return Number.isFinite(raw) && raw > 0 ? raw : 1024 * 1024
71
- })()
72
-
73
- /**
74
- * Before an append: if the file already exceeds AKM_PLUGIN_MAX_LOG_BYTES,
75
- * rewrite it down to its newest half (by line count) via write-temp-then-rename
76
- * so a concurrent reader/writer never observes a truncated or partial file.
77
- *
78
- * Best-effort and never throws: a failed attempt just means the file keeps
79
- * growing until the next successful append/rotate.
80
- */
81
- export function rotateIfOversized(filePath: string): void {
82
- try {
83
- const stat = statSync(filePath)
84
- if (stat.size <= MAX_LOG_BYTES) return
85
- const lines = readFileSync(filePath, "utf8").split("\n")
86
- if (lines[lines.length - 1] === "") lines.pop()
87
- // Keep-at-least-one-line guard: a single line larger than the cap would
88
- // make slice(ceil(len/2)) empty and rotation would erase the file instead
89
- // of capping it. Always retain the newest line.
90
- const keep = lines.length <= 1 ? lines : lines.slice(Math.ceil(lines.length / 2))
91
- // 0o600 on the replacement: without it the rewritten file would come back
92
- // at the umask default and quietly drop the owner-only posture the
93
- // originals are created with.
94
- atomicWriteFileSync(filePath, keep.length > 0 ? `${keep.join("\n")}\n` : "", 0o600)
95
- } catch {
96
- // Rotation is best-effort and must never throw — see the doc comment.
97
- }
98
- }
@@ -1,112 +0,0 @@
1
- // Minimal vendored semver implementation. Covers exactly the subset used by
2
- // akm-hook.ts (`valid`, `satisfies` with caret ranges + `||` disjunction).
3
- //
4
- // Why vendored: the Claude plugin ships via `/plugin marketplace add` which
5
- // just clones the repo — `node_modules` is gitignored and no install runs on
6
- // the user's machine. Relying on Bun's auto-install for `semver` makes every
7
- // hook silently no-op when auto-install is disabled, offline, or rate-limited.
8
- // Vendoring this ~80 LOC removes the runtime dependency entirely.
9
-
10
- type Parsed = {
11
- major: number
12
- minor: number
13
- patch: number
14
- prerelease: Array<string | number>
15
- }
16
-
17
- const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/
18
-
19
- function parse(input: string): Parsed | null {
20
- const m = String(input).trim().replace(/^v/, "").match(SEMVER_RE)
21
- if (!m) return null
22
- const [, maj, min, pat, pre] = m
23
- const prerelease = pre ? pre.split(".").map((p) => (/^\d+$/.test(p) ? Number(p) : p)) : []
24
- return { major: Number(maj), minor: Number(min), patch: Number(pat), prerelease }
25
- }
26
-
27
- export function valid(input: string | null | undefined): string | null {
28
- if (!input) return null
29
- if (!parse(input)) return null
30
- return String(input).trim().replace(/^v/, "")
31
- }
32
-
33
- function cmp(a: Parsed, b: Parsed): number {
34
- if (a.major !== b.major) return a.major < b.major ? -1 : 1
35
- if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1
36
- if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1
37
- if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0
38
- // A version without prerelease has HIGHER precedence than one with.
39
- if (a.prerelease.length === 0) return 1
40
- if (b.prerelease.length === 0) return -1
41
- const n = Math.min(a.prerelease.length, b.prerelease.length)
42
- for (let i = 0; i < n; i++) {
43
- const ai = a.prerelease[i]
44
- const bi = b.prerelease[i]
45
- const aNum = typeof ai === "number"
46
- const bNum = typeof bi === "number"
47
- if (aNum && !bNum) return -1
48
- if (!aNum && bNum) return 1
49
- if (aNum && bNum) {
50
- if (ai !== bi) return (ai as number) < (bi as number) ? -1 : 1
51
- } else {
52
- const s = String(ai)
53
- const t = String(bi)
54
- if (s !== t) return s < t ? -1 : 1
55
- }
56
- }
57
- if (a.prerelease.length !== b.prerelease.length) return a.prerelease.length < b.prerelease.length ? -1 : 1
58
- return 0
59
- }
60
-
61
- type CaretRange = { min: Parsed; maxExclusive: Parsed; allowPrereleases: boolean }
62
-
63
- function parseCaretRange(token: string): CaretRange | null {
64
- const m = token.trim().match(/^\^(.+)$/)
65
- if (!m) return null
66
- const min = parse(m[1])
67
- if (!min) return null
68
- let maxExclusive: Parsed
69
- if (min.major > 0) {
70
- maxExclusive = { major: min.major + 1, minor: 0, patch: 0, prerelease: [] }
71
- } else if (min.minor > 0) {
72
- maxExclusive = { major: 0, minor: min.minor + 1, patch: 0, prerelease: [] }
73
- } else {
74
- maxExclusive = { major: 0, minor: 0, patch: min.patch + 1, prerelease: [] }
75
- }
76
- return { min, maxExclusive, allowPrereleases: min.prerelease.length > 0 }
77
- }
78
-
79
- export function satisfies(version: string, range: string): boolean {
80
- const v = parse(version)
81
- if (!v) return false
82
- const branches = range.split("||").map((s) => s.trim())
83
- for (const branch of branches) {
84
- const tokens = branch.split(/\s+/).filter(Boolean)
85
- let ok = true
86
- for (const t of tokens) {
87
- const r = parseCaretRange(t)
88
- if (!r) {
89
- ok = false
90
- break
91
- }
92
- if (cmp(v, r.min) < 0 || cmp(v, r.maxExclusive) >= 0) {
93
- ok = false
94
- break
95
- }
96
- // node-semver default: prereleases only satisfy a range whose lower
97
- // bound also has a prerelease AND shares the same major.minor.patch.
98
- if (v.prerelease.length > 0) {
99
- if (!r.allowPrereleases) {
100
- ok = false
101
- break
102
- }
103
- if (v.major !== r.min.major || v.minor !== r.min.minor || v.patch !== r.min.patch) {
104
- ok = false
105
- break
106
- }
107
- }
108
- }
109
- if (ok) return true
110
- }
111
- return false
112
- }