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.
- package/README.md +37 -16
- package/dist/index.js +3888 -0
- package/package.json +7 -8
- package/index.ts +0 -3646
- package/shared/akm-version.ts +0 -48
- package/shared/feedback-signals.ts +0 -113
- package/shared/memory-events.ts +0 -95
- package/shared/recall-policy.ts +0 -74
- package/shared/redaction.ts +0 -292
- package/shared/ref-extraction.ts +0 -120
- package/shared/state-files.ts +0 -98
- package/shared/vendor-semver.ts +0 -112
package/shared/ref-extraction.ts
DELETED
|
@@ -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
|
-
}
|
package/shared/state-files.ts
DELETED
|
@@ -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
|
-
}
|
package/shared/vendor-semver.ts
DELETED
|
@@ -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
|
-
}
|