akm-opencode 0.8.0-rc1 → 0.8.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/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "akm-opencode",
3
- "version": "0.8.0-rc1",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
- "description": "OpenCode plugin for AKM v0.7.0 - search, show, and manage extension assets via the akm CLI, including vaults, wikis, workflows, the proposal queue, lesson assets, and reflect/propose/distill commands, with agentic hooks that auto-load relevant stash assets, record feedback, and harvest session memories so the stash improves every session.",
5
+ "description": "OpenCode plugin for AKM v0.8.0 - search, show, and manage extension assets via the akm CLI, including vaults, wikis, workflows, the proposal queue, lesson assets, and improve/propose commands, with agentic hooks that auto-load relevant stash assets, record feedback, and harvest session memories so the stash improves every session.",
6
6
  "keywords": [
7
7
  "opencode",
8
8
  "opencode-ai",
@@ -27,13 +27,20 @@
27
27
  "index.ts",
28
28
  "agent/",
29
29
  "commands/",
30
+ "shared/",
30
31
  "README.md"
31
32
  ],
32
33
  "main": "./index.ts",
33
34
  "publishConfig": {
34
35
  "access": "public"
35
36
  },
37
+ "scripts": {
38
+ "prepack": "node ./scripts/vendor-shared.mjs",
39
+ "postpack": "node ./scripts/unvendor-shared.mjs"
40
+ },
36
41
  "dependencies": {
37
- "@opencode-ai/plugin": "^1.2.20"
42
+ "@opencode-ai/plugin": "^1.2.20",
43
+ "akm-cli": "^0.8.0",
44
+ "semver": "^7.7.2"
38
45
  }
39
46
  }
@@ -0,0 +1,74 @@
1
+ export type AkmFeedbackSignal = {
2
+ ref: string
3
+ polarity: "positive" | "negative"
4
+ confidence: number
5
+ source:
6
+ | "explicit_user_feedback"
7
+ | "tool_success"
8
+ | "tool_failure"
9
+ | "retrospective_positive"
10
+ | "retrospective_negative"
11
+ | "workflow_completion"
12
+ | "proposal_acceptance"
13
+ | "proposal_rejection"
14
+ | "curator_assessment"
15
+ note: string
16
+ sessionId?: string
17
+ harness: "claude-code" | "opencode"
18
+ }
19
+
20
+ export function getAutoFeedbackMinConfidence(): number {
21
+ const raw = Number(process.env.AKM_AUTO_FEEDBACK_MIN_CONFIDENCE ?? "0.6")
22
+ return Number.isFinite(raw) ? raw : 0.6
23
+ }
24
+
25
+ export function classifyFeedbackSignal(input: {
26
+ ref: string
27
+ polarity: "positive" | "negative"
28
+ harness: "claude-code" | "opencode"
29
+ sessionId?: string
30
+ directInput?: boolean
31
+ explicitUser?: boolean
32
+ retrospective?: boolean
33
+ workflowCompletion?: boolean
34
+ proposalAction?: "accept" | "reject"
35
+ curatorAssessment?: boolean
36
+ note?: string
37
+ }): AkmFeedbackSignal {
38
+ let source: AkmFeedbackSignal["source"] = input.polarity === "positive" ? "tool_success" : "tool_failure"
39
+ let confidence = input.directInput ? 0.65 : 0.25
40
+
41
+ if (input.explicitUser) {
42
+ source = "explicit_user_feedback"
43
+ confidence = 0.95
44
+ } else if (input.retrospective) {
45
+ source = input.polarity === "positive" ? "retrospective_positive" : "retrospective_negative"
46
+ confidence = input.polarity === "positive" ? 0.7 : 0.8
47
+ } else if (input.workflowCompletion) {
48
+ source = "workflow_completion"
49
+ confidence = 0.85
50
+ } else if (input.proposalAction === "accept") {
51
+ source = "proposal_acceptance"
52
+ confidence = 0.95
53
+ } else if (input.proposalAction === "reject") {
54
+ source = "proposal_rejection"
55
+ confidence = 0.85
56
+ } else if (input.curatorAssessment) {
57
+ source = "curator_assessment"
58
+ confidence = 0.75
59
+ }
60
+
61
+ return {
62
+ ref: input.ref,
63
+ polarity: input.polarity,
64
+ confidence,
65
+ source,
66
+ note: input.note ?? `${input.harness} auto: source=${source}; confidence=${confidence.toFixed(2)}`,
67
+ sessionId: input.sessionId,
68
+ harness: input.harness,
69
+ }
70
+ }
71
+
72
+ export function shouldSubmitAutomaticFeedback(signal: AkmFeedbackSignal): boolean {
73
+ return signal.confidence >= getAutoFeedbackMinConfidence()
74
+ }
@@ -0,0 +1,196 @@
1
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+ import { redactObject } from "./redaction"
4
+
5
+ // Memory candidates can contain prompt fragments, ref names, and (despite
6
+ // redaction) potentially sensitive contextual data harvested from session
7
+ // activity. Lock the on-disk file down to user-only read/write so multi-user
8
+ // hosts (CI runners, shared VMs, dev sandboxes) cannot side-read another
9
+ // user's stash signals.
10
+ function chmodSafe(filePath: string, mode: number): void {
11
+ try {
12
+ chmodSync(filePath, mode)
13
+ } catch {
14
+ // Best-effort: filesystems without POSIX mode (FAT, some FUSE mounts) or
15
+ // platforms where chmod is a no-op (Windows) silently skip. Don't crash
16
+ // the hook over a hardening attempt.
17
+ }
18
+ }
19
+
20
+ export type AkmMemoryCandidate = {
21
+ id: string
22
+ createdAt: string
23
+ harness: "claude-code" | "opencode"
24
+ sessionId?: string
25
+ sourceEventIds?: string[]
26
+ sourcePaths?: string[]
27
+ type:
28
+ | "preference"
29
+ | "constraint"
30
+ | "decision"
31
+ | "lesson"
32
+ | "workflow_state"
33
+ | "asset_feedback"
34
+ | "coverage_gap"
35
+ | "stale_memory"
36
+ | "unknown"
37
+ scope: "user" | "project" | "repo" | "branch" | "session" | "agent" | "workflow"
38
+ content: string
39
+ evidence: string[]
40
+ confidence: number
41
+ recommendedAction: "remember" | "distill" | "propose" | "feedback" | "ignore"
42
+ targetRef?: string
43
+ status: "pending" | "promoted" | "rejected"
44
+ reason?: string
45
+ }
46
+
47
+ const AKM_REF_RE = /(?:[A-Za-z0-9@._+/-]+\/\/)?(?:skill|command|agent|knowledge|memory|script|workflow|env|secret|wiki|lesson):[A-Za-z0-9._/-]+/g
48
+
49
+ function uniq(values: string[]): string[] {
50
+ return [...new Set(values)]
51
+ }
52
+
53
+ function pickTargetRef(refs: string[]): string | undefined {
54
+ return refs.find((ref) => !ref.startsWith("memory:") && !ref.startsWith("env:") && !ref.startsWith("secret:")) ?? refs[0]
55
+ }
56
+
57
+ function extractRefs(value: string): string[] {
58
+ return uniq(value.match(AKM_REF_RE) ?? [])
59
+ }
60
+
61
+ export function getCandidateLogPath(harness: "claude-code" | "opencode"): string {
62
+ const root = process.env.XDG_STATE_HOME ?? path.join(process.env.HOME ?? ".", ".local", "state")
63
+ const dir = path.join(root, harness === "claude-code" ? "akm-claude" : "akm-opencode")
64
+ return path.join(dir, "memory-candidates.jsonl")
65
+ }
66
+
67
+ function makeCandidateId(harness: string, sessionId: string | undefined, index: number): string {
68
+ const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)
69
+ const sid = (sessionId ?? "session").replace(/[^A-Za-z0-9._-]/g, "").slice(0, 12) || "session"
70
+ return `${harness}-${sid}-${stamp}-${index}`
71
+ }
72
+
73
+ function classifyCandidate(
74
+ content: string,
75
+ refs: string[],
76
+ ): Pick<AkmMemoryCandidate, "type" | "scope" | "confidence" | "recommendedAction" | "targetRef"> {
77
+ const text = content.toLowerCase()
78
+ const targetRef = pickTargetRef(refs)
79
+ if (targetRef && /\b(worked|helped|useful|failed|broken|wrong|didn't work|did not work)\b/.test(text)) {
80
+ return { type: "asset_feedback", scope: "project", confidence: 0.72, recommendedAction: "feedback", targetRef }
81
+ }
82
+ if (/\b(always|never|must|should not|cannot|can't|do not|required)\b/.test(text)) {
83
+ return { type: "constraint", scope: "project", confidence: 0.8, recommendedAction: "remember" }
84
+ }
85
+ if (/\b(prefer|likes|dislikes|wants|remember)\b/.test(text)) {
86
+ return { type: "preference", scope: "user", confidence: 0.8, recommendedAction: "remember" }
87
+ }
88
+ if (/\b(decided|decision|use |chosen|architecture|approach)\b/.test(text)) {
89
+ return { type: "decision", scope: "project", confidence: 0.75, recommendedAction: "remember" }
90
+ }
91
+ if (/\b(blocked|failed|workflow|next step|resume)\b/.test(text)) {
92
+ return { type: "workflow_state", scope: "workflow", confidence: 0.7, recommendedAction: "remember" }
93
+ }
94
+ if (/\b(missing|coverage gap|todo|follow-up|followup)\b/.test(text)) {
95
+ return { type: "coverage_gap", scope: "project", confidence: 0.65, recommendedAction: "propose" }
96
+ }
97
+ if (/\b(lesson|fix|worked|resolved|solution)\b/.test(text)) {
98
+ return { type: "lesson", scope: "project", confidence: 0.7, recommendedAction: "distill", targetRef }
99
+ }
100
+ return { type: "unknown", scope: "session", confidence: 0.4, recommendedAction: "ignore" }
101
+ }
102
+
103
+ export function extractCandidatesFromText(input: {
104
+ harness: "claude-code" | "opencode"
105
+ sessionId?: string
106
+ text: string
107
+ evidence?: string[]
108
+ sourceEventIds?: string[]
109
+ sourcePaths?: string[]
110
+ targetRefHints?: string[]
111
+ }): AkmMemoryCandidate[] {
112
+ const lines = input.text
113
+ .split(/\r?\n/)
114
+ .map((line) => line.trim())
115
+ .filter((line) => line.length >= 20)
116
+ .filter((line) => !line.startsWith("#") && !line.startsWith("## "))
117
+
118
+ const selected = lines.filter((line) => /\b(always|never|must|should|prefer|remember|decision|decided|workflow|blocked|failed|worked|fix|missing|follow-up|followup)\b/i.test(line))
119
+ return selected.slice(0, 12).map((content, index) => {
120
+ const contentRefs = extractRefs(content)
121
+ const evidenceRefs = (input.evidence ?? []).flatMap(extractRefs)
122
+ const hintedRefs = input.targetRefHints ?? []
123
+ const refs = uniq([
124
+ ...contentRefs,
125
+ ...hintedRefs,
126
+ ...evidenceRefs,
127
+ ])
128
+ const details = classifyCandidate(content, refs)
129
+ const targetRef = details.targetRef ?? pickTargetRef([...contentRefs, ...hintedRefs, ...evidenceRefs])
130
+ return {
131
+ id: makeCandidateId(input.harness, input.sessionId, index + 1),
132
+ createdAt: new Date().toISOString(),
133
+ harness: input.harness,
134
+ sessionId: input.sessionId,
135
+ sourceEventIds: input.sourceEventIds,
136
+ sourcePaths: input.sourcePaths ? uniq(input.sourcePaths) : undefined,
137
+ type: details.type,
138
+ scope: details.scope,
139
+ content,
140
+ evidence: uniq([...(input.evidence ?? []), content]),
141
+ confidence: details.confidence,
142
+ recommendedAction: details.recommendedAction,
143
+ targetRef,
144
+ status: "pending",
145
+ }
146
+ })
147
+ }
148
+
149
+ export function appendCandidates(filePath: string, candidates: AkmMemoryCandidate[]): { ok: true; count: number; categories: string[] } | { ok: false; error: string } {
150
+ try {
151
+ mkdirSync(path.dirname(filePath), { recursive: true })
152
+ chmodSafe(path.dirname(filePath), 0o700)
153
+ const categories: string[] = []
154
+ const created = !existsSync(filePath)
155
+ for (const candidate of candidates) {
156
+ const redacted = redactObject(candidate)
157
+ categories.push(...redacted.categories)
158
+ appendFileSync(filePath, `${JSON.stringify(redacted.value)}\n`)
159
+ }
160
+ if (created) chmodSafe(filePath, 0o600)
161
+ return { ok: true, count: candidates.length, categories: [...new Set(categories)] }
162
+ } catch (error: unknown) {
163
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
164
+ }
165
+ }
166
+
167
+ export function readCandidates(filePath: string): AkmMemoryCandidate[] {
168
+ if (!existsSync(filePath)) return []
169
+ return readFileSync(filePath, "utf8")
170
+ .split("\n")
171
+ .filter(Boolean)
172
+ .flatMap((line) => {
173
+ try {
174
+ return [JSON.parse(line) as AkmMemoryCandidate]
175
+ } catch {
176
+ return []
177
+ }
178
+ })
179
+ }
180
+
181
+ export function replaceCandidates(filePath: string, candidates: AkmMemoryCandidate[]): void {
182
+ mkdirSync(path.dirname(filePath), { recursive: true })
183
+ chmodSafe(path.dirname(filePath), 0o700)
184
+ writeFileSync(filePath, candidates.map((candidate) => `${JSON.stringify(candidate)}\n`).join(""))
185
+ chmodSafe(filePath, 0o600)
186
+ }
187
+
188
+ export function updateCandidateStatus(filePath: string, id: string, status: "promoted" | "rejected", reason?: string): AkmMemoryCandidate | undefined {
189
+ const candidates = readCandidates(filePath)
190
+ const index = candidates.findIndex((candidate) => candidate.id === id)
191
+ if (index === -1) return undefined
192
+ const updated = { ...candidates[index], status, reason }
193
+ candidates[index] = updated
194
+ replaceCandidates(filePath, candidates)
195
+ return updated
196
+ }
@@ -0,0 +1,122 @@
1
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs"
2
+ import path from "node:path"
3
+ import { redactObject } from "./redaction"
4
+
5
+ // Memory events log session activity (refs touched, outcomes, scope). Even
6
+ // post-redaction this is a privileged record; lock the directory + file down
7
+ // to owner-only access. Mirrors memory-candidates' chmodSafe helper.
8
+ function chmodSafe(target: string, mode: number): void {
9
+ try {
10
+ chmodSync(target, mode)
11
+ } catch {
12
+ // Best-effort; see memory-candidates.ts for rationale.
13
+ }
14
+ }
15
+
16
+ export type AkmMemoryEventType =
17
+ | "session_started"
18
+ | "prompt_recall"
19
+ | "tool_observation"
20
+ | "tool_batch_observation"
21
+ | "tool_ref_observed"
22
+ | "workflow_step"
23
+ | "workflow_started"
24
+ | "workflow_next_loaded"
25
+ | "workflow_step_completed"
26
+ | "workflow_step_blocked"
27
+ | "workflow_step_failed"
28
+ | "workflow_step_skipped"
29
+ | "workflow_evidence_attached"
30
+ | "workflow_drift_detected"
31
+ | "workflow_resumed"
32
+ | "workflow_abandoned"
33
+ | "task_created"
34
+ | "task_completed"
35
+ | "subagent_started"
36
+ | "subagent_completed"
37
+ | "pre_compact_checkpoint"
38
+ | "post_compact_summary"
39
+ | "session_ended"
40
+ | "candidate_extracted"
41
+ | "candidate_promoted"
42
+ | "candidate_rejected"
43
+ | "durable_memory_written"
44
+ | "feedback_recorded"
45
+ | "safety_blocked"
46
+
47
+ export type AkmMemoryEvent = {
48
+ version: 1
49
+ timestamp: string
50
+ harness: "claude-code" | "opencode"
51
+ event: AkmMemoryEventType
52
+ eventId?: string
53
+ sessionId?: string
54
+ project?: string
55
+ agent?: string
56
+ workflowRunId?: string
57
+ scope?: {
58
+ user?: string
59
+ agent?: string
60
+ run?: string
61
+ channel?: string
62
+ project?: string
63
+ repo?: string
64
+ branch?: string
65
+ }
66
+ input?: Record<string, unknown>
67
+ memory?: Record<string, unknown>
68
+ refs?: string[]
69
+ outcome?: {
70
+ status: "ok" | "skipped" | "blocked" | "failed"
71
+ warnings?: string[]
72
+ error?: string
73
+ }
74
+ redaction?: {
75
+ redacted: boolean
76
+ categories: string[]
77
+ }
78
+ }
79
+
80
+ export function getHarnessStateDir(harness: "claude-code" | "opencode"): string {
81
+ const root = process.env.XDG_STATE_HOME ?? path.join(process.env.HOME ?? ".", ".local", "state")
82
+ return path.join(root, harness === "claude-code" ? "akm-claude" : "akm-opencode")
83
+ }
84
+
85
+ export function getEventLogPath(harness: "claude-code" | "opencode"): string {
86
+ return path.join(getHarnessStateDir(harness), "events.jsonl")
87
+ }
88
+
89
+ export function appendMemoryEvent(filePath: string, event: AkmMemoryEvent): { ok: true; redacted: boolean; categories: string[] } | { ok: false; error: string } {
90
+ try {
91
+ mkdirSync(path.dirname(filePath), { recursive: true })
92
+ chmodSafe(path.dirname(filePath), 0o700)
93
+ const redacted = redactObject(event)
94
+ const enriched = {
95
+ ...redacted.value,
96
+ redaction: {
97
+ redacted: redacted.redacted,
98
+ categories: redacted.categories,
99
+ },
100
+ }
101
+ const created = !existsSync(filePath)
102
+ appendFileSync(filePath, `${JSON.stringify(enriched)}\n`)
103
+ if (created) chmodSafe(filePath, 0o600)
104
+ return { ok: true, redacted: redacted.redacted, categories: redacted.categories }
105
+ } catch (error: unknown) {
106
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
107
+ }
108
+ }
109
+
110
+ export function readJsonl<T>(filePath: string): T[] {
111
+ if (!existsSync(filePath)) return []
112
+ return readFileSync(filePath, "utf8")
113
+ .split("\n")
114
+ .filter(Boolean)
115
+ .flatMap((line) => {
116
+ try {
117
+ return [JSON.parse(line) as T]
118
+ } catch {
119
+ return []
120
+ }
121
+ })
122
+ }
@@ -0,0 +1,70 @@
1
+ export type RecallDecision = {
2
+ shouldRecall: boolean
3
+ reason:
4
+ | "explicit-akm"
5
+ | "long-prompt"
6
+ | "memory-intent"
7
+ | "workflow-intent"
8
+ | "agent-dispatch"
9
+ | "command-dispatch"
10
+ | "wiki-intent"
11
+ | "proposal-intent"
12
+ | "release-intent"
13
+ | "coding-task"
14
+ | "active-workflow"
15
+ | "recent-asset-failure"
16
+ | "skip-short"
17
+ | "skip-chitchat"
18
+ | "skip-low-signal"
19
+ query: string
20
+ scopeHints?: string[]
21
+ }
22
+
23
+ const REF_RE = /(?:skill|command|agent|knowledge|workflow|lesson|wiki|memory|env|secret):[A-Za-z0-9._/-]+/i
24
+
25
+ export function shouldRecall(prompt: string, options?: { activeWorkflow?: boolean; recentAssetFailure?: boolean }): RecallDecision {
26
+ const text = prompt.trim()
27
+ const lower = text.toLowerCase()
28
+ const scopeHints: string[] = []
29
+ if (!text) return { shouldRecall: false, reason: "skip-low-signal", query: "", scopeHints }
30
+ if (options?.activeWorkflow) return { shouldRecall: true, reason: "active-workflow", query: text, scopeHints: ["workflow"] }
31
+ if (options?.recentAssetFailure) return { shouldRecall: true, reason: "recent-asset-failure", query: text, scopeHints }
32
+ if (text.length < 4 || /^(ok|thanks|thank you|yes|no|continue|go ahead|sure|cool)$/i.test(lower)) {
33
+ return { shouldRecall: false, reason: "skip-short", query: text, scopeHints }
34
+ }
35
+ if (/\b(hi|hello|how are you|good morning|good night)\b/i.test(lower) && text.length < 40) {
36
+ return { shouldRecall: false, reason: "skip-chitchat", query: text, scopeHints }
37
+ }
38
+ if (/\bakm\b|\bstash\b/.test(lower) || REF_RE.test(text)) {
39
+ return { shouldRecall: true, reason: "explicit-akm", query: text, scopeHints: ["akm"] }
40
+ }
41
+ if (/\b(remember|memory|prior session|previous decision)\b/.test(lower)) {
42
+ return { shouldRecall: true, reason: "memory-intent", query: text, scopeHints: ["memory"] }
43
+ }
44
+ if (/\b(workflow|resume|complete step|next step|blocked step)\b/.test(lower)) {
45
+ return { shouldRecall: true, reason: "workflow-intent", query: text, scopeHints: ["workflow"] }
46
+ }
47
+ if (/\b(dispatch|agent|subagent|reviewer|planner|curator)\b/.test(lower)) {
48
+ return { shouldRecall: true, reason: "agent-dispatch", query: text, scopeHints: ["agent"] }
49
+ }
50
+ if (/\b(command|slash command|run the stash command)\b/.test(lower)) {
51
+ return { shouldRecall: true, reason: "command-dispatch", query: text, scopeHints: ["command"] }
52
+ }
53
+ if (/\b(wiki|docs|knowledge base|ingest|lint)\b/.test(lower)) {
54
+ return { shouldRecall: true, reason: "wiki-intent", query: text, scopeHints: ["wiki"] }
55
+ }
56
+ if (/\b(proposal|accept|reject|diff proposal|review proposals)\b/.test(lower)) {
57
+ return { shouldRecall: true, reason: "proposal-intent", query: text, scopeHints: ["proposal"] }
58
+ }
59
+ if (/\b(release|publish|semver|version bump|bump version|tag the release|cut a release)\b/.test(lower)) {
60
+ return { shouldRecall: true, reason: "release-intent", query: text, scopeHints: ["workflow", "command"] }
61
+ }
62
+ if (
63
+ text.length >= 16
64
+ && /\b(review|pull request|\bpr\b|diff|refactor|type hints|typing|readability|readable|debug|diagnose|traceback|exception|exceptions|scaffold|unit test|tests|test|changelog|release notes|deployment|deploy|rollback|runbook|error code|healthcheck|linters|lint|format|formatting|naming conventions|convention|style guide|onboarding|new hire|new engineer|api keys|secrets|plan|design|architecture|tradeoffs|build|implement|fix|update)\b/.test(lower)
65
+ ) {
66
+ return { shouldRecall: true, reason: "coding-task", query: text, scopeHints: ["code"] }
67
+ }
68
+ if (text.length >= 120) return { shouldRecall: true, reason: "long-prompt", query: text, scopeHints }
69
+ return { shouldRecall: false, reason: "skip-low-signal", query: text, scopeHints }
70
+ }