ostacky 0.7.0 → 0.7.2

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,6 +1,6 @@
1
1
  {
2
2
  "name": "ostacky-controller",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -15,12 +15,26 @@
15
15
  */
16
16
 
17
17
  import type { Plugin } from "@opencode-ai/plugin"
18
+ import { join, dirname, basename } from "path"
19
+ import { readFileSync, writeFileSync, renameSync, mkdirSync } from "fs"
18
20
 
19
21
  // ─── Configuration ───────────────────────────────────────────────────────────
20
22
 
21
23
  const ENGRAM_PORT = parseInt(process.env.ENGRAM_PORT ?? "7437")
22
24
  const ENGRAM_URL = `http://127.0.0.1:${ENGRAM_PORT}`
23
- const ENGRAM_BIN = process.env.ENGRAM_BIN ?? Bun.which("engram") ?? ".opencode/tools/engram/bin/engram"
25
+ // C3/H2 fix: resolve ENGRAM_BIN per ctx.directory with win32 .exe and absolute fallback
26
+ function resolveEngramBin(directory: string): string {
27
+ if (process.env.ENGRAM_BIN) {
28
+ const p = process.env.ENGRAM_BIN
29
+ const isAbs = p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p)
30
+ return isAbs ? p : join(directory, p)
31
+ }
32
+ const which = Bun.which("engram")
33
+ if (which) return which
34
+ const suffix = process.platform === "win32" ? ".exe" : ""
35
+ return join(directory, ".opencode", "tools", "engram", "bin", `engram${suffix}`)
36
+ }
37
+ // ENGRAM_BIN eliminado: reemplazado por resolveEngramBin(ctx.directory) que maneja .exe+absolutización correctamente
24
38
 
25
39
  // Engram's own MCP tools — don't count these as "tool calls" for session stats
26
40
  const ENGRAM_TOOLS = new Set([
@@ -171,12 +185,12 @@ function extractProjectName(directory: string): string {
171
185
  const result = Bun.spawnSync(["git", "-C", directory, "rev-parse", "--show-toplevel"])
172
186
  if (result.exitCode === 0) {
173
187
  const root = result.stdout?.toString().trim()
174
- if (root) return root.split("/").pop() ?? "unknown"
188
+ if (root) return basename(root.replace(/\\/g, "/")) ?? "unknown"
175
189
  }
176
190
  } catch {}
177
191
 
178
- // Final fallback: cwd basename
179
- return directory.split("/").pop() ?? "unknown"
192
+ // Final fallback: cwd basename (cross-platform)
193
+ return basename(directory.replace(/\\/g, "/")) ?? "unknown"
180
194
  }
181
195
 
182
196
  function truncate(str: string, max: number): string {
@@ -197,7 +211,8 @@ function stripPrivateTags(str: string): string {
197
211
  // ─── Plugin Export ───────────────────────────────────────────────────────────
198
212
 
199
213
  export const Engram: Plugin = async (ctx) => {
200
- const oldProject = ctx.directory.split("/").pop() ?? "unknown"
214
+ // T4: basename multiplataforma — split("/") producía keys basura con backslashes en Windows nativo
215
+ const oldProject = basename(ctx.directory.replace(/\\/g, "/")) ?? "unknown"
201
216
  const project = extractProjectName(ctx.directory)
202
217
 
203
218
  // Track tool counts per session (in-memory only, not critical)
@@ -236,11 +251,12 @@ export const Engram: Plugin = async (ctx) => {
236
251
  })
237
252
  }
238
253
 
239
- // Try to start engram server if not running
254
+ // Try to start engram server if not running — use per-directory resolved bin (win32 .exe + absolute)
255
+ const engramBin = resolveEngramBin(ctx.directory)
240
256
  const running = await isEngramRunning()
241
257
  if (!running) {
242
258
  try {
243
- Bun.spawn([ENGRAM_BIN, "serve"], {
259
+ Bun.spawn([engramBin, "serve"], {
244
260
  stdout: "ignore",
245
261
  stderr: "ignore",
246
262
  stdin: "ignore",
@@ -268,7 +284,7 @@ export const Engram: Plugin = async (ctx) => {
268
284
  const manifestFile = `${ctx.directory}/.engram/manifest.json`
269
285
  const file = Bun.file(manifestFile)
270
286
  if (await file.exists()) {
271
- Bun.spawn([ENGRAM_BIN, "sync", "--import"], {
287
+ Bun.spawn([engramBin, "sync", "--import"], {
272
288
  cwd: ctx.directory,
273
289
  stdout: "ignore",
274
290
  stderr: "ignore",
@@ -513,6 +529,64 @@ export const Engram: Plugin = async (ctx) => {
513
529
  await ensureSession(input.sessionID)
514
530
  }
515
531
 
532
+ // C3: Compaction fallback file — write directly to same anchor as controller's get_handoff
533
+ // Resolves statePath from opencode.json (local) or global config, default .opencode/ostacky-state.json
534
+ try {
535
+ let statePath: string | null = null
536
+ // 1) env var if set
537
+ if (process.env.OSTACKY_STATE_PATH) {
538
+ statePath = process.env.OSTACKY_STATE_PATH
539
+ }
540
+ // 2) try local opencode.json / jsonc in project
541
+ if (!statePath) {
542
+ const candidates = [join(ctx.directory, "opencode.json"), join(ctx.directory, "opencode.jsonc")]
543
+ // also try global config (XDG / APPDATA)
544
+ try {
545
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? ""
546
+ if (home) {
547
+ const xdg = process.env.XDG_CONFIG_HOME ?? join(home, ".config")
548
+ candidates.push(join(xdg, "opencode", "opencode.json"))
549
+ candidates.push(join(xdg, "opencode", "opencode.jsonc"))
550
+ if (process.platform === "win32" && process.env.APPDATA) {
551
+ candidates.push(join(process.env.APPDATA, "opencode", "opencode.json"))
552
+ candidates.push(join(process.env.APPDATA, "opencode", "opencode.jsonc"))
553
+ }
554
+ }
555
+ } catch {}
556
+ for (const cand of candidates) {
557
+ try {
558
+ const raw = readFileSync(cand, "utf-8")
559
+ // strip // and /* */ comments for jsonc
560
+ let j = raw
561
+ .replace(/\/\/.*$/gm, "")
562
+ .replace(/\/\*[\s\S]*?\*\//g, "")
563
+ .replace(/,\s*([}\]])/g, "$1")
564
+ const cfg = JSON.parse(j)
565
+ const envPath = (cfg as any)?.mcp?.["ostacky-controller"]?.environment?.OSTACKY_STATE_PATH
566
+ if (typeof envPath === "string" && envPath) {
567
+ statePath = envPath
568
+ break
569
+ }
570
+ } catch {}
571
+ }
572
+ }
573
+ if (!statePath) statePath = join(ctx.directory, ".opencode", "ostacky-state.json")
574
+ const fallbackPath = join(dirname(statePath), ".ostacky-handoff-compaction.json")
575
+ try { mkdirSync(dirname(fallbackPath), { recursive: true }) } catch {}
576
+ const payload = {
577
+ summary: `Compaction fallback for session ${input.sessionID ?? "unknown"} — project ${project}`,
578
+ nextSteps: [] as string[],
579
+ pendingTasks: [] as string[],
580
+ ts: Date.now(),
581
+ contextSnippet: output.context?.slice(0, 2).join("\n\n").slice(0, 1000) ?? "",
582
+ }
583
+ const tmp = `${fallbackPath}.tmp.${process.pid}`
584
+ writeFileSync(tmp, JSON.stringify(payload, null, 2), "utf-8")
585
+ renameSync(tmp, fallbackPath)
586
+ } catch {
587
+ // fallback is best-effort — never crash compacting
588
+ }
589
+
516
590
  // Inject context from previous sessions
517
591
  const data = await engramFetch(
518
592
  `/context?project=${encodeURIComponent(project)}`
@@ -157,6 +157,10 @@ Modo básico: sin validación de edits, sin memoria persistente, sin análisis e
157
157
  ¿Continuar o cancelar?
158
158
  ```
159
159
 
160
+ ## Handoff Fallback (compaction)
161
+
162
+ Si el controller hizo `set_handoff` o el plugin escribió el fallback `dirname(OSTACKY_STATE_PATH)/.ostacky-handoff-compaction.json` antes de compaction, el próximo agente **debe** llamar `get_handoff` al inicio. `get_handoff` primero chequea `lastHandoff` en memoria y si es `null` lee el archivo fallback (mismo ancla que el writer). `clear_handoff` borra ambos. `cleanupTmpFiles` solo borra ese archivo si `ts >24h`. Ver `assets/plugins/engram.ts:experimental.session.compacting` y `assets/mcp/ostacky-controller/index.js:get_handoff`.
163
+
160
164
  ## Recovery After Degradation
161
165
 
162
166
  When a tool becomes available again during the session: