ostacky 0.7.4 → 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.
@@ -54,86 +54,17 @@ const ENGRAM_TOOLS = new Set([
54
54
  ])
55
55
 
56
56
  // ─── Memory Instructions ─────────────────────────────────────────────────────
57
- // These get injected into the agent's context so it knows to call mem_save.
57
+ // Lazy: full protocol lives in assets/docs/engram-protocol.md (on-demand via Read).
58
+ // System injects only pointer + nudge, not full 1.2k. Source-of-truth: src/tiered.ts for isTrivial.
58
59
 
59
- const MEMORY_INSTRUCTIONS = `## Engram Persistent Memory Protocol
60
+ const MEMORY_POINTER = "Engram disponiblepara formato mem_save/mem_search lee assets/docs/engram-protocol.md (on-demand). Usa mem_search proactivamente si el tema pudo verse antes."
61
+ const MEMORY_INSTRUCTIONS_LAZY = `## Engram — pointer (lazy)
60
62
 
61
- You have access to Engram, a persistent memory system that survives across sessions and compactions.
63
+ ${MEMORY_POINTER}
62
64
 
63
- ### WHEN TO SAVE (mandatory not optional)
64
-
65
- Call \`mem_save\` IMMEDIATELY after any of these:
66
- - Bug fix completed
67
- - Architecture or design decision made
68
- - Non-obvious discovery about the codebase
69
- - Configuration change or environment setup
70
- - Pattern established (naming, structure, convention)
71
- - User preference or constraint learned
72
-
73
- Format for \`mem_save\`:
74
- - **title**: Verb + what — short, searchable (e.g. "Fixed N+1 query in UserList", "Chose Zustand over Redux")
75
- - **type**: bugfix | decision | architecture | discovery | pattern | config | preference
76
- - **scope**: \`project\` (default) | \`personal\`
77
- - **topic_key** (optional, recommended for evolving decisions): stable key like \`architecture/auth-model\`
78
- - **content**:
79
- **What**: One sentence — what was done
80
- **Why**: What motivated it (user request, bug, performance, etc.)
81
- **Where**: Files or paths affected
82
- **Learned**: Gotchas, edge cases, things that surprised you (omit if none)
83
-
84
- Topic rules:
85
- - Different topics must not overwrite each other (e.g. architecture vs bugfix)
86
- - Reuse the same \`topic_key\` to update an evolving topic instead of creating new observations
87
- - If unsure about the key, call \`mem_suggest_topic_key\` first and then reuse it
88
- - Use \`mem_update\` when you have an exact observation ID to correct
89
-
90
- ### WHEN TO SEARCH MEMORY
91
-
92
- When the user asks to recall something — any variation of "remember", "recall", "what did we do",
93
- "how did we solve", or the equivalent in the user's language, or references to past work:
94
- 1. First call \`mem_context\` — checks recent session history (fast, cheap)
95
- 2. If not found, call \`mem_search\` with relevant keywords (FTS5 full-text search)
96
- 3. If you find a match, use \`mem_get_observation\` for full untruncated content
97
-
98
- Also search memory PROACTIVELY when:
99
- - Starting work on something that might have been done before
100
- - The user mentions a topic you have no context on — check if past sessions covered it
101
- - The user's FIRST message references the project, a feature, or a problem — call \`mem_search\` with keywords from their message to check for prior work before responding
102
-
103
- ### SESSION CLOSE PROTOCOL (mandatory)
104
-
105
- Before ending a session or saying "done" / "that's it", you MUST:
106
- 1. Call \`mem_session_summary\` with this structure:
107
-
108
- ## Goal
109
- [What we were working on this session]
110
-
111
- ## Instructions
112
- [User preferences or constraints discovered — skip if none]
113
-
114
- ## Discoveries
115
- - [Technical findings, gotchas, non-obvious learnings]
116
-
117
- ## Accomplished
118
- - [Completed items with key details]
119
-
120
- ## Next Steps
121
- - [What remains to be done — for the next session]
122
-
123
- ## Relevant Files
124
- - path/to/file — [what it does or what changed]
125
-
126
- This is NOT optional. If you skip this, the next session starts blind.
127
-
128
- ### AFTER COMPACTION
129
-
130
- If you see a message about compaction or context reset, or if you see "FIRST ACTION REQUIRED" in your context:
131
- 1. IMMEDIATELY call \`mem_session_summary\` with the compacted summary content — this persists what was done before compaction
132
- 2. Then call \`mem_context\` to recover any additional context from previous sessions
133
- 3. Only THEN continue working
134
-
135
- Do not skip step 1. Without it, everything done before compaction is lost from memory.
136
- `
65
+ Cuando necesites guardar/buscar, lee el protocolo completo con Read. No alucines formato.`
66
+ // Compat: keep full for fallback if file missing — but never inject full in system.transform
67
+ const MEMORY_INSTRUCTIONS = MEMORY_INSTRUCTIONS_LAZY
137
68
 
138
69
  // ─── HTTP Client ─────────────────────────────────────────────────────────────
139
70
 
@@ -270,6 +201,26 @@ export const Engram: Plugin = async (ctx) => {
270
201
  // inflation (e.g. 170 sessions for 1 real conversation, issue #116).
271
202
  const subAgentSessions = new Set<string>()
272
203
 
204
+ // Tiered cache-friendly: single source of truth via src/tiered.ts
205
+ const trivialBySession = new Map<string, boolean>()
206
+ // isTrivial y getControllerState importados lógicamente desde src/tiered.ts
207
+ // Inlined para evitar import dinámico en plugin bundle — mantener regex idéntico a src/tiered.ts
208
+ function isTrivialMessage(msg: string, state: string): boolean {
209
+ if (!msg || state !== "DONE") return false
210
+ if (msg.trim().length >= 30) return false
211
+ if (!/^(hola|hey|gracias|buenas|hi|hello)\b/i.test(msg.trim())) return false
212
+ if (/(necesito|quiero|agregá|fix|bug|feature|auth|spec|implementar)/i.test(msg)) return false
213
+ return true
214
+ }
215
+ function getControllerState(directory: string): string {
216
+ try {
217
+ const statePath = process.env.OSTACKY_STATE_PATH || join(directory, ".opencode", "ostacky-state.json")
218
+ const raw = readFileSync(statePath, "utf-8")
219
+ const j = JSON.parse(raw)
220
+ return j.state ?? "DONE"
221
+ } catch { return "DONE" }
222
+ }
223
+
273
224
  /**
274
225
  * Ensure a session exists in engram. Idempotent — calls POST /sessions
275
226
  * which uses INSERT OR IGNORE. Safe to call multiple times.
@@ -407,6 +358,12 @@ export const Engram: Plugin = async (ctx) => {
407
358
 
408
359
  const finalContent = content || fallback
409
360
 
361
+ // Tiered: set trivial flag for system.transform lazy
362
+ try {
363
+ const state = getControllerState(ctx.directory)
364
+ trivialBySession.set(sessionId, isTrivialMessage(finalContent, state))
365
+ } catch {}
366
+
410
367
  // Only capture non-trivial prompts (>10 chars)
411
368
  if (finalContent.length > 10) {
412
369
  await ensureSession(sessionId)
@@ -465,13 +422,24 @@ export const Engram: Plugin = async (ctx) => {
465
422
  // messages that would break these models. See: GitHub issue #23.
466
423
 
467
424
  "experimental.chat.system.transform": async (input, output) => {
425
+ // Tiered lazy: SIEMPRE pointer (cache-friendly, ~1 línea). Full vive en assets/docs/engram-protocol.md on-demand.
426
+ const sessionId: string = (input as any).sessionID ?? ""
427
+ const isTrivial = trivialBySession.get(sessionId) ?? false
428
+ const state = getControllerState(ctx.directory)
429
+ const shouldBeTrivial = isTrivial && state === "DONE"
430
+ const pointer = shouldBeTrivial
431
+ ? "Engram disponible — detalles a demanda (usa mem_search si necesitas recordar)."
432
+ : MEMORY_POINTER
468
433
  if (output.system.length > 0) {
469
- output.system[output.system.length - 1] += "\n\n" + MEMORY_INSTRUCTIONS
434
+ output.system[output.system.length - 1] += "\n\n" + pointer
470
435
  } else {
471
- output.system.push(MEMORY_INSTRUCTIONS)
436
+ output.system.push(pointer)
472
437
  }
438
+ // No inyectar MEMORY_INSTRUCTIONS completo nunca — se lee on-demand via Read
473
439
 
474
440
  // ── Save nudge ──────────────────────────────────────────────────────────
441
+ // Skip nudge for trivial greeting (cache-friendly, no extra injection)
442
+ if (shouldBeTrivial) return
475
443
  // If it has been a long time since the last mem_save, append a reminder
476
444
  // to the system prompt so the agent notices. All fetches are fire-and-
477
445
  // forget with short timeouts — any failure silently skips the nudge.
@@ -1,212 +1,18 @@
1
1
  /**
2
- * Ostacky Guard plugin hermano de Engram
3
- *
4
- * Bloquea lecturas sensibles sin autorización previa y bloquea cualquier tool
5
- * cuando el controller está en estado PENDING.
6
- *
7
- * Hard gate incluso en degraded: nunca uses bash para .env sin check_file_access → consume ALLOW con razón auditada.
8
- *
9
- * Se registra en `tool.execute.before` y lanza Error si la operación no está
10
- * permitida. OpenCode aborta el tool antes de tocar disco.
2
+ * @deprecated Fusionado en ostacky-plugin.ts re-export legacy.
3
+ * Single source: importa desde src/security.ts vía controller.
4
+ * Fresh install solo escribe ostacky-plugin.ts + engram.ts.
5
+ * Este archivo no debe ser instalado por ostacky install (deprecado).
11
6
  */
12
7
 
13
8
  import type { Plugin } from "@opencode-ai/plugin"
14
- import { readFileSync, existsSync } from "node:fs"
15
- import { join, dirname, basename, resolve, relative } from "node:path"
16
9
 
17
- const SENSITIVE_DEFAULT = [
18
- "**/.env*",
19
- "**/.secrets/**",
20
- "**/*.pem",
21
- "**/*.key",
22
- "**/.aws/**",
23
- "**/.ssh/**",
24
- "**/credentials.json",
25
- "**/.npmrc",
26
- ]
10
+ // Re-export controller como guard por compatibilidad — no duplica isSensitive/BASH_SENSITIVE_RE
11
+ export { OstackyController as OstackyGuard } from "./ostacky-plugin.ts"
27
12
 
28
- const BASH_SENSITIVE_RE =
29
- /(?:^|[^a-zA-Z0-9_.-])(\.env(\b|[_.-])|\.secrets\b|\.pem\b|\.key\b|credentials\.json|\.aws\b|\.ssh\b|\.npmrc\b)/i
13
+ // También export default por compatibilidad con loaders que esperan default
14
+ import { OstackyController } from "./ostacky-plugin.ts"
15
+ export default OstackyController
30
16
 
31
- function getEnvPatterns(): string[] {
32
- const raw = process.env.OSTACKY_SENSITIVE_PATTERNS
33
- if (!raw) return SENSITIVE_DEFAULT
34
- return raw.split(",").map((s) => s.trim()).filter(Boolean)
35
- }
36
-
37
- function isSensitive(filePath: string, patterns: string[]): boolean {
38
- if (!filePath) return false
39
- const normalized = filePath.replace(/\\/g, "/")
40
- const lower = normalized.toLowerCase()
41
- if (lower.endsWith(".env.example") || lower.endsWith(".env.template") || lower.endsWith(".env.sample")) return false
42
- const base = lower.split("/").pop() || ""
43
- for (const pat of patterns) {
44
- if (pat.includes(".env") && base.startsWith(".env")) return true
45
- if (pat.includes(".secrets") && lower.includes(".secrets")) return true
46
- if (pat.includes("*.pem") && lower.endsWith(".pem")) return true
47
- if (pat.includes("*.key") && lower.endsWith(".key")) return true
48
- if (pat.includes(".aws") && lower.includes(".aws")) return true
49
- if (pat.includes(".ssh") && lower.includes(".ssh")) return true
50
- if (pat.includes("credentials.json") && lower.endsWith("credentials.json")) return true
51
- if (pat.includes(".npmrc") && lower.endsWith(".npmrc")) return true
52
- }
53
- if (/\.(pem|key)$/i.test(normalized)) return true
54
- if (base.startsWith(".env")) return true
55
- return false
56
- }
57
-
58
- function extractPathsFromBash(cmd: string): string[] {
59
- if (!cmd) return []
60
- const normalized = cmd.replace(/&&/g, ";").replace(/\|\|/g, ";")
61
- const segments = normalized.split(/[|;><\n]+/)
62
- const paths: string[] = []
63
- for (const seg of segments) {
64
- const trimmed = seg.trim()
65
- if (!trimmed) continue
66
- const tokens = trimmed.match(/(?:[^\s"'`\u0060\\]+|"[^"]*"|'[^']*'|`[^`]*`)+/g) || []
67
- for (let token of tokens) {
68
- const stripped = token.replace(/["'`]/g, "").replace(/\\/g, "")
69
- if (!stripped) continue
70
- if (["cat","grep","ls","echo","awk","sed","cut","head","tail","wc","find","xargs","bash","sh","zsh","env","printenv","node","bun","npm","npx","ls","cat"].includes(stripped)) continue
71
- if (stripped.startsWith("-")) continue
72
- const lower = stripped.toLowerCase()
73
- if (
74
- stripped.includes("/") ||
75
- stripped.includes(".") ||
76
- lower.startsWith(".env") ||
77
- lower.includes(".secrets") ||
78
- lower.endsWith(".pem") ||
79
- lower.endsWith(".key") ||
80
- lower.includes(".aws") ||
81
- lower.includes(".ssh") ||
82
- lower.endsWith("credentials.json") ||
83
- lower.endsWith(".npmrc")
84
- ) {
85
- const cleaned = stripped.replace(/[,:;)\]]+$/, "")
86
- if (cleaned) paths.push(cleaned)
87
- } else if (stripped === ".env") {
88
- paths.push(stripped)
89
- }
90
- }
91
- }
92
- return [...new Set(paths)]
93
- }
94
-
95
- function getStatePath(directory: string): string {
96
- if (process.env.OSTACKY_STATE_PATH) return process.env.OSTACKY_STATE_PATH
97
- const candidates = [join(directory, "opencode.json"), join(directory, "opencode.jsonc")]
98
- for (const cand of candidates) {
99
- try {
100
- const raw = readFileSync(cand, "utf-8")
101
- const json = JSON.parse(raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""))
102
- const envPath = (json as any)?.mcp?.["ostacky-controller"]?.environment?.OSTACKY_STATE_PATH
103
- if (typeof envPath === "string" && envPath) return envPath
104
- } catch {}
105
- }
106
- return join(directory, ".opencode", "ostacky-state.json")
107
- }
108
-
109
- function readState(directory: string): any | null {
110
- const p = getStatePath(directory)
111
- try {
112
- const raw = readFileSync(p, "utf-8")
113
- return JSON.parse(raw)
114
- } catch {
115
- return null
116
- }
117
- }
118
-
119
- export const OstackyGuard: Plugin = async (ctx) => {
120
- const patterns = getEnvPatterns()
121
-
122
- return {
123
- "tool.execute.before": async (input, output) => {
124
- const tool = (input as any).tool as string
125
- const args = (input as any).args as any
126
-
127
- // 10.2: Guard genérico de PENDING — bloquear cualquier tool no-controller cuando estado es PENDING
128
- const state = readState(ctx.directory)
129
- const pendingStates = ["ROUTE_DECISION_PENDING", "EXECUTION_DECISION_PENDING", "CLARIFICATION_PENDING"]
130
- if (state && pendingStates.includes(state.state)) {
131
- const allowedTools = ["consume_route_decision", "consume_execution_decision", "record_clarification", "abandon", "check_file_access", "consume_file_access_decision", "record_user_confirmation"]
132
- const isControllerTool = tool.startsWith("ostacky-controller_") || allowedTools.some((t) => tool.includes(t))
133
- if (!isControllerTool) {
134
- throw new Error(`BLOCKED: call consume_* first — controller is in ${state.state}. Use consume_route_decision / consume_execution_decision / record_clarification to proceed.`)
135
- }
136
- }
137
-
138
- // ── Guard hard para bash/write/edit (hardening-v2 P0) ──
139
- // Hard gate incluso en degraded, nunca uses bash para .env sin check_file_access → consume ALLOW con razón auditada
140
- if (tool === "bash") {
141
- const cmd: string = args?.command || args?.cmd || ""
142
- if (typeof cmd === "string" && cmd) {
143
- const normalized = cmd.replace(/["'`]/g, "").replace(/\\/g, "")
144
- const hasSensitivePattern = BASH_SENSITIVE_RE.test(normalized)
145
- const paths = extractPathsFromBash(cmd)
146
- const sensitivePaths = paths.filter((p) => isSensitive(p, patterns))
147
- // Si hay match de regex o paths sensibles, validar ALLOW
148
- if (hasSensitivePattern || sensitivePaths.length > 0) {
149
- // Si extrajimos paths sensibles, validar cada uno
150
- if (sensitivePaths.length > 0) {
151
- for (const p of sensitivePaths) {
152
- const s = readState(ctx.directory)
153
- // Normalizar p para lookup: probar p tal cual, y resolved, y basename para casos relativos
154
- const candidates = [p, resolve(ctx.directory, p), join(ctx.directory, p)]
155
- const allowed = candidates.some((c) => s?.allowedFiles?.[c] || s?.allowedFiles?.[p])
156
- // También chequear por basename si es .env (ej: .env vs /abs/.env)
157
- const baseAllowed = s?.allowedFiles?.[p] || s?.allowedFiles?.[basename(p)] || s?.allowedFiles?.[resolve(ctx.directory, p)]
158
- if (!allowed && !baseAllowed) {
159
- const denied = s?.deniedFiles?.[p] || s?.deniedFiles?.[basename(p)]
160
- if (denied) {
161
- throw new Error(`BLOCKED: bash contiene acceso sensible (${p}) (previously denied) — Llamá check_file_access con reason antes.`)
162
- }
163
- throw new Error(`BLOCKED: bash contiene acceso sensible (${p}). Llamá check_file_access con reason antes.`)
164
- }
165
- }
166
- } else if (hasSensitivePattern) {
167
- // Fallback genérico cuando regex matchea pero no extrajimos path (ej: cat .env con espacios raros)
168
- const s = readState(ctx.directory)
169
- // Buscar si hay algún allowed que cubra .env* genérico
170
- const hasAllowed = Object.keys(s?.allowedFiles || {}).some((k) => isSensitive(k, patterns))
171
- if (!hasAllowed) {
172
- throw new Error(`BLOCKED: bash contiene acceso sensible (.env). Llamá check_file_access con reason antes.`)
173
- }
174
- }
175
- }
176
- // Caso especial: obfuscación .e""nv → normalized ya quitó quotes, pero paths extraído ya lo cubre
177
- // Si normalized contiene .env después de strip, y no hay allowed, ya bloqueamos arriba
178
- }
179
- }
180
-
181
- // Extraer filePath para read/write/edit/grep/glob/read_mcp_resource
182
- let filePath: string | undefined
183
- if (tool === "read" || tool === "read_mcp_resource" || tool === "grep" || tool === "glob" || tool === "write" || tool === "edit") {
184
- filePath = args?.filePath || args?.path || args?.pattern || args?.uri || ""
185
- if (tool === "grep" && args?.include) filePath = args.include
186
- if (tool === "write" || tool === "edit") {
187
- filePath = args?.filePath || args?.path || ""
188
- }
189
- if (tool === "read_mcp_resource" && typeof args?.uri === "string") {
190
- try {
191
- const u = args.uri as string
192
- if (u.startsWith("file://")) filePath = u.slice(7)
193
- else if (u.includes("/")) filePath = u
194
- } catch {}
195
- }
196
- }
197
-
198
- // 9.1: Guard de credenciales (hard gate incluso en degraded)
199
- if (filePath && isSensitive(filePath, patterns)) {
200
- const s = readState(ctx.directory)
201
- const allowed = s?.allowedFiles?.[filePath] || s?.allowedFiles?.[resolve(ctx.directory, filePath)] || s?.allowedFiles?.[basename(filePath)]
202
- if (!allowed) {
203
- const denied = s?.deniedFiles?.[filePath] || s?.deniedFiles?.[basename(filePath)]
204
- if (denied) {
205
- throw new Error(`BLOCKED: File ${filePath} requires check_file_access (previously denied)`)
206
- }
207
- throw new Error(`BLOCKED: File ${filePath} requires check_file_access`)
208
- }
209
- }
210
- },
211
- }
212
- }
17
+ // Nota: isSensitive, BASH_SENSITIVE_RE, extractPathsFromBash vienen de src/security.ts
18
+ // a través de ostacky-plugin.ts, no se copian aquí (verificación check:security).