@geminixiang/mikan 0.5.2 → 1.0.0-beta.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.
Files changed (208) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +3 -3
  3. package/dist/adapter.d.ts +1 -1
  4. package/dist/adapter.d.ts.map +1 -1
  5. package/dist/adapter.js.map +1 -1
  6. package/dist/adapters/shared.js +2 -2
  7. package/dist/adapters/shared.js.map +1 -1
  8. package/dist/agent.d.ts +11 -2
  9. package/dist/agent.d.ts.map +1 -1
  10. package/dist/agent.js +211 -119
  11. package/dist/agent.js.map +1 -1
  12. package/dist/commands/admin.d.ts.map +1 -1
  13. package/dist/commands/admin.js +1 -5
  14. package/dist/commands/admin.js.map +1 -1
  15. package/dist/commands/auto-reply.d.ts +3 -0
  16. package/dist/commands/auto-reply.d.ts.map +1 -1
  17. package/dist/commands/auto-reply.js +3 -0
  18. package/dist/commands/auto-reply.js.map +1 -1
  19. package/dist/commands/model.d.ts +2 -3
  20. package/dist/commands/model.d.ts.map +1 -1
  21. package/dist/commands/model.js +1 -4
  22. package/dist/commands/model.js.map +1 -1
  23. package/dist/commands/new.d.ts.map +1 -1
  24. package/dist/commands/new.js +1 -5
  25. package/dist/commands/new.js.map +1 -1
  26. package/dist/commands/registry.d.ts.map +1 -1
  27. package/dist/commands/registry.js +2 -5
  28. package/dist/commands/registry.js.map +1 -1
  29. package/dist/commands/types.d.ts +6 -2
  30. package/dist/commands/types.d.ts.map +1 -1
  31. package/dist/commands/types.js.map +1 -1
  32. package/dist/config.d.ts +42 -1
  33. package/dist/config.d.ts.map +1 -1
  34. package/dist/config.js +93 -7
  35. package/dist/config.js.map +1 -1
  36. package/dist/events.d.ts +0 -1
  37. package/dist/events.d.ts.map +1 -1
  38. package/dist/events.js +1 -6
  39. package/dist/events.js.map +1 -1
  40. package/dist/execution-resolver.d.ts.map +1 -1
  41. package/dist/execution-resolver.js +9 -6
  42. package/dist/execution-resolver.js.map +1 -1
  43. package/dist/harness/auth.d.ts +16 -0
  44. package/dist/harness/auth.d.ts.map +1 -0
  45. package/dist/harness/auth.js +86 -0
  46. package/dist/harness/auth.js.map +1 -0
  47. package/dist/harness/extensions/loader.d.ts +43 -0
  48. package/dist/harness/extensions/loader.d.ts.map +1 -0
  49. package/dist/harness/extensions/loader.js +325 -0
  50. package/dist/harness/extensions/loader.js.map +1 -0
  51. package/dist/harness/extensions/registry.d.ts +23 -0
  52. package/dist/harness/extensions/registry.d.ts.map +1 -0
  53. package/dist/harness/extensions/registry.js +45 -0
  54. package/dist/harness/extensions/registry.js.map +1 -0
  55. package/dist/harness/extensions/types.d.ts +225 -0
  56. package/dist/harness/extensions/types.d.ts.map +1 -0
  57. package/dist/harness/extensions/types.js +2 -0
  58. package/dist/harness/extensions/types.js.map +1 -0
  59. package/dist/harness/http.d.ts +16 -0
  60. package/dist/harness/http.d.ts.map +1 -0
  61. package/dist/harness/http.js +71 -0
  62. package/dist/harness/http.js.map +1 -0
  63. package/dist/harness/index.d.ts +19 -0
  64. package/dist/harness/index.d.ts.map +1 -0
  65. package/dist/harness/index.js +18 -0
  66. package/dist/harness/index.js.map +1 -0
  67. package/dist/harness/models.d.ts +34 -0
  68. package/dist/harness/models.d.ts.map +1 -0
  69. package/dist/harness/models.js +248 -0
  70. package/dist/harness/models.js.map +1 -0
  71. package/dist/harness/runner.d.ts +124 -0
  72. package/dist/harness/runner.d.ts.map +1 -0
  73. package/dist/harness/runner.js +436 -0
  74. package/dist/harness/runner.js.map +1 -0
  75. package/dist/harness/session-store.d.ts +77 -0
  76. package/dist/harness/session-store.d.ts.map +1 -0
  77. package/dist/harness/session-store.js +248 -0
  78. package/dist/harness/session-store.js.map +1 -0
  79. package/dist/harness/settings.d.ts +47 -0
  80. package/dist/harness/settings.d.ts.map +1 -0
  81. package/dist/harness/settings.js +29 -0
  82. package/dist/harness/settings.js.map +1 -0
  83. package/dist/harness/skills.d.ts +54 -0
  84. package/dist/harness/skills.d.ts.map +1 -0
  85. package/dist/harness/skills.js +203 -0
  86. package/dist/harness/skills.js.map +1 -0
  87. package/dist/harness/types.d.ts +32 -0
  88. package/dist/harness/types.d.ts.map +1 -0
  89. package/dist/harness/types.js +2 -0
  90. package/dist/harness/types.js.map +1 -0
  91. package/dist/index.d.ts +1 -0
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +1 -0
  94. package/dist/index.js.map +1 -1
  95. package/dist/log.d.ts.map +1 -1
  96. package/dist/log.js +11 -14
  97. package/dist/log.js.map +1 -1
  98. package/dist/main.js +67 -8
  99. package/dist/main.js.map +1 -1
  100. package/dist/model-registry.d.ts +2 -2
  101. package/dist/model-registry.d.ts.map +1 -1
  102. package/dist/model-registry.js.map +1 -1
  103. package/dist/provisioner.d.ts +1 -4
  104. package/dist/provisioner.d.ts.map +1 -1
  105. package/dist/provisioner.js +3 -8
  106. package/dist/provisioner.js.map +1 -1
  107. package/dist/runtime/agent-run-controller.d.ts.map +1 -1
  108. package/dist/runtime/agent-run-controller.js +6 -3
  109. package/dist/runtime/agent-run-controller.js.map +1 -1
  110. package/dist/runtime/conversation-runtime.js +1 -1
  111. package/dist/runtime/conversation-runtime.js.map +1 -1
  112. package/dist/runtime/types.d.ts +4 -3
  113. package/dist/runtime/types.d.ts.map +1 -1
  114. package/dist/runtime/types.js.map +1 -1
  115. package/dist/sandbox/container.d.ts +1 -0
  116. package/dist/sandbox/container.d.ts.map +1 -1
  117. package/dist/sandbox/container.js +2 -2
  118. package/dist/sandbox/container.js.map +1 -1
  119. package/dist/sandbox/host.d.ts.map +1 -1
  120. package/dist/sandbox/host.js +0 -4
  121. package/dist/sandbox/host.js.map +1 -1
  122. package/dist/sandbox/image.d.ts.map +1 -1
  123. package/dist/sandbox/image.js +0 -3
  124. package/dist/sandbox/image.js.map +1 -1
  125. package/dist/sandbox/index.d.ts.map +1 -1
  126. package/dist/sandbox/index.js +4 -1
  127. package/dist/sandbox/index.js.map +1 -1
  128. package/dist/sandbox/types.d.ts +2 -2
  129. package/dist/sandbox/types.d.ts.map +1 -1
  130. package/dist/sandbox/types.js.map +1 -1
  131. package/dist/sessions/agent-memory-file-manager.d.ts.map +1 -1
  132. package/dist/sessions/agent-memory-file-manager.js +8 -8
  133. package/dist/sessions/agent-memory-file-manager.js.map +1 -1
  134. package/dist/sessions/metadata.js +2 -2
  135. package/dist/sessions/metadata.js.map +1 -1
  136. package/dist/sessions/store.d.ts +3 -3
  137. package/dist/sessions/store.d.ts.map +1 -1
  138. package/dist/sessions/store.js +17 -43
  139. package/dist/sessions/store.js.map +1 -1
  140. package/dist/sessions/types.d.ts +1 -1
  141. package/dist/sessions/types.d.ts.map +1 -1
  142. package/dist/sessions/types.js.map +1 -1
  143. package/dist/store.d.ts +1 -0
  144. package/dist/store.d.ts.map +1 -1
  145. package/dist/store.js +18 -11
  146. package/dist/store.js.map +1 -1
  147. package/dist/tools/edit.d.ts.map +1 -1
  148. package/dist/tools/edit.js +1 -3
  149. package/dist/tools/edit.js.map +1 -1
  150. package/dist/tools/event.d.ts.map +1 -1
  151. package/dist/tools/event.js +7 -74
  152. package/dist/tools/event.js.map +1 -1
  153. package/dist/tools/read.d.ts.map +1 -1
  154. package/dist/tools/read.js +1 -3
  155. package/dist/tools/read.js.map +1 -1
  156. package/dist/tools/write.d.ts.map +1 -1
  157. package/dist/tools/write.js +3 -7
  158. package/dist/tools/write.js.map +1 -1
  159. package/dist/trigger.d.ts +2 -0
  160. package/dist/trigger.d.ts.map +1 -1
  161. package/dist/trigger.js +7 -13
  162. package/dist/trigger.js.map +1 -1
  163. package/dist/types.d.ts +21 -0
  164. package/dist/types.d.ts.map +1 -1
  165. package/dist/types.js.map +1 -1
  166. package/dist/vault/index.d.ts.map +1 -1
  167. package/dist/vault/index.js +7 -1
  168. package/dist/vault/index.js.map +1 -1
  169. package/dist/web/admin/portal.d.ts +1 -1
  170. package/dist/web/admin/portal.d.ts.map +1 -1
  171. package/dist/web/admin/portal.js +394 -73
  172. package/dist/web/admin/portal.js.map +1 -1
  173. package/dist/web/admin/provider-models.d.ts +2 -2
  174. package/dist/web/admin/provider-models.d.ts.map +1 -1
  175. package/dist/web/admin/provider-models.js +4 -2
  176. package/dist/web/admin/provider-models.js.map +1 -1
  177. package/dist/web/admin/store.d.ts.map +1 -1
  178. package/dist/web/admin/store.js +3 -12
  179. package/dist/web/admin/store.js.map +1 -1
  180. package/dist/web/admin/types.d.ts +7 -5
  181. package/dist/web/admin/types.d.ts.map +1 -1
  182. package/dist/web/admin/types.js.map +1 -1
  183. package/dist/web/login/oauth.d.ts.map +1 -1
  184. package/dist/web/login/oauth.js +41 -23
  185. package/dist/web/login/oauth.js.map +1 -1
  186. package/dist/web/login/store.d.ts +0 -4
  187. package/dist/web/login/store.d.ts.map +1 -1
  188. package/dist/web/login/store.js +3 -16
  189. package/dist/web/login/store.js.map +1 -1
  190. package/dist/web/server.js +2 -2
  191. package/dist/web/server.js.map +1 -1
  192. package/dist/web/session-view/portal.d.ts.map +1 -1
  193. package/dist/web/session-view/portal.js +5 -16
  194. package/dist/web/session-view/portal.js.map +1 -1
  195. package/dist/web/session-view/service.js +7 -13
  196. package/dist/web/session-view/service.js.map +1 -1
  197. package/dist/web/session-view/store.d.ts.map +1 -1
  198. package/dist/web/session-view/store.js +2 -7
  199. package/dist/web/session-view/store.js.map +1 -1
  200. package/dist/web/token-store.d.ts +2 -8
  201. package/dist/web/token-store.d.ts.map +1 -1
  202. package/dist/web/token-store.js +11 -12
  203. package/dist/web/token-store.js.map +1 -1
  204. package/package.json +6 -3
  205. package/dist/context.d.ts +0 -2
  206. package/dist/context.d.ts.map +0 -1
  207. package/dist/context.js +0 -2
  208. package/dist/context.js.map +0 -1
@@ -1,7 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync, rmSync, statSync } from "fs";
2
- import { homedir } from "os";
3
2
  import { basename, join, resolve as pathResolve, sep as pathSep } from "path";
4
- import { AuthStorage, ModelRegistry, SessionManager } from "@earendil-works/pi-coding-agent";
3
+ import { MikanModels, SessionStore } from "../../harness/index.js";
5
4
  import { loadConversationAutoReplyConfig, loadGlobalSettings, resolveConversationSettings, saveConversationAutoReplyConfig, updateConversationSettings, updateGlobalSettings, } from "../../config.js";
6
5
  import { escapeHtml } from "../../utils/html.js";
7
6
  import { readRawBody } from "../../utils/http-body.js";
@@ -12,7 +11,7 @@ import { resolveActorVaultKey } from "../../vault/routing.js";
12
11
  import { sharedVaultKey } from "../../vault/index.js";
13
12
  import { modelKey, resolveAdminModelAccessStatuses } from "./provider-models.js";
14
13
  // ── Handler ────────────────────────────────────────────────────────────────────
15
- export function handleAdminRequest(req, res, url, services) {
14
+ export async function handleAdminRequest(req, res, url, services) {
16
15
  if (!url.pathname.startsWith("/admin"))
17
16
  return false;
18
17
  if (req.method === "GET" && url.pathname === "/admin") {
@@ -31,13 +30,13 @@ export function handleAdminRequest(req, res, url, services) {
31
30
  return true;
32
31
  }
33
32
  if (url.pathname.startsWith("/admin/api/")) {
34
- routeApiRequest(req, res, url, services);
33
+ await routeApiRequest(req, res, url, services);
35
34
  return true;
36
35
  }
37
36
  return false;
38
37
  }
39
38
  // ── API routing ────────────────────────────────────────────────────────────────
40
- function routeApiRequest(req, res, url, services) {
39
+ async function routeApiRequest(req, res, url, services) {
41
40
  if (req.method === "GET") {
42
41
  const token = services.adminTokenStore.peek(url.searchParams.get("token") ?? "");
43
42
  if (!token) {
@@ -56,6 +55,10 @@ function routeApiRequest(req, res, url, services) {
56
55
  serveSessionUsage(res, services);
57
56
  return;
58
57
  }
58
+ if (url.pathname === "/admin/api/conversation-usage") {
59
+ serveConversationUsage(res, url, services);
60
+ return;
61
+ }
59
62
  if (url.pathname === "/admin/api/conversation-state") {
60
63
  serveConversationState(res, url, services, token);
61
64
  return;
@@ -99,59 +102,60 @@ function routeApiRequest(req, res, url, services) {
99
102
  jsonRes(res, 404, { error: "Not found" });
100
103
  return;
101
104
  }
102
- if (req.method === "POST") {
103
- void readJsonBody(req, res, (body) => {
104
- const rawToken = typeof body.token === "string" ? body.token : "";
105
- const token = services.adminTokenStore.peek(rawToken);
106
- if (!token) {
107
- jsonRes(res, 403, { error: "Unauthorized" });
108
- return;
109
- }
110
- if (url.pathname === "/admin/api/conversations/model") {
111
- serveConversationModelUpdate(res, body, services, token);
112
- return;
113
- }
114
- if (url.pathname === "/admin/api/conversations/sandbox") {
115
- serveConversationSandboxUpdate(res, body, services, token);
116
- return;
117
- }
118
- if (url.pathname === "/admin/api/conversations/auto-reply") {
119
- serveConversationAutoReplyUpdate(res, body, services, token);
120
- return;
121
- }
122
- if (url.pathname === "/admin/api/conversations/slack") {
123
- serveConversationSlackUpdate(res, body, services, token);
124
- return;
125
- }
126
- if (url.pathname === "/admin/api/conversations/session-link") {
127
- serveConversationSessionLink(res, body, services, token);
128
- return;
129
- }
130
- if (url.pathname === "/admin/api/conversations/login-link") {
131
- serveConversationLoginLink(res, body, services, token);
132
- return;
133
- }
134
- if (url.pathname === "/admin/api/conversations/events/delete") {
135
- serveConversationEventDelete(res, body, services, token);
136
- return;
137
- }
138
- if (url.pathname === "/admin/api/settings/model") {
139
- serveGlobalModelUpdate(res, body);
140
- return;
141
- }
142
- if (url.pathname === "/admin/api/settings/sandbox") {
143
- serveGlobalSandboxUpdate(res, body);
144
- return;
145
- }
146
- if (url.pathname === "/admin/api/settings/slack") {
147
- serveGlobalSlackUpdate(res, body);
148
- return;
149
- }
150
- jsonRes(res, 404, { error: "Not found" });
151
- });
105
+ if (req.method !== "POST") {
106
+ jsonRes(res, 405, { error: "Method not allowed" });
107
+ return;
108
+ }
109
+ const body = await readJsonBody(req, res);
110
+ if (!body)
111
+ return;
112
+ const rawToken = typeof body.token === "string" ? body.token : "";
113
+ const token = services.adminTokenStore.peek(rawToken);
114
+ if (!token) {
115
+ jsonRes(res, 403, { error: "Unauthorized" });
116
+ return;
117
+ }
118
+ if (url.pathname === "/admin/api/conversations/model") {
119
+ serveConversationModelUpdate(res, body, services, token);
120
+ return;
121
+ }
122
+ if (url.pathname === "/admin/api/conversations/sandbox") {
123
+ serveConversationSandboxUpdate(res, body, services, token);
124
+ return;
125
+ }
126
+ if (url.pathname === "/admin/api/conversations/auto-reply") {
127
+ serveConversationAutoReplyUpdate(res, body, services, token);
152
128
  return;
153
129
  }
154
- jsonRes(res, 405, { error: "Method not allowed" });
130
+ if (url.pathname === "/admin/api/conversations/slack") {
131
+ serveConversationSlackUpdate(res, body, services, token);
132
+ return;
133
+ }
134
+ if (url.pathname === "/admin/api/conversations/session-link") {
135
+ serveConversationSessionLink(res, body, services, token);
136
+ return;
137
+ }
138
+ if (url.pathname === "/admin/api/conversations/login-link") {
139
+ serveConversationLoginLink(res, body, services, token);
140
+ return;
141
+ }
142
+ if (url.pathname === "/admin/api/conversations/events/delete") {
143
+ serveConversationEventDelete(res, body, services, token);
144
+ return;
145
+ }
146
+ if (url.pathname === "/admin/api/settings/model") {
147
+ serveGlobalModelUpdate(res, body);
148
+ return;
149
+ }
150
+ if (url.pathname === "/admin/api/settings/sandbox") {
151
+ serveGlobalSandboxUpdate(res, body);
152
+ return;
153
+ }
154
+ if (url.pathname === "/admin/api/settings/slack") {
155
+ serveGlobalSlackUpdate(res, body);
156
+ return;
157
+ }
158
+ jsonRes(res, 404, { error: "Not found" });
155
159
  }
156
160
  // ── Scope helpers ──────────────────────────────────────────────────────────────
157
161
  function resolveConversationId(requested, token) {
@@ -197,7 +201,9 @@ function listConversationDirs(workingDir) {
197
201
  const dir = join(workingDir, name);
198
202
  try {
199
203
  const items = readdirSync(dir);
200
- return items.some((item) => SETTINGS_FILES.has(item) || item.endsWith(".jsonl"));
204
+ // Conversation settings migrated to the state dir, so a sessions/
205
+ // subdir is the primary marker for existing conversation dirs.
206
+ return items.some((item) => item === "sessions" || SETTINGS_FILES.has(item) || item.endsWith(".jsonl"));
201
207
  }
202
208
  catch {
203
209
  return false;
@@ -288,7 +294,7 @@ function listConversationSessionUsage(workingDir, conversationId, label) {
288
294
  }
289
295
  function readSessionUsage(sessionFile, conversationId, label) {
290
296
  try {
291
- const manager = SessionManager.open(sessionFile);
297
+ const manager = SessionStore.open(sessionFile);
292
298
  const header = manager.getHeader();
293
299
  if (!header)
294
300
  return [];
@@ -329,6 +335,114 @@ function readSessionUsage(sessionFile, conversationId, label) {
329
335
  function numberOrZero(value) {
330
336
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
331
337
  }
338
+ function localDayKey(d) {
339
+ const year = d.getFullYear();
340
+ const month = String(d.getMonth() + 1).padStart(2, "0");
341
+ const day = String(d.getDate()).padStart(2, "0");
342
+ return `${year}-${month}-${day}`;
343
+ }
344
+ function emptyBucket(date) {
345
+ return { date, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, cost: 0 };
346
+ }
347
+ /** Per-conversation daily token usage over the last N days (N clamped to 1..7). */
348
+ function serveConversationUsage(res, url, services) {
349
+ const workingDir = requireAdminWorkingDir(res, services);
350
+ if (!workingDir)
351
+ return;
352
+ const conversationId = url.searchParams.get("conversationId") ?? "";
353
+ if (!conversationId || !listConversationDirs(workingDir).includes(conversationId)) {
354
+ jsonRes(res, 400, { error: "Unknown conversationId" });
355
+ return;
356
+ }
357
+ const days = 14;
358
+ const today = new Date();
359
+ today.setHours(0, 0, 0, 0);
360
+ const buckets = new Map();
361
+ const order = [];
362
+ for (let i = days - 1; i >= 0; i--) {
363
+ const d = new Date(today);
364
+ d.setDate(today.getDate() - i);
365
+ const key = localDayKey(d);
366
+ order.push(key);
367
+ buckets.set(key, emptyBucket(key));
368
+ }
369
+ const cutoff = new Date(today);
370
+ cutoff.setDate(today.getDate() - (days - 1));
371
+ const flags = { hasOlder: false };
372
+ const sessionDir = join(workingDir, conversationId, "sessions");
373
+ try {
374
+ for (const entry of readdirSync(sessionDir, { withFileTypes: true })) {
375
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl"))
376
+ continue;
377
+ accumulateSessionUsageByDay(join(sessionDir, entry.name), cutoff, buckets, flags);
378
+ }
379
+ }
380
+ catch {
381
+ // No sessions directory yet — return empty buckets.
382
+ }
383
+ const series = order.map((key) => buckets.get(key) ?? emptyBucket(key));
384
+ const totals = series.reduce((sum, b) => {
385
+ sum.input += b.input;
386
+ sum.output += b.output;
387
+ sum.cacheRead += b.cacheRead;
388
+ sum.cacheWrite += b.cacheWrite;
389
+ sum.total += b.total;
390
+ sum.cost += b.cost;
391
+ return sum;
392
+ }, emptyBucket(""));
393
+ jsonRes(res, 200, {
394
+ conversationId,
395
+ label: conversationDisplayLabel(services, conversationId),
396
+ days,
397
+ hasOlder: flags.hasOlder,
398
+ buckets: series,
399
+ totals: {
400
+ input: totals.input,
401
+ output: totals.output,
402
+ cacheRead: totals.cacheRead,
403
+ cacheWrite: totals.cacheWrite,
404
+ total: totals.total,
405
+ cost: totals.cost,
406
+ },
407
+ });
408
+ }
409
+ function accumulateSessionUsageByDay(sessionFile, cutoff, buckets, flags) {
410
+ try {
411
+ const manager = SessionStore.open(sessionFile);
412
+ if (!manager.getHeader())
413
+ return;
414
+ for (const entry of manager.getEntries()) {
415
+ if (entry.type !== "message" || entry.message.role !== "assistant")
416
+ continue;
417
+ const usage = entry.message.usage;
418
+ if (!usage || !entry.timestamp)
419
+ continue;
420
+ const when = new Date(entry.timestamp);
421
+ if (Number.isNaN(when.getTime()))
422
+ continue;
423
+ if (when < cutoff) {
424
+ flags.hasOlder = true;
425
+ continue;
426
+ }
427
+ const bucket = buckets.get(localDayKey(when));
428
+ if (!bucket)
429
+ continue;
430
+ const input = numberOrZero(usage.input);
431
+ const output = numberOrZero(usage.output);
432
+ const cacheRead = numberOrZero(usage.cacheRead);
433
+ const cacheWrite = numberOrZero(usage.cacheWrite);
434
+ bucket.input += input;
435
+ bucket.output += output;
436
+ bucket.cacheRead += cacheRead;
437
+ bucket.cacheWrite += cacheWrite;
438
+ bucket.total += input + output + cacheRead + cacheWrite;
439
+ bucket.cost += numberOrZero(usage.cost?.total);
440
+ }
441
+ }
442
+ catch {
443
+ // Skip unreadable session files.
444
+ }
445
+ }
332
446
  function serveConversationState(res, url, services, token) {
333
447
  const workingDir = requireAdminWorkingDir(res, services);
334
448
  if (!workingDir)
@@ -385,8 +499,7 @@ function serveGlobalSettings(res) {
385
499
  }
386
500
  async function serveModelsList(res) {
387
501
  try {
388
- const authStorage = AuthStorage.create(join(homedir(), ".pi", "mikan", "auth.json"));
389
- const registry = ModelRegistry.create(authStorage);
502
+ const registry = MikanModels.create();
390
503
  const availableModels = await registry.getAvailable();
391
504
  const statuses = await resolveAdminModelAccessStatuses(registry, availableModels);
392
505
  const models = availableModels.map((model) => ({
@@ -489,9 +602,12 @@ function serveConversationSlackUpdate(res, body, services, token) {
489
602
  }
490
603
  function serveConversationAutoReplyUpdate(res, body, services, token) {
491
604
  const enabled = body.enabled === true;
492
- const rules = Array.isArray(body.rules)
493
- ? body.rules.filter((r) => typeof r === "string")
494
- : undefined;
605
+ const rules = body.rules;
606
+ if (rules !== undefined &&
607
+ (!Array.isArray(rules) || rules.some((rule) => typeof rule !== "string"))) {
608
+ jsonRes(res, 400, { error: "rules must be an array of strings" });
609
+ return;
610
+ }
495
611
  const scope = resolveTargetConversation(body, token);
496
612
  if (scope.error) {
497
613
  jsonRes(res, 403, { error: scope.error });
@@ -707,7 +823,7 @@ function buildTree(startDir, relPrefix) {
707
823
  catch {
708
824
  return null;
709
825
  }
710
- const name = rel === "" ? "." : (rel.split(/[\\/]/).pop() ?? rel);
826
+ const name = rel === "" ? "." : basename(rel);
711
827
  if (!stats.isDirectory()) {
712
828
  counter.value += 1;
713
829
  return {
@@ -1153,19 +1269,17 @@ function jsonRes(res, status, body) {
1153
1269
  });
1154
1270
  res.end(JSON.stringify(body));
1155
1271
  }
1156
- async function readJsonBody(req, res, callback) {
1272
+ async function readJsonBody(req, res) {
1157
1273
  const data = await readRawBody(req, res, 32 * 1024);
1158
1274
  if (data === null)
1159
- return;
1160
- let parsed;
1275
+ return null;
1161
1276
  try {
1162
- parsed = JSON.parse(data);
1277
+ return JSON.parse(data);
1163
1278
  }
1164
1279
  catch {
1165
1280
  jsonRes(res, 400, { error: "Invalid JSON" });
1166
- return;
1281
+ return null;
1167
1282
  }
1168
- callback(parsed);
1169
1283
  }
1170
1284
  const esc = escapeHtml;
1171
1285
  // ── HTML ───────────────────────────────────────────────────────────────────────
@@ -1268,11 +1382,18 @@ function renderAdminPage(token) {
1268
1382
  <header class="sect-head">
1269
1383
  <div>
1270
1384
  <p class="eyebrow">Token Usage</p>
1271
- <h2 class="card-title">Top 20 sessions</h2>
1272
1385
  </div>
1273
- <button class="refresh-btn" onclick="loadSessionUsage()">↻</button>
1386
+ <button class="refresh-btn" onclick="loadTokenUsage()">↻</button>
1274
1387
  </header>
1388
+ <h2 class="card-subtitle" style="margin-bottom:10px">Top 20 sessions</h3>
1275
1389
  <div id="session-usage-content"><div class="loading-msg">Loading…</div></div>
1390
+ <h2 class="card-subtitle" style="margin:24px 0 10px">Usage timeline</h3>
1391
+ <div class="timeline-controls">
1392
+ <label>Conversation
1393
+ <select id="timeline-conv" onchange="loadUsageTimeline()"></select>
1394
+ </label>
1395
+ </div>
1396
+ <div id="usage-timeline-content"><div class="loading-msg">Loading…</div></div>
1276
1397
  </section>
1277
1398
 
1278
1399
  <section class="card sect">
@@ -1803,7 +1924,7 @@ function renderAdminPage(token) {
1803
1924
  if (globalLoaded) return;
1804
1925
  globalLoaded = true;
1805
1926
  loadAllConversations();
1806
- loadSessionUsage();
1927
+ loadTokenUsage();
1807
1928
  loadGlobalSettings();
1808
1929
  loadGlobalSkills();
1809
1930
  loadEvents();
@@ -1862,6 +1983,140 @@ function renderAdminPage(token) {
1862
1983
  return Number(value || 0).toLocaleString('en-US');
1863
1984
  }
1864
1985
 
1986
+ let timelineConvLoaded = false;
1987
+ let timelineData = null;
1988
+ let timelineFilter = null;
1989
+ async function ensureTimelineConvOptions() {
1990
+ if (timelineConvLoaded) return;
1991
+ const sel = document.getElementById('timeline-conv');
1992
+ const prev = sel.value;
1993
+ const data = await apiGet('/admin/api/conversations');
1994
+ if (!data.conversations.length) {
1995
+ sel.innerHTML = '<option value="">No conversations</option>';
1996
+ timelineConvLoaded = true;
1997
+ return;
1998
+ }
1999
+ const want = prev || defaultConversationId;
2000
+ sel.innerHTML = data.conversations.map((c) =>
2001
+ '<option value="' + escAttr(c.conversationId) + '"' +
2002
+ (c.conversationId === want ? ' selected' : '') + '>' +
2003
+ escHtml(c.label || c.conversationId) + '</option>'
2004
+ ).join('');
2005
+ timelineConvLoaded = true;
2006
+ }
2007
+
2008
+ // Shared refresh for the merged Token Usage section: reloads the session
2009
+ // ranking and re-fetches the conversation list (so new sessions appear),
2010
+ // preserving the currently selected conversation.
2011
+ async function loadTokenUsage() {
2012
+ loadSessionUsage();
2013
+ timelineConvLoaded = false;
2014
+ await loadUsageTimeline();
2015
+ }
2016
+
2017
+ async function loadUsageTimeline() {
2018
+ const container = document.getElementById('usage-timeline-content');
2019
+ try {
2020
+ await ensureTimelineConvOptions();
2021
+ const conv = document.getElementById('timeline-conv').value;
2022
+ if (!conv) {
2023
+ container.innerHTML = '<div class="empty-state">No conversations found</div>';
2024
+ return;
2025
+ }
2026
+ container.innerHTML = '<div class="loading-msg">Loading…</div>';
2027
+ const data = await apiGet('/admin/api/conversation-usage?conversationId=' +
2028
+ encodeURIComponent(conv));
2029
+ timelineData = data;
2030
+ container.innerHTML = renderUsageTimeline(data);
2031
+ } catch (err) {
2032
+ container.innerHTML = '<div class="err-msg">' + escHtml(err.message) + '</div>';
2033
+ }
2034
+ }
2035
+
2036
+ function tlCard(label, value) {
2037
+ return '<div class="tl-card"><div class="tl-card-label">' + label +
2038
+ '</div><div class="tl-card-value">' + value + '</div></div>';
2039
+ }
2040
+
2041
+ const TL_SERIES = [
2042
+ { key: 'cacheRead', seg: 'tl-cache-read', sw: 'sw-cache-read', label: 'Cache read' },
2043
+ { key: 'cacheWrite', seg: 'tl-cache-write', sw: 'sw-cache-write', label: 'Cache write' },
2044
+ { key: 'input', seg: 'tl-input', sw: 'sw-input', label: 'Input' },
2045
+ { key: 'output', seg: 'tl-output', sw: 'sw-output', label: 'Output' },
2046
+ ];
2047
+
2048
+ // Click a legend item to show only that series; click it again for all.
2049
+ function toggleTimelineFilter(key) {
2050
+ timelineFilter = timelineFilter === key ? null : key;
2051
+ if (timelineData) {
2052
+ document.getElementById('usage-timeline-content').innerHTML = renderUsageTimeline(timelineData);
2053
+ }
2054
+ }
2055
+
2056
+ function renderUsageTimeline(data) {
2057
+ const buckets = data.buckets || [];
2058
+ const totals = data.totals || { total: 0, cost: 0, cacheRead: 0 };
2059
+ const cacheHit = totals.total > 0 ? Math.round((totals.cacheRead / totals.total) * 100) : 0;
2060
+ const cards = '<div class="tl-cards">' +
2061
+ tlCard('Total cost', totals.cost > 0 ? '$' + Number(totals.cost).toFixed(4) : '—') +
2062
+ tlCard('Total tokens', fmtNum(totals.total)) +
2063
+ tlCard('Cache hit', cacheHit + '%') +
2064
+ '</div>';
2065
+
2066
+ if (totals.total === 0) {
2067
+ const emptyNote = data.hasOlder
2068
+ ? '<div class="tl-note">No usage in the last 14 days · earlier activity exists</div>'
2069
+ : '<div class="empty-state">No token usage in the last 14 days</div>';
2070
+ return cards + emptyNote;
2071
+ }
2072
+
2073
+ const active = TL_SERIES.find((s) => s.key === timelineFilter) || null;
2074
+ const valueOf = (b) => active ? (b[active.key] || 0) : b.total;
2075
+ const max = Math.max(1, ...buckets.map(valueOf));
2076
+ const px = (v) => Math.round((v / max) * 180);
2077
+
2078
+ const legend = '<div class="tl-legend">' + TL_SERIES.map((s) => {
2079
+ const cls = 'tl-legend-item' +
2080
+ (active && active.key === s.key ? ' active' : (active ? ' dim' : ''));
2081
+ return '<span class="' + cls + '" onclick="toggleTimelineFilter(\\'' + s.key + '\\')">' +
2082
+ '<i class="sw ' + s.sw + '"></i>' + s.label + '</span>';
2083
+ }).join('') + '</div>';
2084
+
2085
+ const bars = buckets.map((b) => {
2086
+ const val = valueOf(b);
2087
+ const tip = active
2088
+ ? b.date + ' · ' + active.label + ': ' + fmtNum(b[active.key] || 0) + ' tokens'
2089
+ : b.date + ' · ' + fmtNum(b.total) + ' tokens' +
2090
+ (b.cost > 0 ? ' · $' + Number(b.cost).toFixed(4) : '');
2091
+ let inner;
2092
+ if (val <= 0) {
2093
+ inner = '<span class="tl-empty"></span>';
2094
+ } else if (active) {
2095
+ inner = '<span class="tl-seg ' + active.seg + '" style="height:' + px(val) + 'px"></span>';
2096
+ } else {
2097
+ inner = '<span class="tl-seg tl-output" style="height:' + px(b.output) + 'px"></span>' +
2098
+ '<span class="tl-seg tl-input" style="height:' + px(b.input) + 'px"></span>' +
2099
+ '<span class="tl-seg tl-cache-write" style="height:' + px(b.cacheWrite) + 'px"></span>' +
2100
+ '<span class="tl-seg tl-cache-read" style="height:' + px(b.cacheRead) + 'px"></span>';
2101
+ }
2102
+ return '<div class="tl-bar">' +
2103
+ '<span class="tl-tip">' + escHtml(tip) + '</span>' +
2104
+ '<div class="tl-fill">' + inner + '</div>' +
2105
+ '</div>';
2106
+ }).join('');
2107
+
2108
+ const axis = buckets.length
2109
+ ? '<div class="tl-axis"><span>' + escHtml(buckets[0].date.slice(5)) +
2110
+ '</span><span>' + escHtml(buckets[buckets.length - 1].date.slice(5)) + '</span></div>'
2111
+ : '';
2112
+ const note = data.hasOlder
2113
+ ? '<div class="tl-note">Showing last 14 days · earlier activity not shown</div>'
2114
+ : '<div class="tl-note">Showing last 14 days</div>';
2115
+ const peak = '<div class="tl-peak" style="bottom:180px"><span class="tl-peak-label">' +
2116
+ fmtNum(max) + ' tokens</span></div>';
2117
+ return cards + legend + '<div class="tl-chart">' + peak + bars + '</div>' + axis + note;
2118
+ }
2119
+
1865
2120
  async function loadGlobalSettings() {
1866
2121
  const container = document.getElementById('global-settings-content');
1867
2122
  container.innerHTML = '<div class="loading-msg">Loading…</div>';
@@ -2233,6 +2488,72 @@ const adminViewStyles = `
2233
2488
  }
2234
2489
  .usage-table code { font-size: 0.72rem; }
2235
2490
 
2491
+ .timeline-controls {
2492
+ display: flex; flex-wrap: wrap; gap: 18px; align-items: center; margin-bottom: 16px;
2493
+ }
2494
+ .timeline-controls label {
2495
+ display: flex; align-items: center; gap: 8px;
2496
+ font-size: 0.76rem; color: var(--muted);
2497
+ }
2498
+ .timeline-controls select {
2499
+ padding: 6px 10px; border: 1px solid var(--border); border-radius: 8px;
2500
+ background: #fff; color: var(--text); font-size: 0.8rem; max-width: 240px;
2501
+ }
2502
+ .tl-note { margin-top: 8px; font-size: 0.72rem; color: var(--subtle); }
2503
+ .tl-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px; }
2504
+ .tl-card { background: rgba(0,0,0,0.025); border-radius: 10px; padding: 10px 12px; }
2505
+ .tl-card-label { font-size: 0.73rem; color: var(--muted); margin-bottom: 4px; }
2506
+ .tl-card-value { font-size: 1.35rem; font-weight: 600; color: var(--text); }
2507
+ .tl-legend { display: flex; gap: 16px; font-size: 0.73rem; color: var(--muted); margin-bottom: 10px; }
2508
+ .tl-legend span { display: inline-flex; align-items: center; gap: 6px; }
2509
+ .tl-legend-item { cursor: pointer; transition: opacity 100ms; }
2510
+ .tl-legend-item:hover { text-decoration: underline; }
2511
+ .tl-legend-item.active { color: var(--text); font-weight: 600; text-decoration: underline; }
2512
+ .tl-legend-item.dim { opacity: 0.4; }
2513
+ .sw { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
2514
+ .sw-cache-read { background: rgba(0,0,0,0.18); }
2515
+ .sw-cache-write { background: #3b6fb0; }
2516
+ .sw-input { background: var(--accent); }
2517
+ .sw-output { background: var(--ok-text); }
2518
+ .tl-chart {
2519
+ position: relative;
2520
+ display: flex; align-items: flex-end; gap: 6px;
2521
+ height: 216px; border-bottom: 1px solid var(--border);
2522
+ }
2523
+ .tl-peak {
2524
+ position: absolute; left: 0; right: 0; height: 0;
2525
+ border-top: 1px dashed var(--accent); pointer-events: none;
2526
+ }
2527
+ .tl-peak-label {
2528
+ position: absolute; left: 0; top: -15px;
2529
+ font-size: 0.7rem; color: var(--accent); white-space: nowrap;
2530
+ }
2531
+ .tl-bar {
2532
+ position: relative; flex: 1; min-width: 0; height: 180px;
2533
+ display: flex; flex-direction: column; justify-content: flex-end;
2534
+ border-radius: 6px 6px 0 0; transition: background 80ms ease;
2535
+ }
2536
+ .tl-bar:hover { background: rgba(0,0,0,0.06); }
2537
+ .tl-bar:hover .tl-seg { opacity: 0.55; }
2538
+ .tl-fill {
2539
+ display: flex; flex-direction: column; justify-content: flex-end;
2540
+ border-radius: 3px 3px 0 0; overflow: hidden;
2541
+ }
2542
+ .tl-tip {
2543
+ position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%);
2544
+ margin-bottom: 6px; padding: 5px 9px; border-radius: 6px;
2545
+ background: var(--text); color: #fafafa; font-size: 0.7rem; line-height: 1.35;
2546
+ white-space: nowrap; opacity: 0; pointer-events: none; z-index: 5;
2547
+ }
2548
+ .tl-bar:hover .tl-tip { opacity: 1; }
2549
+ .tl-seg { display: block; width: 100%; }
2550
+ .tl-seg.tl-output { background: var(--ok-text); }
2551
+ .tl-seg.tl-input { background: var(--accent); }
2552
+ .tl-seg.tl-cache-write { background: #3b6fb0; }
2553
+ .tl-seg.tl-cache-read { background: rgba(0,0,0,0.18); }
2554
+ .tl-empty { display: block; width: 100%; height: 3px; background: var(--border); }
2555
+ .tl-axis { display: flex; justify-content: space-between; margin-top: 6px; font-size: 0.7rem; color: var(--subtle); }
2556
+
2236
2557
  .status-pill {
2237
2558
  display: inline-flex; padding: 2px 9px; border-radius: 999px;
2238
2559
  font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;