pi-mega-compact 0.16.0 → 0.16.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.
Files changed (80) hide show
  1. package/dist/extensions/dashboard-server/api-contracts/endpoints/registry.js +7 -0
  2. package/dist/extensions/dashboard-server/api-contracts/model-thresholds.js +1 -0
  3. package/dist/extensions/dashboard-server/routes-model-thresholds.js +167 -0
  4. package/dist/extensions/dashboard-server/routes.js +3 -2
  5. package/dist/extensions/dashboard-server/server.js +6 -2
  6. package/dist/extensions/mega-events/context-handler.js +37 -11
  7. package/dist/src/store/sqlite/model-snapshots.js +21 -0
  8. package/dist/src/store/sqlite/model-thresholds.js +93 -0
  9. package/dist/src/store/sqlite/schema/core.js +10 -0
  10. package/dist/src/store/sqlite.js +1 -0
  11. package/dist/src/vector-search.js +1 -1
  12. package/extensions/dashboard-client/dist/assets/{AreaChart-CrmTcE7w.js → AreaChart-Y_X0Bbjd.js} +2 -2
  13. package/extensions/dashboard-client/dist/assets/{AreaChart-CrmTcE7w.js.map → AreaChart-Y_X0Bbjd.js.map} +1 -1
  14. package/extensions/dashboard-client/dist/assets/{BarChart-Cf8DTaXS.js → BarChart-B9XDb-Ob.js} +2 -2
  15. package/extensions/dashboard-client/dist/assets/{BarChart-Cf8DTaXS.js.map → BarChart-B9XDb-Ob.js.map} +1 -1
  16. package/extensions/dashboard-client/dist/assets/{CacheTab-Ce0MES6p.js → CacheTab-BUGVb1G1.js} +2 -2
  17. package/extensions/dashboard-client/dist/assets/{CacheTab-Ce0MES6p.js.map → CacheTab-BUGVb1G1.js.map} +1 -1
  18. package/extensions/dashboard-client/dist/assets/{EventsTab-DbaUuA58.js → EventsTab-dOqLYTsy.js} +2 -2
  19. package/extensions/dashboard-client/dist/assets/{EventsTab-DbaUuA58.js.map → EventsTab-dOqLYTsy.js.map} +1 -1
  20. package/extensions/dashboard-client/dist/assets/{HealthTab-BmEXkcfL.js → HealthTab-c2vezKg1.js} +2 -2
  21. package/extensions/dashboard-client/dist/assets/{HealthTab-BmEXkcfL.js.map → HealthTab-c2vezKg1.js.map} +1 -1
  22. package/extensions/dashboard-client/dist/assets/{MaintenanceTab-n2JQrMy7.js → MaintenanceTab-bQYHIcdx.js} +2 -2
  23. package/extensions/dashboard-client/dist/assets/{MaintenanceTab-n2JQrMy7.js.map → MaintenanceTab-bQYHIcdx.js.map} +1 -1
  24. package/extensions/dashboard-client/dist/assets/MemoryMapTab-2L8pH6y2.js +2 -0
  25. package/extensions/dashboard-client/dist/assets/{MemoryMapTab-Dvu7vkOm.js.map → MemoryMapTab-2L8pH6y2.js.map} +1 -1
  26. package/extensions/dashboard-client/dist/assets/{MetricsTab-Q0g32AmP.js → MetricsTab-CzUx1jpo.js} +2 -2
  27. package/extensions/dashboard-client/dist/assets/{MetricsTab-Q0g32AmP.js.map → MetricsTab-CzUx1jpo.js.map} +1 -1
  28. package/extensions/dashboard-client/dist/assets/{OverviewTab-iKiEcOLL.js → OverviewTab-eCNoaclv.js} +2 -2
  29. package/extensions/dashboard-client/dist/assets/{OverviewTab-iKiEcOLL.js.map → OverviewTab-eCNoaclv.js.map} +1 -1
  30. package/extensions/dashboard-client/dist/assets/{ReposTab-DWpnv77y.js → ReposTab-tb0IW3NF.js} +2 -2
  31. package/extensions/dashboard-client/dist/assets/{ReposTab-DWpnv77y.js.map → ReposTab-tb0IW3NF.js.map} +1 -1
  32. package/extensions/dashboard-client/dist/assets/{SessionsTab-G0YxR1vG.js → SessionsTab-C-IoLw5h.js} +2 -2
  33. package/extensions/dashboard-client/dist/assets/{SessionsTab-G0YxR1vG.js.map → SessionsTab-C-IoLw5h.js.map} +1 -1
  34. package/extensions/dashboard-client/dist/assets/SetupTab-BvmFi8gf.js +2 -0
  35. package/extensions/dashboard-client/dist/assets/SetupTab-BvmFi8gf.js.map +1 -0
  36. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-1_LCFiJp.js → TimeSavedCard-DBH-cOlA.js} +2 -2
  37. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-1_LCFiJp.js.map → TimeSavedCard-DBH-cOlA.js.map} +1 -1
  38. package/extensions/dashboard-client/dist/assets/{TurnsTab-pqOkjE4L.js → TurnsTab-DBMgmvnX.js} +3 -3
  39. package/extensions/dashboard-client/dist/assets/{TurnsTab-pqOkjE4L.js.map → TurnsTab-DBMgmvnX.js.map} +1 -1
  40. package/extensions/dashboard-client/dist/assets/WikiTab-1LcQrXk3.js +2 -0
  41. package/extensions/dashboard-client/dist/assets/{WikiTab-99sKE4nB.js.map → WikiTab-1LcQrXk3.js.map} +1 -1
  42. package/extensions/dashboard-client/dist/assets/button-DtEsZTUE.js +2 -0
  43. package/extensions/dashboard-client/dist/assets/{button-YcV2VQEP.js.map → button-DtEsZTUE.js.map} +1 -1
  44. package/extensions/dashboard-client/dist/assets/{card-Bwxzo50p.js → card-DcfTmw-4.js} +2 -2
  45. package/extensions/dashboard-client/dist/assets/{card-Bwxzo50p.js.map → card-DcfTmw-4.js.map} +1 -1
  46. package/extensions/dashboard-client/dist/assets/{generateCategoricalChart-C6hlfMYG.js → generateCategoricalChart-BsIxUDyw.js} +2 -2
  47. package/extensions/dashboard-client/dist/assets/{generateCategoricalChart-C6hlfMYG.js.map → generateCategoricalChart-BsIxUDyw.js.map} +1 -1
  48. package/extensions/dashboard-client/dist/assets/{index-DRa2d4TF.js → index-B93cewiX.js} +14 -14
  49. package/extensions/dashboard-client/dist/assets/index-B93cewiX.js.map +1 -0
  50. package/extensions/dashboard-client/dist/assets/index-C_tCbOI0.css +1 -0
  51. package/extensions/dashboard-client/dist/assets/{switch-rU-lor0v.js → switch-Bowd7jCD.js} +2 -2
  52. package/extensions/dashboard-client/dist/assets/{switch-rU-lor0v.js.map → switch-Bowd7jCD.js.map} +1 -1
  53. package/extensions/dashboard-client/dist/assets/{toggle-h-9B4w9X.js → toggle-Bo7kJZ4u.js} +2 -2
  54. package/extensions/dashboard-client/dist/assets/{toggle-h-9B4w9X.js.map → toggle-Bo7kJZ4u.js.map} +1 -1
  55. package/extensions/dashboard-client/dist/assets/{useSSE-BxJORWx8.js → useSSE-zNhb0c3u.js} +2 -2
  56. package/extensions/dashboard-client/dist/assets/{useSSE-BxJORWx8.js.map → useSSE-zNhb0c3u.js.map} +1 -1
  57. package/extensions/dashboard-client/dist/index.html +2 -2
  58. package/extensions/dashboard-client/src/api/client.ts +38 -13
  59. package/extensions/dashboard-client/src/tabs/SetupTab/ThresholdsPanel.tsx +334 -0
  60. package/extensions/dashboard-client/src/tabs/SetupTab.tsx +9 -1
  61. package/extensions/dashboard-server/api-contracts/endpoints/registry.ts +50 -17
  62. package/extensions/dashboard-server/api-contracts/index.ts +28 -3
  63. package/extensions/dashboard-server/api-contracts/model-thresholds.ts +76 -0
  64. package/extensions/dashboard-server/routes-model-thresholds.ts +222 -0
  65. package/extensions/dashboard-server/routes.ts +11 -2
  66. package/extensions/dashboard-server/server.ts +5 -1
  67. package/extensions/mega-events/context-handler.ts +106 -69
  68. package/package.json +1 -1
  69. package/src/store/sqlite/model-snapshots.ts +99 -62
  70. package/src/store/sqlite/model-thresholds.ts +168 -0
  71. package/src/store/sqlite/schema/core.ts +10 -0
  72. package/src/store/sqlite.ts +1 -0
  73. package/src/vector-search.ts +1 -1
  74. package/extensions/dashboard-client/dist/assets/MemoryMapTab-Dvu7vkOm.js +0 -2
  75. package/extensions/dashboard-client/dist/assets/SetupTab-C-y6H-F-.js +0 -2
  76. package/extensions/dashboard-client/dist/assets/SetupTab-C-y6H-F-.js.map +0 -1
  77. package/extensions/dashboard-client/dist/assets/WikiTab-99sKE4nB.js +0 -2
  78. package/extensions/dashboard-client/dist/assets/button-YcV2VQEP.js +0 -2
  79. package/extensions/dashboard-client/dist/assets/index-DRa2d4TF.js.map +0 -1
  80. package/extensions/dashboard-client/dist/assets/index-za_gmEmQ.css +0 -1
@@ -0,0 +1,76 @@
1
+ /**
2
+ * api-contracts/model-thresholds.ts — per-model compaction threshold API types.
3
+ *
4
+ * Types for the /api/model-thresholds endpoints used by the Setup panel
5
+ * "Thresholds" sub-tab to tune the safety margin + fire point per model.
6
+ *
7
+ * PREVENT-PI-004: type definitions only, no network code.
8
+ * PREVENT-011: no `any` type — all types are explicit.
9
+ */
10
+ /** A per-model threshold (mirrors src/store/sqlite/model-thresholds.ts
11
+ * ModelThreshold — duplicated here so api-contracts has no cross-package
12
+ * import into src/, per the ast-grep:no-relative-cross-package-import rule). */
13
+ export interface ModelThresholdDTO {
14
+ readonly modelId: string;
15
+ readonly safetyMarginPct: number;
16
+ readonly firePointPct: number;
17
+ readonly updatedAt: number | null;
18
+ }
19
+
20
+ /** A model known to this repo (most-recent model_snapshots row, deduped). */
21
+ export interface KnownModel {
22
+ readonly modelId: string;
23
+ readonly provider: string;
24
+ readonly modelName: string | null;
25
+ readonly contextWindow: number;
26
+ readonly maxTokens: number;
27
+ /** True when an override row exists in model_thresholds. */
28
+ readonly hasOverride: boolean;
29
+ }
30
+
31
+ /**
32
+ * Response for GET /api/model-thresholds — every known model with its
33
+ * threshold (override if present, otherwise defaults) for the UI table.
34
+ */
35
+ export interface ModelThresholdsResponse {
36
+ readonly defaults: {
37
+ readonly safetyMarginPct: number;
38
+ readonly firePointPct: number;
39
+ readonly safetyMarginRange: readonly [number, number];
40
+ readonly firePointRange: readonly [number, number];
41
+ };
42
+ readonly models: ReadonlyArray<
43
+ KnownModel & {
44
+ readonly threshold: {
45
+ readonly safetyMarginPct: number;
46
+ readonly firePointPct: number;
47
+ readonly isOverride: boolean;
48
+ };
49
+ }
50
+ >;
51
+ }
52
+
53
+ /** Request body for PUT /api/model-thresholds. */
54
+ export interface ModelThresholdPutRequest {
55
+ readonly modelId: string;
56
+ readonly safetyMarginPct: number;
57
+ readonly firePointPct: number;
58
+ }
59
+
60
+ /** Response for PUT /api/model-thresholds. */
61
+ export interface ModelThresholdPutResponse {
62
+ readonly threshold: ModelThresholdDTO;
63
+ }
64
+
65
+ /** Response for DELETE /api/model-thresholds/:modelId. */
66
+ export interface ModelThresholdDeleteResponse {
67
+ readonly deleted: boolean;
68
+ }
69
+
70
+ export type ModelThresholdsError =
71
+ | { readonly error: "method_not_allowed" }
72
+ | { readonly error: "invalid_json" }
73
+ | { readonly error: "body_too_large" }
74
+ | { readonly error: "missing_model_id" }
75
+ | { readonly error: "invalid_pct"; readonly detail: string }
76
+ | { readonly error: "internal" };
@@ -0,0 +1,222 @@
1
+ /**
2
+ * dashboard-server/routes-model-thresholds.ts — per-model compaction
3
+ * thresholds (S52 / v0.16.1).
4
+ *
5
+ * Routes for the Setup panel "Thresholds" sub-tab.
6
+ *
7
+ * GET /api/model-thresholds — list every known model + its
8
+ * threshold (override or default),
9
+ * for the dashboard table.
10
+ * PUT /api/model-thresholds — upsert a per-model override.
11
+ * Body: ModelThresholdPutRequest.
12
+ * DELETE /api/model-thresholds/:modelId — delete the override (revert to
13
+ * env/defaults).
14
+ *
15
+ * Guardrails: PREVENT-PI-004 (loopback), PREVENT-001 (null-safe JSON),
16
+ * PREVENT-002 (parameterized via model-thresholds.ts),
17
+ * PREVENT-011 (no `any`).
18
+ */
19
+ import type { IncomingMessage, ServerResponse } from "node:http";
20
+ import type { RouteContext } from "./routes-core.js";
21
+ import type {
22
+ ModelThresholdsResponse,
23
+ ModelThresholdPutResponse,
24
+ ModelThresholdDeleteResponse,
25
+ ModelThresholdsError,
26
+ KnownModel,
27
+ } from "./api-contracts/model-thresholds.js";
28
+ import {
29
+ DEFAULT_SAFETY_MARGIN_PCT,
30
+ DEFAULT_FIRE_POINT_PCT,
31
+ MAX_SAFETY_MARGIN_PCT,
32
+ MIN_SAFETY_MARGIN_PCT,
33
+ MIN_FIRE_POINT_PCT,
34
+ MAX_FIRE_POINT_PCT,
35
+ getModelThreshold,
36
+ listModelThresholds,
37
+ putModelThreshold,
38
+ deleteModelThreshold,
39
+ } from "../../src/store/sqlite/model-thresholds.js";
40
+ import { listModelSnapshots } from "../../src/store/sqlite/model-snapshots.js";
41
+
42
+ function send(res: ServerResponse, status: number, body: unknown): void {
43
+ // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
44
+ res.writeHead(status, { "Content-Type": "application/json" });
45
+ res.end(JSON.stringify(body));
46
+ }
47
+
48
+ /** Read + JSON.parse a request body (被骗: spans POST/PUT/DELETE).
49
+ * Capped at 64KB; null-safe. */
50
+ function readBody(
51
+ req: IncomingMessage,
52
+ cb: (
53
+ result:
54
+ | { ok: true; value: Record<string, unknown> }
55
+ | { ok: false; error: ModelThresholdsError["error"] },
56
+ ) => void,
57
+ ): void {
58
+ let body = "";
59
+ let tooBig = false;
60
+ req.on("data", (chunk: Buffer) => {
61
+ if (body.length > 65536) {
62
+ tooBig = true;
63
+ return;
64
+ }
65
+ body += chunk.toString();
66
+ });
67
+ req.on("end", () => {
68
+ if (tooBig) return cb({ ok: false, error: "body_too_large" });
69
+ try {
70
+ const v = body ? JSON.parse(body) : {}; // PREVENT-001: type-checked
71
+ if (typeof v !== "object" || v === null || Array.isArray(v)) {
72
+ return cb({ ok: false, error: "invalid_json" });
73
+ }
74
+ cb({ ok: true, value: v as Record<string, unknown> });
75
+ } catch {
76
+ cb({ ok: false, error: "invalid_json" });
77
+ }
78
+ });
79
+ req.on("error", () => cb({ ok: false, error: "invalid_json" }));
80
+ }
81
+
82
+ /** Read body that also handles 0 bytes. */
83
+ function requireNumber(v: unknown, label: string): number {
84
+ const n = Number(v);
85
+ if (!Number.isFinite(n)) {
86
+ throw new Error(`${label} must be a finite number, got ${String(v)}`);
87
+ }
88
+ return n;
89
+ }
90
+
91
+ /** List known models from model_snapshots, deduped by model_id (latest wins). */
92
+ function listKnownModels(stateDir: string): KnownModel[] {
93
+ const snaps = listModelSnapshots(stateDir);
94
+ // Dedup by model_id, keep latest (highest capturedAt).
95
+ const byId = new Map<string, (typeof snaps)[number]>();
96
+ for (const s of snaps) {
97
+ const existing = byId.get(s.modelId);
98
+ if (!existing || s.capturedAt > existing.capturedAt) byId.set(s.modelId, s);
99
+ }
100
+ return Array.from(byId.values()).map((s) => ({
101
+ modelId: s.modelId,
102
+ provider: s.provider,
103
+ modelName: s.modelName,
104
+ contextWindow: s.contextWindow,
105
+ maxTokens: s.maxTokens,
106
+ hasOverride: getModelThreshold(s.modelId, stateDir) !== null,
107
+ }));
108
+ }
109
+
110
+ export function handleModelThresholds(
111
+ req: IncomingMessage,
112
+ res: ServerResponse,
113
+ ctx: RouteContext,
114
+ ): boolean {
115
+ if (!req.url?.startsWith("/api/model-thresholds")) return false;
116
+ const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
117
+
118
+ // GET: list every known model + its threshold.
119
+ if (req.method === "GET" && url.pathname === "/api/model-thresholds") {
120
+ try {
121
+ const known = listKnownModels(ctx.stateDir);
122
+ const overrides = new Map(
123
+ listModelThresholds(ctx.stateDir).map((t) => [t.modelId, t]),
124
+ );
125
+ const body: ModelThresholdsResponse = {
126
+ defaults: {
127
+ safetyMarginPct: DEFAULT_SAFETY_MARGIN_PCT,
128
+ firePointPct: DEFAULT_FIRE_POINT_PCT,
129
+ safetyMarginRange: [MIN_SAFETY_MARGIN_PCT, MAX_SAFETY_MARGIN_PCT],
130
+ firePointRange: [MIN_FIRE_POINT_PCT, MAX_FIRE_POINT_PCT],
131
+ },
132
+ models: known.map((m) => {
133
+ const ov = overrides.get(m.modelId);
134
+ return {
135
+ ...m,
136
+ threshold: ov
137
+ ? {
138
+ safetyMarginPct: ov.safetyMarginPct,
139
+ firePointPct: ov.firePointPct,
140
+ isOverride: true,
141
+ }
142
+ : {
143
+ safetyMarginPct: DEFAULT_SAFETY_MARGIN_PCT,
144
+ firePointPct: DEFAULT_FIRE_POINT_PCT,
145
+ isOverride: false,
146
+ },
147
+ };
148
+ }),
149
+ };
150
+ send(res, 200, body);
151
+ } catch (e) {
152
+ send(res, 500, { error: "internal", detail: String(e) });
153
+ }
154
+ return true;
155
+ }
156
+
157
+ // PUT: upsert a per-model override.
158
+ if (req.method === "PUT" && url.pathname === "/api/model-thresholds") {
159
+ readBody(req, (result) => {
160
+ if (!result.ok) return send(res, 400, { error: result.error });
161
+ try {
162
+ const v = result.value;
163
+ const modelId = v.modelId;
164
+ if (typeof modelId !== "string" || !modelId.trim()) {
165
+ return send(res, 400, { error: "missing_model_id" });
166
+ }
167
+ let safety: number;
168
+ let fire: number;
169
+ try {
170
+ safety = requireNumber(v.safetyMarginPct, "safetyMarginPct");
171
+ fire = requireNumber(v.firePointPct, "firePointPct");
172
+ } catch (e) {
173
+ return send(res, 400, {
174
+ error: "invalid_pct",
175
+ detail: e instanceof Error ? e.message : String(e),
176
+ });
177
+ }
178
+ const t = putModelThreshold(modelId, safety, fire, ctx.stateDir);
179
+ const body: ModelThresholdPutResponse = { threshold: t };
180
+ send(res, 200, body);
181
+ } catch (e) {
182
+ const msg = e instanceof Error ? e.message : String(e);
183
+ if (/must be in \[/.test(msg)) {
184
+ return send(res, 400, { error: "invalid_pct", detail: msg });
185
+ }
186
+ send(res, 500, { error: "internal", detail: msg });
187
+ }
188
+ });
189
+ return true;
190
+ }
191
+
192
+ // DELETE: /api/model-thresholds/:modelId
193
+ if (
194
+ req.method === "DELETE" &&
195
+ url.pathname.startsWith("/api/model-thresholds/")
196
+ ) {
197
+ const modelId = decodeURIComponent(
198
+ url.pathname.slice("/api/model-thresholds/".length),
199
+ );
200
+ if (!modelId) {
201
+ send(res, 400, { error: "missing_model_id" });
202
+ return true;
203
+ }
204
+ try {
205
+ const deleted = deleteModelThreshold(modelId, ctx.stateDir);
206
+ const body: ModelThresholdDeleteResponse = { deleted };
207
+ send(res, 200, body);
208
+ } catch (e) {
209
+ send(res, 500, { error: "internal", detail: String(e) });
210
+ }
211
+ return true;
212
+ }
213
+
214
+ // Catch-all: only GET/PUT/DELETE on /api/model-thresholds[*] are supported.
215
+ if (url.pathname === "/api/model-thresholds") {
216
+ send(res, 405, {
217
+ error: "method_not_allowed",
218
+ } satisfies ModelThresholdsError);
219
+ return true;
220
+ }
221
+ return false;
222
+ }
@@ -22,12 +22,21 @@ export { handleTurns } from "./routes-turns.js";
22
22
  export { handleMaintenance } from "./routes-maintenance.js";
23
23
  export { handleProviderCache } from "./routes-cache.js";
24
24
  export { handleMemoryStatus } from "./routes-memory.js";
25
- export { handleSetupStatus, handleSetupDetect, handleSetupConfigure } from "./routes-setup.js";
25
+ export {
26
+ handleSetupStatus,
27
+ handleSetupDetect,
28
+ handleSetupConfigure,
29
+ } from "./routes-setup.js";
26
30
  export { handleMemoryMap } from "./routes-memory-map.js";
27
31
  export { handleRaptorTree, handleRaptorBuildHistory } from "./routes-raptor.js";
28
32
  export { handleCacheStripes } from "./routes-cache.js";
29
- export { handleContextHealth, handleCachePoison, handleHealthSettings } from "./routes-health.js";
33
+ export {
34
+ handleContextHealth,
35
+ handleCachePoison,
36
+ handleHealthSettings,
37
+ } from "./routes-health.js";
30
38
  export { handleEmbedderHealth } from "./routes-embedder-health.js";
31
39
  export { handleRagSettings } from "./routes-rag-settings.js";
32
40
  export { handleRagMetrics } from "./routes-rag-metrics.js";
41
+ export { handleModelThresholds } from "./routes-model-thresholds.js";
33
42
  export { handleWiki } from "./routes-wiki.js";
@@ -54,6 +54,7 @@ import {
54
54
  handleEmbedderHealth,
55
55
  handleRagSettings,
56
56
  handleRagMetrics,
57
+ handleModelThresholds,
57
58
  handleWiki,
58
59
  handleStatic,
59
60
  } from "./routes.js";
@@ -160,7 +161,9 @@ export async function launchDashboardServer(
160
161
  map: "application/json",
161
162
  };
162
163
  const payload: string | Uint8Array =
163
- ext === "html" ? injectUiFlag(readFileSync(file, "utf-8")) : readFileSync(file);
164
+ ext === "html"
165
+ ? injectUiFlag(readFileSync(file, "utf-8"))
166
+ : readFileSync(file);
164
167
  res.writeHead(200, {
165
168
  "Content-Type": types[ext] ?? "application/octet-stream",
166
169
  });
@@ -270,6 +273,7 @@ export async function launchDashboardServer(
270
273
  if (handleEmbedderHealth(req, res, ctx)) return;
271
274
  if (handleRagSettings(req, res, ctx)) return;
272
275
  if (handleRagMetrics(req, res, ctx)) return;
276
+ if (handleModelThresholds(req, res, ctx)) return;
273
277
  if (handleWiki(req, res, ctx)) return;
274
278
  handleStatic(req, res, ctx);
275
279
  });
@@ -17,9 +17,16 @@ import type {
17
17
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
18
18
  import {
19
19
  openStore,
20
+ resolveModelThreshold,
21
+ DEFAULT_SAFETY_MARGIN_PCT,
22
+ DEFAULT_FIRE_POINT_PCT,
20
23
  } from "../../src/store/sqlite.js";
21
24
  import { autoCompactCheck } from "../../src/compact.js";
22
- import { estimateSessionTokens, estimateBlockTokens, estimateMessageTokens } from "../../src/tokens.js";
25
+ import {
26
+ estimateSessionTokens,
27
+ estimateBlockTokens,
28
+ estimateMessageTokens,
29
+ } from "../../src/tokens.js";
23
30
  import type { MegaRuntime } from "../mega-runtime.js";
24
31
  import { runCompact, piCompactWouldNoop } from "../mega-pipeline.js";
25
32
  import {
@@ -94,59 +101,75 @@ export function registerContextHandler(
94
101
  const view = viewForFallback ?? runtime.engineView(messages);
95
102
 
96
103
  // S27 DB-mirror: append incoming messages to raw_transcript.
97
- // Runs BEFORE fast-gate so every message is captured, even if we
98
- // don't compact this turn. Append is idempotent (content_hash PK).
99
- // F3: high-water mark (mirror-append.ts) skips already-processed
100
- // messages on subsequent events. On fork/rewind (shorter list or
101
- // boundary hash mismatch) the mark is dropped, falling back to a
102
- // full reprocess.
103
- if (config.dbMirror) {
104
- try {
105
- const db = openStore(runtime.currentStateDir);
106
- appendMirrorMessages(
107
- db,
108
- messages,
109
- runtime.rt.sessionId,
110
- epochIdFor(runtime.rt.sessionId),
111
- runtime.currentTurn,
104
+ // Runs BEFORE fast-gate so every message is captured, even if we
105
+ // don't compact this turn. Append is idempotent (content_hash PK).
106
+ // F3: high-water mark (mirror-append.ts) skips already-processed
107
+ // messages on subsequent events. On fork/rewind (shorter list or
108
+ // boundary hash mismatch) the mark is dropped, falling back to a
109
+ // full reprocess.
110
+ if (config.dbMirror) {
111
+ try {
112
+ const db = openStore(runtime.currentStateDir);
113
+ appendMirrorMessages(
114
+ db,
115
+ messages,
116
+ runtime.rt.sessionId,
117
+ epochIdFor(runtime.rt.sessionId),
118
+ runtime.currentTurn,
119
+ );
120
+ // P2.2: populate conversation_thread + tool_results tables for
121
+ // prompt-cache analytics and durable separation. The live-array
122
+ // separation (buildSeparatedPrompt / buildCacheOptimizedPrompt in
123
+ // tailResult above) is sufficient for the prompt-construction path;
124
+ // these DB writes persist the split for post-hoc analysis, dashboard
125
+ // queries, and future readers. Non-fatal — failure here never breaks
126
+ // the agent loop (PREVENT-PI-004: zero network, local SQLite only).
127
+ {
128
+ const sid = runtime.rt.sessionId;
129
+ const turn = runtime.currentTurn;
130
+ const now = Date.now();
131
+ const threadStmt = db.prepare(
132
+ "INSERT OR IGNORE INTO conversation_thread (conversation_id, role, content, turn_index, timestamp) VALUES (?, ?, ?, ?, ?)",
112
133
  );
113
- // P2.2: populate conversation_thread + tool_results tables for
114
- // prompt-cache analytics and durable separation. The live-array
115
- // separation (buildSeparatedPrompt / buildCacheOptimizedPrompt in
116
- // tailResult above) is sufficient for the prompt-construction path;
117
- // these DB writes persist the split for post-hoc analysis, dashboard
118
- // queries, and future readers. Non-fatal — failure here never breaks
119
- // the agent loop (PREVENT-PI-004: zero network, local SQLite only).
120
- {
121
- const sid = runtime.rt.sessionId;
122
- const turn = runtime.currentTurn;
123
- const now = Date.now();
124
- const threadStmt = db.prepare(
125
- "INSERT OR IGNORE INTO conversation_thread (conversation_id, role, content, turn_index, timestamp) VALUES (?, ?, ?, ?, ?)",
126
- );
127
- const toolStmt = db.prepare(
128
- "INSERT OR IGNORE INTO tool_results (conversation_id, role, content, turn_index, timestamp) VALUES (?, ?, ?, ?, ?)",
129
- );
130
- for (const m of messages) {
131
- const role = m.role;
132
- const content = messageContentText(m);
133
- if (role === "user" || role === "assistant") {
134
- threadStmt.run(sid, role, content, turn, now);
135
- } else if (role === "toolResult" || role === "bashExecution") {
136
- toolStmt.run(sid, role, content, turn, now);
137
- }
134
+ const toolStmt = db.prepare(
135
+ "INSERT OR IGNORE INTO tool_results (conversation_id, role, content, turn_index, timestamp) VALUES (?, ?, ?, ?, ?)",
136
+ );
137
+ for (const m of messages) {
138
+ const role = m.role;
139
+ const content = messageContentText(m);
140
+ if (role === "user" || role === "assistant") {
141
+ threadStmt.run(sid, role, content, turn, now);
142
+ } else if (role === "toolResult" || role === "bashExecution") {
143
+ toolStmt.run(sid, role, content, turn, now);
138
144
  }
139
145
  }
140
- } catch (e) {
141
- runtime.logger.warn("db-mirror-append-fail", { error: String(e) });
142
146
  }
147
+ } catch (e) {
148
+ runtime.logger.warn("db-mirror-append-fail", { error: String(e) });
143
149
  }
150
+ }
144
151
 
152
+ // S52 / v0.16.1: per-model threshold override. The user can tune the
153
+ // fire point + safety margin PER MODEL (different providers' models range
154
+ // 8K-1M+ context, so one global tier % is wrong). Falls back to env/default
155
+ // when no override row exists. Computed once here + reused in the tail cap
156
+ // below; the lookup is a single SQLite PK hit (cheap; cached after the
157
+ // first read in a session).
158
+ const _modelIdForThreshold = runtime.currentModel?.modelId ?? null;
159
+ const _perModelThreshold = resolveModelThreshold(_modelIdForThreshold, {
160
+ safetyMarginFallback: DEFAULT_SAFETY_MARGIN_PCT,
161
+ firePointFallback:
162
+ config.tierPct != null
163
+ ? Math.round(config.tierPct * 100)
164
+ : DEFAULT_FIRE_POINT_PCT,
165
+ stateDir: runtime.currentStateDir,
166
+ });
145
167
  // S29 FAST GATE: drive the auto-trigger off the context % (the number the
146
168
  // menu bar shows), NOT the token count — the model under-reports tokens,
147
169
  // so a token-only gate misses the overshoot that causes max-output-tokens
148
- // truncation. The fire point is the tier's percent threshold (tierPct)
149
- // unless overridden by MEGACOMPACT_AUTO_PCT_TRIGGER. `custom` (absolute
170
+ // truncation. The fire point is the per-model override when present,
171
+ // otherwise the tier's percent threshold (tierPct) unless overridden by
172
+ // MEGACOMPACT_AUTO_PCT_TRIGGER. `custom` (absolute
150
173
  // MEGACOMPACT_THRESHOLD_TOKENS, tierPct null) is an explicit opt-out of
151
174
  // percent scaling — it keeps the token gate. When pct is unavailable
152
175
  // (window unknown / a model that doesn't report percent) a tiered config
@@ -154,7 +177,12 @@ export function registerContextHandler(
154
177
  // skipping compaction — a percent-only gate would regress that.
155
178
  let gatePassed = false;
156
179
  if (config.tierPct != null && pct != null) {
157
- const firePct = config.autoPctTrigger ?? config.tierPct;
180
+ // Per-model override is a % (10-90); tierPct is a fraction (0.1-1.0).
181
+ // Prefer the override; fall back to autoPctTrigger + tierPct.
182
+ const tierPctFraction = config.autoPctTrigger ?? config.tierPct;
183
+ const perModelFraction = _perModelThreshold.firePointPct / 100;
184
+ const firePct =
185
+ _modelIdForThreshold != null ? perModelFraction : tierPctFraction;
158
186
  gatePassed = pct / 100 >= firePct;
159
187
  } else {
160
188
  // custom tier OR tiered-but-pct-unavailable → token gate (S27 fallback).
@@ -204,7 +232,10 @@ export function registerContextHandler(
204
232
  runtime.snapshot(ctx);
205
233
  // v0.8.7: shallow-copy the cached summary so pi's transformContext can't
206
234
  // mutate the shared reference across replays (audit P3).
207
- const replayView = [{ ...runtime.trimCache.summaryAgentMsg }, ...recent];
235
+ const replayView = [
236
+ { ...runtime.trimCache.summaryAgentMsg },
237
+ ...recent,
238
+ ];
208
239
  return tailResult(replayView) ?? { messages: replayView };
209
240
  }
210
241
  // else: context grew enough → fall through to re-compact (cache is stale)
@@ -324,23 +355,21 @@ export function registerContextHandler(
324
355
  compactedFrom: ran.result.compactedFrom,
325
356
  summary: ran.result.summary,
326
357
  anchorUserMessages,
327
- // CRITICAL-OVER ESCAPE HATCH: when context is at/over ~90% of the
328
- // window, relief takes priority over the anchor floor. Without this,
329
- // computeLiveTrimCut bails to null (can't satisfy the floor) and the
330
- // model is fed a raw overflow that errors every turn — the
331
- // "Already compacted" + overflow death-spiral (2026-08-01 incident).
332
- // A thin anchor is recoverable; an overflowed session is not.
333
- //
334
- // CRITICAL: pct is null for OpenAI-compatible providers that don't
335
- // report usage.percent (e.g. neuralwatt). Without the token-pressure
336
- // fallback the hatch never armed → cut=null → raw overflow → 400
337
- // "conversation too long even after compaction" (2026-08-03 incident
338
- // on glm-5.2-short, 200K window). Now also fires on pressure >= 0.9
339
- // (token-basis) so the hatch arms regardless of whether the provider
340
- // reports pct.
341
- criticalOver:
342
- (pct ?? 0) >= 90 ||
343
- pressure >= 0.9,
358
+ // CRITICAL-OVER ESCAPE HATCH: when context is at/over ~90% of the
359
+ // window, relief takes priority over the anchor floor. Without this,
360
+ // computeLiveTrimCut bails to null (can't satisfy the floor) and the
361
+ // model is fed a raw overflow that errors every turn — the
362
+ // "Already compacted" + overflow death-spiral (2026-08-01 incident).
363
+ // A thin anchor is recoverable; an overflowed session is not.
364
+ //
365
+ // CRITICAL: pct is null for OpenAI-compatible providers that don't
366
+ // report usage.percent (e.g. neuralwatt). Without the token-pressure
367
+ // fallback the hatch never armed → cut=null → raw overflow → 400
368
+ // "conversation too long even after compaction" (2026-08-03 incident
369
+ // on glm-5.2-short, 200K window). Now also fires on pressure >= 0.9
370
+ // (token-basis) so the hatch arms regardless of whether the provider
371
+ // reports pct.
372
+ criticalOver: (pct ?? 0) >= 90 || pressure >= 0.9,
344
373
  });
345
374
  if (cut === null) {
346
375
  runtime.diagCtxCutNull++;
@@ -386,6 +415,8 @@ export function registerContextHandler(
386
415
  // turn so the agent can respond). This is a last-resort HARD cap — it
387
416
  // only fires when the preserved tail alone is oversized, which is rare.
388
417
  const ctxWindow = runtime.lastCtxWindow;
418
+ // Reuse the per-model threshold resolved at the gate (single lookup).
419
+ const modelThreshold = _perModelThreshold;
389
420
  // Reserve room for output tokens. Use the model's reported max output
390
421
  // when known; fall back to 10% of the window (scales with any model —
391
422
  // 20K for a 200K window, 100K for a 1M window) so we never let the
@@ -397,10 +428,11 @@ export function registerContextHandler(
397
428
  let recent = recentRaw;
398
429
  if (ctxWindow > 0 && recentRaw.length > 1) {
399
430
  const summaryTokens = estimateBlockTokens(summaryMsg.text);
400
- // Reserve: summary + max output + 10% safety margin.
401
- const safetyMargin = Math.ceil(ctxWindow * 0.1);
402
- const budget =
403
- ctxWindow - maxOutput - safetyMargin - summaryTokens;
431
+ // Reserve: summary + max output + per-model safety margin (0-20%).
432
+ const safetyMargin = Math.ceil(
433
+ ctxWindow * (modelThreshold.safetyMarginPct / 100),
434
+ );
435
+ const budget = ctxWindow - maxOutput - safetyMargin - summaryTokens;
404
436
  if (budget > 0) {
405
437
  // Walk recent from the front, dropping oldest first until the
406
438
  // remaining tail fits. Use the AgentMessage→engine-text estimate via
@@ -420,6 +452,7 @@ export function registerContextHandler(
420
452
  sessionId: runtime.rt.sessionId,
421
453
  dropped: startIdx,
422
454
  tailTokens,
455
+ safetyMarginPct: modelThreshold.safetyMarginPct,
423
456
  budget,
424
457
  ctxWindow,
425
458
  });
@@ -468,7 +501,11 @@ export function registerContextHandler(
468
501
  ctxPct: pct,
469
502
  ctxTokens: usage?.tokens ?? null,
470
503
  });
471
- return tailResult([summaryAgentMsg, ...recent]) ?? { messages: [summaryAgentMsg, ...recent] };
504
+ return (
505
+ tailResult([summaryAgentMsg, ...recent]) ?? {
506
+ messages: [summaryAgentMsg, ...recent],
507
+ }
508
+ );
472
509
  } catch {
473
510
  runtime.diagCtxThrown++;
474
511
  return tailResult() ?? undefined; // non-fatal: no trim this call; the next context event retries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-3-Clause",