ostacky 0.8.8 → 0.9.1
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 +17 -17
- package/assets/agents/ostacky.md +7 -7
- package/assets/commands/install-stack.md +38 -30
- package/assets/docs/ostacky-reference.md +1 -1
- package/assets/mcp/ostacky-controller/controller-core.js +2 -0
- package/assets/mcp/ostacky-controller/index.js +248 -67
- package/assets/mcp/ostacky-controller/package.json +1 -1
- package/assets/plugins/controller-core.ts +2 -0
- package/assets/plugins/engram.ts +648 -646
- package/assets/plugins/ostacky-plugin.ts +258 -214
- package/dist/cli.js +288 -108
- package/manifest.json +28 -28
- package/package.json +2 -2
package/assets/plugins/engram.ts
CHANGED
|
@@ -1,646 +1,648 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Engram — OpenCode plugin adapter
|
|
3
|
-
*
|
|
4
|
-
* Thin layer that connects OpenCode's event system to the Engram Go binary.
|
|
5
|
-
* The Go binary runs as a local HTTP server and handles all persistence.
|
|
6
|
-
*
|
|
7
|
-
* Flow:
|
|
8
|
-
* OpenCode events → this plugin → HTTP calls → engram serve → SQLite
|
|
9
|
-
*
|
|
10
|
-
* Session resilience:
|
|
11
|
-
* Uses `ensureSession()` before any DB write. This means sessions are
|
|
12
|
-
* created on-demand — even if the plugin was loaded after the session
|
|
13
|
-
* started (restart, reconnect, etc.). The session ID comes from OpenCode's
|
|
14
|
-
* hooks (
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import
|
|
18
|
-
import { join, dirname, basename } from "path"
|
|
19
|
-
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "fs"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
i
|
|
174
|
-
continue
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
const
|
|
429
|
-
const
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
const
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
//
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
//
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
)
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Engram — OpenCode plugin adapter (OpenCode V2)
|
|
3
|
+
*
|
|
4
|
+
* Thin layer that connects OpenCode's event system to the Engram Go binary.
|
|
5
|
+
* The Go binary runs as a local HTTP server and handles all persistence.
|
|
6
|
+
*
|
|
7
|
+
* Flow:
|
|
8
|
+
* OpenCode events → this plugin → HTTP calls → engram serve → SQLite
|
|
9
|
+
*
|
|
10
|
+
* Session resilience:
|
|
11
|
+
* Uses `ensureSession()` before any DB write. This means sessions are
|
|
12
|
+
* created on-demand — even if the plugin was loaded after the session
|
|
13
|
+
* started (restart, reconnect, etc.). The session ID comes from OpenCode's
|
|
14
|
+
* hooks (event.sessionID) rather than relying on a session.created event.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Plugin } from "@opencode/plugin"
|
|
18
|
+
import { join, dirname, basename, delimiter } from "node:path"
|
|
19
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from "node:fs"
|
|
20
|
+
import { spawn, spawnSync } from "node:child_process"
|
|
21
|
+
|
|
22
|
+
// ─── Configuration ───────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const ENGRAM_PORT = parseInt(process.env.ENGRAM_PORT ?? "7437")
|
|
25
|
+
const ENGRAM_URL = `http://127.0.0.1:${ENGRAM_PORT}`
|
|
26
|
+
// C3/H2 fix: resolve ENGRAM_BIN per directory with win32 .exe and absolute fallback
|
|
27
|
+
function lookupOnPath(name: string): string | null {
|
|
28
|
+
try {
|
|
29
|
+
const pathEnv = process.env.PATH ?? ""
|
|
30
|
+
const suffix = process.platform === "win32" ? ".exe" : ""
|
|
31
|
+
for (const dir of pathEnv.split(delimiter)) {
|
|
32
|
+
if (!dir) continue
|
|
33
|
+
try {
|
|
34
|
+
const cand = join(dir, name + suffix)
|
|
35
|
+
if (existsSync(cand)) return cand
|
|
36
|
+
if (suffix && existsSync(join(dir, name))) return join(dir, name)
|
|
37
|
+
} catch {}
|
|
38
|
+
}
|
|
39
|
+
} catch {}
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
function resolveEngramBin(directory: string): string {
|
|
43
|
+
if (process.env.ENGRAM_BIN) {
|
|
44
|
+
const p = process.env.ENGRAM_BIN
|
|
45
|
+
const isAbs = p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p)
|
|
46
|
+
return isAbs ? p : join(directory, p)
|
|
47
|
+
}
|
|
48
|
+
const which = lookupOnPath("engram")
|
|
49
|
+
if (which) return which
|
|
50
|
+
const suffix = process.platform === "win32" ? ".exe" : ""
|
|
51
|
+
return join(directory, ".opencode", "tools", "engram", "bin", `engram${suffix}`)
|
|
52
|
+
}
|
|
53
|
+
// ENGRAM_BIN eliminado: reemplazado por resolveEngramBin(directory) que maneja .exe+absolutización correctamente
|
|
54
|
+
|
|
55
|
+
// Engram's own MCP tools — don't count these as "tool calls" for session stats
|
|
56
|
+
const ENGRAM_TOOLS = new Set([
|
|
57
|
+
"mem_search",
|
|
58
|
+
"mem_save",
|
|
59
|
+
"mem_update",
|
|
60
|
+
"mem_delete",
|
|
61
|
+
"mem_suggest_topic_key",
|
|
62
|
+
"mem_save_prompt",
|
|
63
|
+
"mem_session_summary",
|
|
64
|
+
"mem_context",
|
|
65
|
+
"mem_stats",
|
|
66
|
+
"mem_timeline",
|
|
67
|
+
"mem_get_observation",
|
|
68
|
+
"mem_session_start",
|
|
69
|
+
"mem_session_end",
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
// ─── Memory Instructions ─────────────────────────────────────────────────────
|
|
73
|
+
// Lazy: full protocol lives in assets/docs/engram-protocol.md (on-demand via Read).
|
|
74
|
+
// System injects only pointer + nudge, not full 1.2k. Source-of-truth: src/tiered.ts for isTrivial.
|
|
75
|
+
|
|
76
|
+
const MEMORY_POINTER = "Engram disponible — para formato mem_save/mem_search lee assets/docs/engram-protocol.md (on-demand). Usa mem_search proactivamente si el tema pudo verse antes."
|
|
77
|
+
const MEMORY_INSTRUCTIONS_LAZY = `## Engram — pointer (lazy)
|
|
78
|
+
|
|
79
|
+
${MEMORY_POINTER}
|
|
80
|
+
|
|
81
|
+
Cuando necesites guardar/buscar, lee el protocolo completo con Read. No alucines formato.`
|
|
82
|
+
// Compat: keep full for fallback if file missing — but never inject full in system.transform
|
|
83
|
+
const MEMORY_INSTRUCTIONS = MEMORY_INSTRUCTIONS_LAZY
|
|
84
|
+
|
|
85
|
+
// ─── HTTP Client ─────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
async function engramFetch(
|
|
88
|
+
path: string,
|
|
89
|
+
opts: { method?: string; body?: any } = {}
|
|
90
|
+
): Promise<any> {
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(`${ENGRAM_URL}${path}`, {
|
|
93
|
+
method: opts.method ?? "GET",
|
|
94
|
+
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
|
|
95
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
96
|
+
})
|
|
97
|
+
return await res.json()
|
|
98
|
+
} catch {
|
|
99
|
+
// Engram server not running — silently fail
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function isEngramRunning(): Promise<boolean> {
|
|
105
|
+
try {
|
|
106
|
+
const res = await fetch(`${ENGRAM_URL}/health`, {
|
|
107
|
+
signal: AbortSignal.timeout(500),
|
|
108
|
+
})
|
|
109
|
+
return res.ok
|
|
110
|
+
} catch {
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
function extractProjectName(directory: string): string {
|
|
118
|
+
// Try git remote origin URL
|
|
119
|
+
try {
|
|
120
|
+
const result = spawnSync("git", ["-C", directory, "remote", "get-url", "origin"], { encoding: "utf-8" })
|
|
121
|
+
if (result.status === 0) {
|
|
122
|
+
const url = (result.stdout ?? "").trim()
|
|
123
|
+
if (url) {
|
|
124
|
+
const name = url.replace(/\.git$/, "").split(/[/:]/).pop()
|
|
125
|
+
if (name) return name
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch {}
|
|
129
|
+
|
|
130
|
+
// Fallback: git root directory name (works in worktrees)
|
|
131
|
+
try {
|
|
132
|
+
const result = spawnSync("git", ["-C", directory, "rev-parse", "--show-toplevel"], { encoding: "utf-8" })
|
|
133
|
+
if (result.status === 0) {
|
|
134
|
+
const root = (result.stdout ?? "").trim()
|
|
135
|
+
if (root) return basename(root.replace(/\\/g, "/")) ?? "unknown"
|
|
136
|
+
}
|
|
137
|
+
} catch {}
|
|
138
|
+
|
|
139
|
+
// Final fallback: cwd basename (cross-platform)
|
|
140
|
+
return basename(directory.replace(/\\/g, "/")) ?? "unknown"
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function truncate(str: string, max: number): string {
|
|
144
|
+
if (!str) return ""
|
|
145
|
+
return str.length > max ? str.slice(0, max) + "..." : str
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Strip <private>...</private> tags before sending to engram.
|
|
150
|
+
* Double safety: the Go binary also strips, but we strip here too
|
|
151
|
+
* so sensitive data never even hits the wire.
|
|
152
|
+
*/
|
|
153
|
+
function stripPrivateTags(str: string): string {
|
|
154
|
+
if (!str) return ""
|
|
155
|
+
return str.replace(/<private>[\s\S]*?<\/private>/gi, "[REDACTED]").trim()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function stripJsoncComments(text: string): string {
|
|
159
|
+
let result = ""
|
|
160
|
+
let i = 0
|
|
161
|
+
let inString = false
|
|
162
|
+
while (i < text.length) {
|
|
163
|
+
const char = text[i]
|
|
164
|
+
const next = text[i + 1]
|
|
165
|
+
if (inString) {
|
|
166
|
+
if (char === "\\") {
|
|
167
|
+
result += char + (next ?? "")
|
|
168
|
+
i += 2
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
if (char === '"') inString = false
|
|
172
|
+
result += char
|
|
173
|
+
i++
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
if (char === '"') {
|
|
177
|
+
inString = true
|
|
178
|
+
result += char
|
|
179
|
+
i++
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
if (char === "/" && next === "/") {
|
|
183
|
+
while (i < text.length && text[i] !== "\n") i++
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
if (char === "/" && next === "*") {
|
|
187
|
+
i += 2
|
|
188
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++
|
|
189
|
+
i += 2
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
result += char
|
|
193
|
+
i++
|
|
194
|
+
}
|
|
195
|
+
return result.replace(/,\s*([}\]])/g, "$1")
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ─── Plugin ──────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
export default Plugin.define({
|
|
201
|
+
id: "engram",
|
|
202
|
+
async setup(ctx) {
|
|
203
|
+
const directory = ctx.location.directory
|
|
204
|
+
// T4: basename multiplataforma — split("/") producía keys basura con backslashes en Windows nativo
|
|
205
|
+
const oldProject = basename(directory.replace(/\\/g, "/")) ?? "unknown"
|
|
206
|
+
const project = extractProjectName(directory)
|
|
207
|
+
|
|
208
|
+
// Track tool counts per session (in-memory only, not critical)
|
|
209
|
+
const toolCounts = new Map<string, number>()
|
|
210
|
+
|
|
211
|
+
// Track last nudge time per session to debounce save reminders
|
|
212
|
+
const lastNudgeTime = new Map<string, number>() // sessionID -> epoch seconds
|
|
213
|
+
|
|
214
|
+
// Track which sessions we've already ensured exist in engram
|
|
215
|
+
const knownSessions = new Set<string>()
|
|
216
|
+
|
|
217
|
+
// Track sub-agent session IDs so we can suppress their tool-hook registrations.
|
|
218
|
+
// Sub-agents (subagent tool calls) have a parentID or a title ending in " subagent)".
|
|
219
|
+
// We must not register them as top-level Engram sessions — they cause session
|
|
220
|
+
// inflation (e.g. 170 sessions for 1 real conversation, issue #116).
|
|
221
|
+
const subAgentSessions = new Set<string>()
|
|
222
|
+
|
|
223
|
+
// Tiered cache-friendly: single source of truth via src/tiered.ts
|
|
224
|
+
const trivialBySession = new Map<string, boolean>()
|
|
225
|
+
// isTrivial y getControllerState importados lógicamente desde src/tiered.ts
|
|
226
|
+
// Inlined para evitar import dinámico en plugin bundle — mantener regex idéntico a src/tiered.ts
|
|
227
|
+
function isTrivialMessage(msg: string, state: string): boolean {
|
|
228
|
+
if (!msg || state !== "DONE") return false
|
|
229
|
+
if (msg.trim().length >= 30) return false
|
|
230
|
+
if (!/^(hola|hey|gracias|buenas|hi|hello)\b/i.test(msg.trim())) return false
|
|
231
|
+
if (/(necesito|quiero|agregá|fix|bug|feature|auth|spec|implementar)/i.test(msg)) return false
|
|
232
|
+
return true
|
|
233
|
+
}
|
|
234
|
+
function getControllerState(dir: string): string {
|
|
235
|
+
try {
|
|
236
|
+
const statePath = process.env.OSTACKY_STATE_PATH || join(dir, ".opencode", "ostacky-state.json")
|
|
237
|
+
const raw = readFileSync(statePath, "utf-8")
|
|
238
|
+
const j = JSON.parse(raw)
|
|
239
|
+
return j.state ?? "DONE"
|
|
240
|
+
} catch { return "DONE" }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Ensure a session exists in engram. Idempotent — calls POST /sessions
|
|
245
|
+
* which uses INSERT OR IGNORE. Safe to call multiple times.
|
|
246
|
+
*
|
|
247
|
+
* Silently skips sub-agent sessions (tracked in `subAgentSessions`).
|
|
248
|
+
*/
|
|
249
|
+
async function ensureSession(sessionId: string): Promise<void> {
|
|
250
|
+
if (!sessionId || knownSessions.has(sessionId)) return
|
|
251
|
+
// Do not register sub-agent sessions in Engram (issue #116).
|
|
252
|
+
if (subAgentSessions.has(sessionId)) return
|
|
253
|
+
knownSessions.add(sessionId)
|
|
254
|
+
await engramFetch("/sessions", {
|
|
255
|
+
method: "POST",
|
|
256
|
+
body: {
|
|
257
|
+
id: sessionId,
|
|
258
|
+
project,
|
|
259
|
+
directory,
|
|
260
|
+
},
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Try to start engram server if not running — use per-directory resolved bin (win32 .exe + absolute)
|
|
265
|
+
const engramBin = resolveEngramBin(directory)
|
|
266
|
+
const running = await isEngramRunning()
|
|
267
|
+
if (!running) {
|
|
268
|
+
try {
|
|
269
|
+
const child = spawn(engramBin, ["serve"], {
|
|
270
|
+
detached: true,
|
|
271
|
+
stdio: "ignore",
|
|
272
|
+
cwd: directory,
|
|
273
|
+
})
|
|
274
|
+
child.unref()
|
|
275
|
+
await new Promise((r) => setTimeout(r, 500))
|
|
276
|
+
} catch {
|
|
277
|
+
// Binary not found or can't start — plugin will silently no-op
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Migrate project name if it changed (one-time, idempotent)
|
|
282
|
+
// Must run AFTER server startup to ensure the endpoint is available
|
|
283
|
+
if (oldProject !== project) {
|
|
284
|
+
await engramFetch("/projects/migrate", {
|
|
285
|
+
method: "POST",
|
|
286
|
+
body: { old_project: oldProject, new_project: project },
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Auto-import: if .engram/manifest.json exists in the project repo,
|
|
291
|
+
// run `engram sync --import` to load any new chunks into the local DB.
|
|
292
|
+
// This is how git-synced memories get loaded when cloning a repo or
|
|
293
|
+
// pulling changes. Each chunk is imported only once (tracked by ID).
|
|
294
|
+
try {
|
|
295
|
+
const manifestFile = join(directory, ".engram", "manifest.json")
|
|
296
|
+
if (existsSync(manifestFile)) {
|
|
297
|
+
const child = spawn(engramBin, ["sync", "--import"], {
|
|
298
|
+
detached: true,
|
|
299
|
+
stdio: "ignore",
|
|
300
|
+
cwd: directory,
|
|
301
|
+
})
|
|
302
|
+
child.unref()
|
|
303
|
+
}
|
|
304
|
+
} catch {
|
|
305
|
+
// Manifest doesn't exist or binary not found — silently skip
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ─── Event subscription (session lifecycle) ──────────────────────
|
|
309
|
+
const eventController = new AbortController()
|
|
310
|
+
void (async () => {
|
|
311
|
+
for await (const event of ctx.event.subscribe({ signal: eventController.signal })) {
|
|
312
|
+
// --- Session Created ---
|
|
313
|
+
if (event.type === "session.created") {
|
|
314
|
+
// V2: session data is flat under event.data (id/parentID/title),
|
|
315
|
+
// not nested under properties.info like V1.
|
|
316
|
+
const data = event.data as { sessionID?: string; parentID?: string | null; title?: string }
|
|
317
|
+
const sessionId = data?.sessionID
|
|
318
|
+
const parentID = data?.parentID
|
|
319
|
+
const title: string = data?.title ?? ""
|
|
320
|
+
|
|
321
|
+
// Sub-agent sessions (created via subagent tool) must NOT be registered as
|
|
322
|
+
// top-level Engram sessions. They cause massive session inflation
|
|
323
|
+
// (e.g. 170 sessions for 1 real conversation).
|
|
324
|
+
//
|
|
325
|
+
// Detection heuristics:
|
|
326
|
+
// - parentID is set on all sub-agent sessions
|
|
327
|
+
// - title ends with " subagent)" as a secondary signal
|
|
328
|
+
const isSubAgent = !!parentID || title.endsWith(" subagent)")
|
|
329
|
+
|
|
330
|
+
if (sessionId && !isSubAgent) {
|
|
331
|
+
await ensureSession(sessionId)
|
|
332
|
+
} else if (sessionId && isSubAgent) {
|
|
333
|
+
// Remember this as a sub-agent session so tool-hook calls
|
|
334
|
+
// to ensureSession() are also suppressed for it.
|
|
335
|
+
subAgentSessions.add(sessionId)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// --- Session Deleted ---
|
|
340
|
+
if (event.type === "session.deleted") {
|
|
341
|
+
// V2: event.data.sessionID (flat), not properties.info.id.
|
|
342
|
+
const sessionId = (event.data as { sessionID?: string })?.sessionID
|
|
343
|
+
if (sessionId) {
|
|
344
|
+
toolCounts.delete(sessionId)
|
|
345
|
+
knownSessions.delete(sessionId)
|
|
346
|
+
subAgentSessions.delete(sessionId)
|
|
347
|
+
lastNudgeTime.delete(sessionId)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
})()
|
|
352
|
+
|
|
353
|
+
// ─── User Prompt Capture ──────────────────────────────────────
|
|
354
|
+
// session prompt hook runs once per user message at admission, before the LLM sees it.
|
|
355
|
+
// event.sessionID is always reliable here. event.prompt.text holds the message text.
|
|
356
|
+
|
|
357
|
+
await ctx.session.hook("prompt", async (event) => {
|
|
358
|
+
// Skip sub-agent sessions — they inflate session counts (issue #116)
|
|
359
|
+
if (subAgentSessions.has(event.sessionID)) return
|
|
360
|
+
|
|
361
|
+
const sessionId = event.sessionID
|
|
362
|
+
|
|
363
|
+
// Prompt text is the source (V2 prompt admission carries the full text)
|
|
364
|
+
const finalContent = (event.prompt.text ?? "").trim()
|
|
365
|
+
|
|
366
|
+
// Tiered: set trivial flag for context-hook pointer selection
|
|
367
|
+
try {
|
|
368
|
+
const state = getControllerState(directory)
|
|
369
|
+
trivialBySession.set(sessionId, isTrivialMessage(finalContent, state))
|
|
370
|
+
} catch {}
|
|
371
|
+
|
|
372
|
+
// Only capture non-trivial prompts (>10 chars)
|
|
373
|
+
if (finalContent.length > 10) {
|
|
374
|
+
await ensureSession(sessionId)
|
|
375
|
+
await engramFetch("/prompts", {
|
|
376
|
+
method: "POST",
|
|
377
|
+
body: {
|
|
378
|
+
session_id: sessionId,
|
|
379
|
+
content: stripPrivateTags(truncate(finalContent, 2000)),
|
|
380
|
+
project,
|
|
381
|
+
},
|
|
382
|
+
})
|
|
383
|
+
}
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
// ─── Tool Execution Hook ─────────────────────────────────────
|
|
387
|
+
// Count tool calls per session (for session end stats).
|
|
388
|
+
// Also ensures the session exists — handles plugin reload / reconnect.
|
|
389
|
+
// Passive capture: when a subagent tool completes, POST its output to
|
|
390
|
+
// the passive capture endpoint so the server extracts learnings.
|
|
391
|
+
|
|
392
|
+
await ctx.tool.hook("execute.after", async (event) => {
|
|
393
|
+
if (ENGRAM_TOOLS.has(event.tool.toLowerCase())) return
|
|
394
|
+
|
|
395
|
+
// event.sessionID comes from OpenCode — always available
|
|
396
|
+
const sessionId = event.sessionID
|
|
397
|
+
if (sessionId) {
|
|
398
|
+
await ensureSession(sessionId)
|
|
399
|
+
toolCounts.set(sessionId, (toolCounts.get(sessionId) ?? 0) + 1)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Passive capture: extract learnings from subagent tool output
|
|
403
|
+
// (V2 tool name is "subagent"; V1 "Task" no longer exists — see v2/docs/tools)
|
|
404
|
+
if (event.tool === "subagent" && event.status === "completed" && event.result && sessionId) {
|
|
405
|
+
const text = JSON.stringify(event.result)
|
|
406
|
+
if (text.length > 50) {
|
|
407
|
+
await engramFetch("/observations/passive", {
|
|
408
|
+
method: "POST",
|
|
409
|
+
body: {
|
|
410
|
+
session_id: sessionId,
|
|
411
|
+
content: stripPrivateTags(text),
|
|
412
|
+
project,
|
|
413
|
+
source: "task-complete",
|
|
414
|
+
},
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
// ─── System Pointer: Always-on memory instructions ──────────
|
|
421
|
+
// Injects the lazy pointer into model-visible context before each agent request.
|
|
422
|
+
// This ensures the agent ALWAYS knows about Engram, even after compaction.
|
|
423
|
+
//
|
|
424
|
+
// We append to system (not a new message) so prompt caching stays stable.
|
|
425
|
+
// Tiered lazy: SIEMPRE pointer (cache-friendly, ~1 línea). Full vive en assets/docs/engram-protocol.md on-demand.
|
|
426
|
+
|
|
427
|
+
await ctx.session.hook("context", async (event) => {
|
|
428
|
+
const sessionId: string = event.sessionID ?? ""
|
|
429
|
+
const isTrivial = trivialBySession.get(sessionId) ?? false
|
|
430
|
+
const state = getControllerState(directory)
|
|
431
|
+
const shouldBeTrivial = isTrivial && state === "DONE"
|
|
432
|
+
const pointer = shouldBeTrivial
|
|
433
|
+
? "Engram disponible — detalles a demanda (usa mem_search si necesitas recordar)."
|
|
434
|
+
: MEMORY_POINTER
|
|
435
|
+
event.system.push({ type: "text", text: pointer })
|
|
436
|
+
// No inyectar MEMORY_INSTRUCTIONS completo nunca — se lee on-demand via Read
|
|
437
|
+
|
|
438
|
+
// harden-compaction-resume: auto-inject recovery hint when pending (no depende del modelo)
|
|
439
|
+
if (!shouldBeTrivial) {
|
|
440
|
+
try {
|
|
441
|
+
const statePath = process.env.OSTACKY_STATE_PATH || join(directory, ".opencode", "ostacky-state.json")
|
|
442
|
+
const raw = readFileSync(statePath, "utf-8")
|
|
443
|
+
const st = JSON.parse(raw)
|
|
444
|
+
const curState = st?.state ?? "DONE"
|
|
445
|
+
if (!["DONE", "INTERPRETATION_PENDING"].includes(curState)) {
|
|
446
|
+
let pending: string[] = Array.isArray(st?.lastHandoff?.pendingTasks)
|
|
447
|
+
? st.lastHandoff.pendingTasks.filter((id: string) => !st.tasks?.[id] || st.tasks[id].status !== "COMPLETED")
|
|
448
|
+
: []
|
|
449
|
+
if (pending.length === 0) {
|
|
450
|
+
try {
|
|
451
|
+
const fbPath = join(dirname(statePath), ".ostacky-handoff-compaction.json")
|
|
452
|
+
const fbRaw = readFileSync(fbPath, "utf-8")
|
|
453
|
+
const fb = JSON.parse(fbRaw)
|
|
454
|
+
if (fb && Array.isArray(fb.pendingTasks) && typeof fb.ts === "number" && Date.now() - fb.ts < 24 * 60 * 60 * 1000) {
|
|
455
|
+
const fbPend = fb.pendingTasks.filter((id: string) => !st.tasks?.[id] || st.tasks[id].status !== "COMPLETED")
|
|
456
|
+
if (fbPend.length > 0) pending = fbPend
|
|
457
|
+
}
|
|
458
|
+
} catch {}
|
|
459
|
+
}
|
|
460
|
+
if (pending.length > 0) {
|
|
461
|
+
const hint = `[RECOVERY: te quedan ${pending.slice(0, 3).join(",")}${pending.length > 3 ? `, +${pending.length - 3} más` : ""} - usa get_handoff / mem_context para retomar]`
|
|
462
|
+
event.system.push({ type: "text", text: hint })
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
} catch {}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ── Save nudge ──────────────────────────────────────────────────────────
|
|
469
|
+
// Skip nudge for trivial greeting (cache-friendly, no extra injection)
|
|
470
|
+
if (shouldBeTrivial) return
|
|
471
|
+
// If it has been a long time since the last mem_save, append a reminder
|
|
472
|
+
// to the system context so the agent notices. All fetches are fire-and-
|
|
473
|
+
// forget with short timeouts — any failure silently skips the nudge.
|
|
474
|
+
try {
|
|
475
|
+
if (!sessionId || subAgentSessions.has(sessionId)) return
|
|
476
|
+
|
|
477
|
+
// SQLite datetime('now') returns "YYYY-MM-DD HH:MM:SS" in UTC with no
|
|
478
|
+
// zone suffix; new Date() would parse that as local time. Normalize to
|
|
479
|
+
// UTC first so the thresholds are correct in every timezone.
|
|
480
|
+
const toEpochSecs = (ts: string): number => {
|
|
481
|
+
if (!ts) return 0
|
|
482
|
+
const normalized = ts.includes("T") ? ts : ts.replace(" ", "T") + "Z"
|
|
483
|
+
const ms = new Date(normalized).getTime()
|
|
484
|
+
return Number.isNaN(ms) ? 0 : Math.floor(ms / 1000)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const cooldownSecs = parseInt(process.env.ENGRAM_NUDGE_COOLDOWN_SECS ?? "900", 10)
|
|
488
|
+
const nowSecs = Math.floor(Date.now() / 1000)
|
|
489
|
+
|
|
490
|
+
// Debounce: skip if we nudged recently this session
|
|
491
|
+
const lastNudge = lastNudgeTime.get(sessionId)
|
|
492
|
+
if (lastNudge !== undefined && nowSecs - lastNudge < cooldownSecs) return
|
|
493
|
+
|
|
494
|
+
// Skip if the session is too young (< 5 minutes)
|
|
495
|
+
let sessionStartEpoch = 0
|
|
496
|
+
try {
|
|
497
|
+
const sessionRes = await fetch(`${ENGRAM_URL}/sessions/${encodeURIComponent(sessionId)}`, {
|
|
498
|
+
signal: AbortSignal.timeout(200),
|
|
499
|
+
})
|
|
500
|
+
if (sessionRes.ok) {
|
|
501
|
+
const sessionData = await sessionRes.json()
|
|
502
|
+
const startedAt: string = sessionData?.started_at ?? ""
|
|
503
|
+
if (startedAt) {
|
|
504
|
+
sessionStartEpoch = toEpochSecs(startedAt)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
} catch {
|
|
508
|
+
// Server unreachable or timed out — skip nudge
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
if (sessionStartEpoch > 0 && nowSecs - sessionStartEpoch < 300) return
|
|
512
|
+
|
|
513
|
+
// Check when the last observation was saved for this project
|
|
514
|
+
let lastObsEpoch = 0
|
|
515
|
+
try {
|
|
516
|
+
const obsRes = await fetch(
|
|
517
|
+
`${ENGRAM_URL}/observations?project=${encodeURIComponent(project)}&limit=1&sort=created_at:desc`,
|
|
518
|
+
{ signal: AbortSignal.timeout(200) }
|
|
519
|
+
)
|
|
520
|
+
if (obsRes.ok) {
|
|
521
|
+
const obsData = await obsRes.json()
|
|
522
|
+
const createdAt: string = obsData?.[0]?.created_at ?? ""
|
|
523
|
+
if (createdAt) {
|
|
524
|
+
lastObsEpoch = toEpochSecs(createdAt)
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
} catch {
|
|
528
|
+
// Server unreachable or timed out — skip nudge
|
|
529
|
+
return
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// No observations yet — nothing to nudge about
|
|
533
|
+
if (lastObsEpoch === 0) return
|
|
534
|
+
|
|
535
|
+
// Only nudge if last save was more than 15 minutes ago
|
|
536
|
+
if (nowSecs - lastObsEpoch < 900) return
|
|
537
|
+
|
|
538
|
+
// Append the nudge to system context
|
|
539
|
+
const nudge =
|
|
540
|
+
"MEMORY REMINDER: It's been over 15 minutes since your last memory save. " +
|
|
541
|
+
"If you've made decisions, discoveries, completed significant work, or found non-obvious things, " +
|
|
542
|
+
"call mem_save now."
|
|
543
|
+
event.system.push({ type: "text", text: nudge })
|
|
544
|
+
lastNudgeTime.set(sessionId, nowSecs)
|
|
545
|
+
} catch {
|
|
546
|
+
// Any unexpected error — silently skip the nudge, never crash the hook
|
|
547
|
+
}
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
// ─── Compaction Hook: Persist memory + inject context ──────────
|
|
551
|
+
// Compaction is triggered by the system (not the agent) when context
|
|
552
|
+
// gets too long. The old agent "dies" and a new one starts with the
|
|
553
|
+
// compacted summary. This is our chance to:
|
|
554
|
+
// 1. Auto-save a session checkpoint (the agent can't do this itself)
|
|
555
|
+
// 2. Inject context from previous sessions into the compaction prompt
|
|
556
|
+
// 3. Tell the compressor to remind the new agent to save memories
|
|
557
|
+
|
|
558
|
+
await ctx.session.hook("compaction", async (event) => {
|
|
559
|
+
if (event.sessionID) {
|
|
560
|
+
await ensureSession(event.sessionID)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// C3: Compaction fallback file — write directly to same anchor as controller's get_handoff
|
|
564
|
+
// Resolves statePath from opencode.json (local) or global config, default .opencode/ostacky-state.json
|
|
565
|
+
try {
|
|
566
|
+
let statePath: string | null = null
|
|
567
|
+
// 1) env var if set
|
|
568
|
+
if (process.env.OSTACKY_STATE_PATH) {
|
|
569
|
+
statePath = process.env.OSTACKY_STATE_PATH
|
|
570
|
+
}
|
|
571
|
+
// 2) try local opencode.json / jsonc in project
|
|
572
|
+
if (!statePath) {
|
|
573
|
+
const candidates = [join(directory, "opencode.json"), join(directory, "opencode.jsonc")]
|
|
574
|
+
// also try global config (XDG / APPDATA)
|
|
575
|
+
try {
|
|
576
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? ""
|
|
577
|
+
if (home) {
|
|
578
|
+
const xdg = process.env.XDG_CONFIG_HOME ?? join(home, ".config")
|
|
579
|
+
candidates.push(join(xdg, "opencode", "opencode.json"))
|
|
580
|
+
candidates.push(join(xdg, "opencode", "opencode.jsonc"))
|
|
581
|
+
if (process.platform === "win32" && process.env.APPDATA) {
|
|
582
|
+
candidates.push(join(process.env.APPDATA, "opencode", "opencode.json"))
|
|
583
|
+
candidates.push(join(process.env.APPDATA, "opencode", "opencode.jsonc"))
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
} catch {}
|
|
587
|
+
for (const cand of candidates) {
|
|
588
|
+
try {
|
|
589
|
+
const raw = readFileSync(cand, "utf-8")
|
|
590
|
+
const j = stripJsoncComments(raw)
|
|
591
|
+
const cfg = JSON.parse(j)
|
|
592
|
+
const envPath = (cfg as any)?.mcp?.["ostacky-controller"]?.environment?.OSTACKY_STATE_PATH
|
|
593
|
+
?? (cfg as any)?.mcp?.servers?.["ostacky-controller"]?.environment?.OSTACKY_STATE_PATH
|
|
594
|
+
if (typeof envPath === "string" && envPath) {
|
|
595
|
+
statePath = envPath
|
|
596
|
+
break
|
|
597
|
+
}
|
|
598
|
+
} catch {}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (!statePath) statePath = join(directory, ".opencode", "ostacky-state.json")
|
|
602
|
+
const fallbackPath = join(dirname(statePath), ".ostacky-handoff-compaction.json")
|
|
603
|
+
try { mkdirSync(dirname(fallbackPath), { recursive: true }) } catch {}
|
|
604
|
+
const contextSnippet = (() => {
|
|
605
|
+
try {
|
|
606
|
+
return JSON.stringify(event.messages.slice(0, 2)).slice(0, 1000)
|
|
607
|
+
} catch { return "" }
|
|
608
|
+
})()
|
|
609
|
+
const payload = {
|
|
610
|
+
summary: `Compaction fallback for session ${event.sessionID ?? "unknown"} — project ${project}`,
|
|
611
|
+
nextSteps: [] as string[],
|
|
612
|
+
pendingTasks: [] as string[],
|
|
613
|
+
ts: Date.now(),
|
|
614
|
+
contextSnippet,
|
|
615
|
+
}
|
|
616
|
+
const tmp = `${fallbackPath}.tmp.${process.pid}`
|
|
617
|
+
writeFileSync(tmp, JSON.stringify(payload, null, 2), "utf-8")
|
|
618
|
+
renameSync(tmp, fallbackPath)
|
|
619
|
+
} catch {
|
|
620
|
+
// fallback is best-effort — never crash compacting
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Inject context from previous sessions into the summarizer's system
|
|
624
|
+
const data = await engramFetch(
|
|
625
|
+
`/context?project=${encodeURIComponent(project)}`
|
|
626
|
+
)
|
|
627
|
+
if (data?.context) {
|
|
628
|
+
event.system.push({ type: "text", text: data.context })
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Tell the compressor to instruct the new agent to persist the
|
|
632
|
+
// compacted summary to Engram. The new agent reads the compacted
|
|
633
|
+
// summary and this instruction, then saves it as a session summary.
|
|
634
|
+
event.system.push({
|
|
635
|
+
type: "text",
|
|
636
|
+
text:
|
|
637
|
+
`CRITICAL INSTRUCTION FOR COMPACTED SUMMARY:\n` +
|
|
638
|
+
`The agent has access to Engram persistent memory via MCP tools.\n` +
|
|
639
|
+
`You MUST include the following instruction at the TOP of the compacted summary:\n\n` +
|
|
640
|
+
`"FIRST ACTION REQUIRED: Call mem_session_summary with the content of this compacted summary. ` +
|
|
641
|
+
`Use project: '${project}'. This preserves what was accomplished before compaction. Do this BEFORE any other work."\n\n` +
|
|
642
|
+
`This is NOT optional. Without this, everything done before compaction is lost from memory.`,
|
|
643
|
+
})
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
return () => eventController.abort()
|
|
647
|
+
},
|
|
648
|
+
})
|