@mercury-fw/core 0.25.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 (117) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +38 -0
  3. package/dist/index.d.ts +23 -0
  4. package/dist/src/admin/cli-routes.d.ts +22 -0
  5. package/dist/src/admin/env-file.d.ts +1 -0
  6. package/dist/src/admin/model-routes.d.ts +26 -0
  7. package/dist/src/admin/qdrant-scroll.d.ts +34 -0
  8. package/dist/src/admin/server.d.ts +40 -0
  9. package/dist/src/admin/wiki-routes.d.ts +31 -0
  10. package/dist/src/compose.d.ts +42 -0
  11. package/dist/src/config/define-config.d.ts +31 -0
  12. package/dist/src/cron/idle-session-cron.d.ts +80 -0
  13. package/dist/src/cron/idle-session-scanner.d.ts +16 -0
  14. package/dist/src/cron/self-review-cron.d.ts +55 -0
  15. package/dist/src/cron/semantic-consolidation.d.ts +71 -0
  16. package/dist/src/memory/embedder.d.ts +9 -0
  17. package/dist/src/memory/episodic-store.d.ts +121 -0
  18. package/dist/src/memory/memory-provider.d.ts +51 -0
  19. package/dist/src/memory/semantic-facts-store.d.ts +37 -0
  20. package/dist/src/memory/tool-corrections-store.d.ts +26 -0
  21. package/dist/src/memory/verbatim-archive-store.d.ts +86 -0
  22. package/dist/src/model/client.d.ts +24 -0
  23. package/dist/src/model/context-size.d.ts +30 -0
  24. package/dist/src/plugins/manifest.d.ts +29 -0
  25. package/dist/src/plugins/plugin-loader.d.ts +85 -0
  26. package/dist/src/router/channel-loader.d.ts +30 -0
  27. package/dist/src/router/provider.d.ts +7 -0
  28. package/dist/src/router/terminal-provider.d.ts +37 -0
  29. package/dist/src/router/terminal.d.ts +41 -0
  30. package/dist/src/router/tool-log.d.ts +65 -0
  31. package/dist/src/router/turn-runner.d.ts +86 -0
  32. package/dist/src/session/agent-turn.d.ts +266 -0
  33. package/dist/src/session/context-primer.d.ts +16 -0
  34. package/dist/src/session/episodic-summarizer.d.ts +25 -0
  35. package/dist/src/session/history.d.ts +95 -0
  36. package/dist/src/session/pending-confirmation.d.ts +8 -0
  37. package/dist/src/session/read-skill-tool.d.ts +4 -0
  38. package/dist/src/session/semantic-fact-extractor.d.ts +45 -0
  39. package/dist/src/session/step-info.d.ts +24 -0
  40. package/dist/src/session/summarizer.d.ts +23 -0
  41. package/dist/src/session/system-prompt.d.ts +38 -0
  42. package/dist/src/session/tool-correction-extractor.d.ts +43 -0
  43. package/dist/src/session/tool-log-buffer.d.ts +24 -0
  44. package/dist/src/session/tool-log-recall-tool.d.ts +18 -0
  45. package/dist/src/session/tool-start-hook.d.ts +57 -0
  46. package/dist/src/tools/display-store.d.ts +36 -0
  47. package/dist/src/tools/present-tool.d.ts +23 -0
  48. package/dist/src/wiki/frontmatter-schema.d.ts +53 -0
  49. package/dist/src/wiki/index-entry.d.ts +15 -0
  50. package/dist/src/wiki/orphan-detector.d.ts +1 -0
  51. package/dist/src/wiki/self-review-runner.d.ts +48 -0
  52. package/dist/src/wiki/self-review-tools.d.ts +22 -0
  53. package/dist/src/wiki/vault-cli.d.ts +2 -0
  54. package/dist/src/wiki/vault-init.d.ts +7 -0
  55. package/dist/src/wiki/wiki-note.d.ts +62 -0
  56. package/dist/src/wiki/wiki-read.d.ts +27 -0
  57. package/dist/src/wiki/wiki-tools.d.ts +7 -0
  58. package/index.ts +23 -0
  59. package/package.json +49 -0
  60. package/src/admin/cli-routes.ts +48 -0
  61. package/src/admin/env-file.ts +29 -0
  62. package/src/admin/model-routes.ts +71 -0
  63. package/src/admin/public/index.html +416 -0
  64. package/src/admin/qdrant-scroll.ts +45 -0
  65. package/src/admin/server.ts +188 -0
  66. package/src/admin/wiki-routes.ts +93 -0
  67. package/src/compose.ts +599 -0
  68. package/src/config/define-config.ts +35 -0
  69. package/src/cron/.gitkeep +0 -0
  70. package/src/cron/idle-session-cron.ts +144 -0
  71. package/src/cron/idle-session-scanner.ts +37 -0
  72. package/src/cron/self-review-cron.ts +103 -0
  73. package/src/cron/semantic-consolidation.ts +228 -0
  74. package/src/memory/.gitkeep +0 -0
  75. package/src/memory/embedder.ts +15 -0
  76. package/src/memory/episodic-store.ts +183 -0
  77. package/src/memory/memory-provider.ts +98 -0
  78. package/src/memory/semantic-facts-store.ts +89 -0
  79. package/src/memory/tool-corrections-store.ts +72 -0
  80. package/src/memory/verbatim-archive-store.ts +202 -0
  81. package/src/model/client.ts +33 -0
  82. package/src/model/context-size.ts +42 -0
  83. package/src/plugins/manifest.ts +47 -0
  84. package/src/plugins/plugin-loader.ts +205 -0
  85. package/src/router/channel-loader.ts +56 -0
  86. package/src/router/provider.ts +7 -0
  87. package/src/router/terminal-provider.ts +155 -0
  88. package/src/router/terminal.ts +151 -0
  89. package/src/router/tool-log.ts +116 -0
  90. package/src/router/turn-runner.ts +205 -0
  91. package/src/session/agent-turn.ts +391 -0
  92. package/src/session/context-primer.ts +134 -0
  93. package/src/session/episodic-summarizer.ts +38 -0
  94. package/src/session/history.ts +168 -0
  95. package/src/session/pending-confirmation.ts +8 -0
  96. package/src/session/read-skill-tool.ts +38 -0
  97. package/src/session/semantic-fact-extractor.ts +69 -0
  98. package/src/session/step-info.ts +27 -0
  99. package/src/session/summarizer.ts +36 -0
  100. package/src/session/system-prompt.ts +142 -0
  101. package/src/session/tool-correction-extractor.ts +133 -0
  102. package/src/session/tool-log-buffer.ts +73 -0
  103. package/src/session/tool-log-recall-tool.ts +38 -0
  104. package/src/session/tool-start-hook.ts +164 -0
  105. package/src/tools/display-store.ts +89 -0
  106. package/src/tools/present-tool.ts +41 -0
  107. package/src/wiki/.gitkeep +0 -0
  108. package/src/wiki/frontmatter-schema.ts +49 -0
  109. package/src/wiki/index-entry.ts +59 -0
  110. package/src/wiki/orphan-detector.ts +61 -0
  111. package/src/wiki/self-review-runner.ts +133 -0
  112. package/src/wiki/self-review-tools.ts +162 -0
  113. package/src/wiki/vault-cli.ts +143 -0
  114. package/src/wiki/vault-init.ts +43 -0
  115. package/src/wiki/wiki-note.ts +326 -0
  116. package/src/wiki/wiki-read.ts +122 -0
  117. package/src/wiki/wiki-tools.ts +112 -0
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Admin panel's CLI status tab: lists each active CLI's allowlisted
3
+ * commands straight from the same `CliConfig` `src/index.ts` already
4
+ * built at startup (`activeCliConfigs`), and — where a CLI declares a
5
+ * plain, non-mutating `auth whoami`-shaped prefix — runs it through the
6
+ * exact same `runCli` used for real tool calls. Never constructs or runs
7
+ * anything else; this is read-only status, not a command console.
8
+ */
9
+ import type { CliConfig } from "@mercury-fw/cli-engine";
10
+ import type { runCli } from "@mercury-fw/cli-engine";
11
+
12
+ export type CliStatusEntry = {
13
+ binary: string;
14
+ allowedPrefixes: CliConfig["allowedPrefixes"];
15
+ whoami: { available: false } | { available: true; ok: boolean; output: string };
16
+ };
17
+
18
+ function findWhoamiPrefix(config: CliConfig): string[] | null {
19
+ const match = config.allowedPrefixes.find(
20
+ (c) => !c.confirm && c.prefix.length === 2 && c.prefix[0] === "auth" && c.prefix[1] === "whoami",
21
+ );
22
+ return match ? match.prefix : null;
23
+ }
24
+
25
+ export async function getCliStatus(
26
+ configs: Record<string, CliConfig>,
27
+ runCliFn: typeof runCli,
28
+ ): Promise<CliStatusEntry[]> {
29
+ const entries: CliStatusEntry[] = [];
30
+ for (const [binary, config] of Object.entries(configs)) {
31
+ const whoamiPrefix = findWhoamiPrefix(config);
32
+ if (!whoamiPrefix) {
33
+ entries.push({ binary, allowedPrefixes: config.allowedPrefixes, whoami: { available: false } });
34
+ continue;
35
+ }
36
+ const result = await runCliFn(binary, whoamiPrefix);
37
+ entries.push({
38
+ binary,
39
+ allowedPrefixes: config.allowedPrefixes,
40
+ whoami: {
41
+ available: true,
42
+ ok: result.ok,
43
+ output: result.ok ? String(result.data) : result.error,
44
+ },
45
+ });
46
+ }
47
+ return entries;
48
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Targeted single-key rewrite of a `.env` file's raw text — used by the
3
+ * admin panel's allowlisted env editor (see `env-routes.ts`). Only the
4
+ * matched key's own line changes; every other line (including any secret
5
+ * sitting right next to it) is passed through byte-identical. `content`
6
+ * in, `content` out — no filesystem access here, so it's trivially testable.
7
+ */
8
+ function escapeRegExp(value: string): string {
9
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
10
+ }
11
+
12
+ export function setEnvValue(content: string, key: string, value: string): string {
13
+ const linePattern = new RegExp(`^${escapeRegExp(key)}=`);
14
+ let found = false;
15
+ const lines = content.split("\n").map((line) => {
16
+ if (!found && linePattern.test(line)) {
17
+ found = true;
18
+ return `${key}=${value}`;
19
+ }
20
+ return line;
21
+ });
22
+
23
+ if (found) {
24
+ return lines.join("\n");
25
+ }
26
+
27
+ const needsNewlineBefore = content.length > 0 && !content.endsWith("\n");
28
+ return `${content}${needsNewlineBefore ? "\n" : ""}${key}=${value}\n`;
29
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Admin panel's Model tab (Ollama host/model/context-length/reachability)
3
+ * and the self+dependency health check that stands in for "container
4
+ * status" (see plan §5 — no Docker socket access, so this is Mercury's
5
+ * own process plus its two real dependencies, not `docker compose ps`).
6
+ */
7
+ import { getLoadedContextLength } from "../model/context-size.ts";
8
+
9
+ type OllamaTagsResponse = { models?: Array<{ name: string }> };
10
+
11
+ /** Live model names Ollama actually has pulled, or `null` if the host isn't reachable. */
12
+ export async function getAvailableModels(host: string, fetchFn: typeof fetch = fetch): Promise<string[] | null> {
13
+ try {
14
+ const response = await fetchFn(`${host}/api/tags`);
15
+ if (!response.ok) return null;
16
+ const data = (await response.json()) as OllamaTagsResponse;
17
+ return data.models?.map((m) => m.name) ?? [];
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ export type ModelStatus = {
24
+ host: string;
25
+ model: string;
26
+ contextLength: number | null;
27
+ availableModels: string[] | null;
28
+ };
29
+
30
+ export async function getModelStatus(
31
+ host: string,
32
+ model: string,
33
+ fetchFn: typeof fetch = fetch,
34
+ ): Promise<ModelStatus> {
35
+ // getLoadedContextLength (unlike getAvailableModels) doesn't catch its own
36
+ // fetch failures — an unreachable host makes it throw, which would
37
+ // otherwise take the whole status endpoint down with it.
38
+ const [contextLength, availableModels] = await Promise.all([
39
+ getLoadedContextLength(host, model, fetchFn).catch(() => null),
40
+ getAvailableModels(host, fetchFn),
41
+ ]);
42
+ return { host, model, contextLength, availableModels };
43
+ }
44
+
45
+ export type SelfHealth = {
46
+ uptimeSeconds: number;
47
+ memory: { rss: number; heapUsed: number; heapTotal: number };
48
+ qdrantReachable: boolean;
49
+ ollamaReachable: boolean;
50
+ };
51
+
52
+ export async function getSelfHealth(deps: {
53
+ qdrant: { getCollections(): Promise<unknown> };
54
+ ollamaHost: string;
55
+ fetchFn?: typeof fetch;
56
+ }): Promise<SelfHealth> {
57
+ const [qdrantReachable, availableModels] = await Promise.all([
58
+ deps.qdrant
59
+ .getCollections()
60
+ .then(() => true)
61
+ .catch(() => false),
62
+ getAvailableModels(deps.ollamaHost, deps.fetchFn),
63
+ ]);
64
+ const memory = process.memoryUsage();
65
+ return {
66
+ uptimeSeconds: process.uptime(),
67
+ memory: { rss: memory.rss, heapUsed: memory.heapUsed, heapTotal: memory.heapTotal },
68
+ qdrantReachable,
69
+ ollamaReachable: availableModels !== null,
70
+ };
71
+ }
@@ -0,0 +1,416 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Mercury admin (POC)</title>
6
+ <style>
7
+ :root { color-scheme: light dark; }
8
+ body { font-family: system-ui, sans-serif; margin: 0; padding: 0; }
9
+ header { padding: 0.75rem 1rem; border-bottom: 1px solid #8884; display: flex; align-items: center; gap: 1rem; }
10
+ header h1 { font-size: 1rem; margin: 0; }
11
+ nav { display: flex; gap: 0.25rem; padding: 0.5rem 1rem; border-bottom: 1px solid #8884; flex-wrap: wrap; }
12
+ nav button { padding: 0.4rem 0.8rem; border: 1px solid #8884; background: transparent; cursor: pointer; border-radius: 4px; }
13
+ nav button[aria-selected="true"] { background: #8882; font-weight: 600; }
14
+ main { padding: 1rem; max-width: 100%; overflow-x: auto; }
15
+ section { display: none; }
16
+ section[aria-hidden="false"] { display: block; }
17
+ textarea, input[type=text], input[type=number], select { font-family: inherit; font-size: 0.9rem; padding: 0.3rem; }
18
+ textarea { width: 100%; min-height: 6rem; box-sizing: border-box; }
19
+ pre { white-space: pre-wrap; word-break: break-word; background: #8881; padding: 0.6rem; border-radius: 4px; max-height: 24rem; overflow: auto; }
20
+ table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }
21
+ th, td { text-align: left; padding: 0.3rem 0.5rem; border-bottom: 1px solid #8883; vertical-align: top; }
22
+ .row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin: 0.4rem 0; }
23
+ .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
24
+ .muted { opacity: 0.7; font-size: 0.85rem; }
25
+ .list { list-style: none; padding: 0; margin: 0; max-height: 20rem; overflow: auto; border: 1px solid #8883; border-radius: 4px; }
26
+ .list li { padding: 0.25rem 0.5rem; cursor: pointer; }
27
+ .list li:hover { background: #8882; }
28
+ fieldset { border: 1px solid #8883; border-radius: 4px; margin: 0.6rem 0; }
29
+ button.primary { font-weight: 600; }
30
+ .status-ok { color: #2a2; }
31
+ .status-bad { color: #c33; }
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <header>
36
+ <h1>Mercury admin — POC</h1>
37
+ <span class="muted">throwaway panel, local only, no auth</span>
38
+ </header>
39
+ <nav id="tabs"></nav>
40
+ <main id="panels"></main>
41
+
42
+ <script>
43
+ const TABS = ["Wiki", "Qdrant", "CLI", "Model", "System prompt", "Tool log", "Env"];
44
+ const tabsEl = document.getElementById("tabs");
45
+ const panelsEl = document.getElementById("panels");
46
+ let active = TABS[0];
47
+
48
+ function renderNav() {
49
+ tabsEl.innerHTML = "";
50
+ for (const t of TABS) {
51
+ const b = document.createElement("button");
52
+ b.textContent = t;
53
+ b.setAttribute("aria-selected", String(t === active));
54
+ b.onclick = () => { active = t; renderNav(); showPanel(t); };
55
+ tabsEl.appendChild(b);
56
+ }
57
+ }
58
+
59
+ function panel(id) {
60
+ return document.getElementById("panel-" + id);
61
+ }
62
+
63
+ function showPanel(t) {
64
+ for (const el of panelsEl.children) {
65
+ el.setAttribute("aria-hidden", String(el.id !== "panel-" + t));
66
+ }
67
+ const loader = loaders[t];
68
+ if (loader) loader();
69
+ }
70
+
71
+ async function api(path, opts) {
72
+ const res = await fetch(path, opts);
73
+ const data = await res.json();
74
+ if (!data.ok) throw new Error(data.error || "request failed");
75
+ return data;
76
+ }
77
+
78
+ function el(html) {
79
+ const t = document.createElement("template");
80
+ t.innerHTML = html.trim();
81
+ return t.content.firstChild;
82
+ }
83
+
84
+ // ---------- Wiki ----------
85
+ function buildWikiPanel() {
86
+ const p = el(`<section id="panel-Wiki" aria-hidden="true">
87
+ <div class="cols">
88
+ <div>
89
+ <div class="row"><button id="wiki-refresh">Refresh list</button></div>
90
+ <ul class="list" id="wiki-list"></ul>
91
+ <div class="row">
92
+ <input type="text" id="wiki-grep-pattern" placeholder="" />
93
+ <button id="wiki-grep-btn">Serach</button>
94
+ </div>
95
+ <pre id="wiki-grep-results"></pre>
96
+ </div>
97
+ <div>
98
+ <h3 id="wiki-read-path" class="muted">(select a file)</h3>
99
+ <pre id="wiki-read-content"></pre>
100
+
101
+ <fieldset>
102
+ <legend>Edit a curated note (via the model, real turn)</legend>
103
+ <textarea id="wiki-edit-instruction" placeholder='e.g. "update curated/standards/x.md to say..."'></textarea>
104
+ <div class="row"><button id="wiki-edit-btn" class="primary">Send to Mercury</button></div>
105
+ <pre id="wiki-edit-reply"></pre>
106
+ </fieldset>
107
+
108
+ <fieldset>
109
+ <legend>Write a raw/ entry (direct, no model)</legend>
110
+ <input type="text" id="wiki-raw-path" placeholder="raw/some-note.md" style="width:100%;box-sizing:border-box;" />
111
+ <textarea id="wiki-raw-content" placeholder="verbatim content"></textarea>
112
+ <div class="row"><button id="wiki-raw-btn">Write raw entry</button></div>
113
+ </fieldset>
114
+
115
+ <fieldset>
116
+ <legend>Delete curated/ or raw/ entry (direct, no model)</legend>
117
+ <div class="row">
118
+ <input type="text" id="wiki-delete-path" placeholder="curated/x.md or raw/x.md" />
119
+ <button id="wiki-delete-btn">Delete</button>
120
+ </div>
121
+ </fieldset>
122
+ </div>
123
+ </div>
124
+ </section>`);
125
+
126
+ p.querySelector("#wiki-refresh").onclick = loadWikiList;
127
+ p.querySelector("#wiki-grep-btn").onclick = async () => {
128
+ const pattern = p.querySelector("#wiki-grep-pattern").value;
129
+ const out = p.querySelector("#wiki-grep-results");
130
+ try {
131
+ const { matches } = await api("/api/wiki/grep?pattern=" + encodeURIComponent(pattern));
132
+ out.textContent = matches.map(m => `${m.path}:${m.line}: ${m.text}`).join("\n") || "(no matches)";
133
+ } catch (e) { out.textContent = "error: " + e.message; }
134
+ };
135
+ p.querySelector("#wiki-edit-btn").onclick = async () => {
136
+ const instruction = p.querySelector("#wiki-edit-instruction").value;
137
+ const out = p.querySelector("#wiki-edit-reply");
138
+ out.textContent = "working...";
139
+ try {
140
+ const { reply } = await api("/api/wiki/edit", { method: "POST", headers: jsonHeaders, body: JSON.stringify({ instruction }) });
141
+ out.textContent = reply;
142
+ loadWikiList();
143
+ } catch (e) { out.textContent = "error: " + e.message; }
144
+ };
145
+ p.querySelector("#wiki-raw-btn").onclick = async () => {
146
+ const path = p.querySelector("#wiki-raw-path").value;
147
+ const content = p.querySelector("#wiki-raw-content").value;
148
+ try {
149
+ await api("/api/wiki/raw", { method: "POST", headers: jsonHeaders, body: JSON.stringify({ path, content }) });
150
+ loadWikiList();
151
+ } catch (e) { alert("error: " + e.message); }
152
+ };
153
+ p.querySelector("#wiki-delete-btn").onclick = async () => {
154
+ const path = p.querySelector("#wiki-delete-path").value;
155
+ if (!confirm(`Delete "${path}"? This is a real git commit removing the file.`)) return;
156
+ try {
157
+ await api("/api/wiki/entry?path=" + encodeURIComponent(path), { method: "DELETE" });
158
+ loadWikiList();
159
+ } catch (e) { alert("error: " + e.message); }
160
+ };
161
+
162
+ return p;
163
+ }
164
+
165
+ async function loadWikiList() {
166
+ const listEl = panel("Wiki").querySelector("#wiki-list");
167
+ listEl.innerHTML = "loading...";
168
+ try {
169
+ const { files } = await api("/api/wiki/list");
170
+ listEl.innerHTML = "";
171
+ for (const f of files) {
172
+ const li = document.createElement("li");
173
+ li.textContent = f;
174
+ li.onclick = async () => {
175
+ panel("Wiki").querySelector("#wiki-read-path").textContent = f;
176
+ const out = panel("Wiki").querySelector("#wiki-read-content");
177
+ out.textContent = "loading...";
178
+ try {
179
+ const { content } = await api("/api/wiki/read?path=" + encodeURIComponent(f));
180
+ out.textContent = content;
181
+ } catch (e) { out.textContent = "error: " + e.message; }
182
+ };
183
+ listEl.appendChild(li);
184
+ }
185
+ } catch (e) { listEl.textContent = "error: " + e.message; }
186
+ }
187
+
188
+ // ---------- Qdrant ----------
189
+ function buildQdrantPanel() {
190
+ const p = el(`<section id="panel-Qdrant" aria-hidden="true">
191
+ <div class="row">
192
+ <button id="qdrant-refresh">List collections</button>
193
+ <select id="qdrant-collection"></select>
194
+ <input type="number" id="qdrant-limit" value="20" style="width:5rem;" />
195
+ <button id="qdrant-scroll-btn">Scroll</button>
196
+ </div>
197
+ <pre id="qdrant-collections"></pre>
198
+ <pre id="qdrant-points"></pre>
199
+ </section>`);
200
+
201
+ p.querySelector("#qdrant-refresh").onclick = async () => {
202
+ const out = p.querySelector("#qdrant-collections");
203
+ const select = p.querySelector("#qdrant-collection");
204
+ try {
205
+ const { collections } = await api("/api/qdrant/collections");
206
+ out.textContent = JSON.stringify(collections, null, 2);
207
+ select.innerHTML = "";
208
+ for (const c of collections) {
209
+ const opt = document.createElement("option");
210
+ opt.value = c.name; opt.textContent = c.name;
211
+ select.appendChild(opt);
212
+ }
213
+ } catch (e) { out.textContent = "error: " + e.message; }
214
+ };
215
+ p.querySelector("#qdrant-scroll-btn").onclick = async () => {
216
+ const collection = p.querySelector("#qdrant-collection").value;
217
+ const limit = p.querySelector("#qdrant-limit").value;
218
+ const out = p.querySelector("#qdrant-points");
219
+ if (!collection) { out.textContent = "list collections first"; return; }
220
+ try {
221
+ const page = await api(`/api/qdrant/scroll?collection=${encodeURIComponent(collection)}&limit=${limit}`);
222
+ out.textContent = JSON.stringify(page.points, null, 2) + `\n\nnextOffset: ${JSON.stringify(page.nextOffset)}`;
223
+ } catch (e) { out.textContent = "error: " + e.message; }
224
+ };
225
+ return p;
226
+ }
227
+
228
+ // ---------- CLI ----------
229
+ function buildCliPanel() {
230
+ return el(`<section id="panel-CLI" aria-hidden="true">
231
+ <div class="row"><button id="cli-refresh">Refresh</button></div>
232
+ <div id="cli-table"></div>
233
+ </section>`);
234
+ }
235
+ async function loadCli() {
236
+ const p = panel("CLI");
237
+ if (!p.querySelector("#cli-refresh").onclick) {
238
+ p.querySelector("#cli-refresh").onclick = loadCli;
239
+ }
240
+ const out = p.querySelector("#cli-table");
241
+ out.textContent = "loading...";
242
+ try {
243
+ const { clis } = await api("/api/cli/status");
244
+ out.innerHTML = "";
245
+ for (const c of clis) {
246
+ const section = el(`<fieldset><legend>${c.binary}</legend></fieldset>`);
247
+ const prefixes = el(`<pre>${c.allowedPrefixes.map(a => `${a.prefix.join(" ")} ${a.confirm ? "[confirm]" : ""}${a.mutating ? "[mutating]" : ""}`).join("\n")}</pre>`);
248
+ section.appendChild(prefixes);
249
+ const whoami = document.createElement("div");
250
+ whoami.className = c.whoami.available ? (c.whoami.ok ? "status-ok" : "status-bad") : "muted";
251
+ whoami.textContent = c.whoami.available ? `auth whoami: ${c.whoami.output}` : "auth whoami: not available for this CLI";
252
+ section.appendChild(whoami);
253
+ out.appendChild(section);
254
+ }
255
+ } catch (e) { out.textContent = "error: " + e.message; }
256
+ }
257
+
258
+ // ---------- Model ----------
259
+ function buildModelPanel() {
260
+ return el(`<section id="panel-Model" aria-hidden="true">
261
+ <div class="row"><button id="model-refresh">Refresh</button></div>
262
+ <pre id="model-status"></pre>
263
+ <h3>Self + dependency health</h3>
264
+ <pre id="health-status"></pre>
265
+ </section>`);
266
+ }
267
+ async function loadModel() {
268
+ const p = panel("Model");
269
+ if (!p.querySelector("#model-refresh").onclick) p.querySelector("#model-refresh").onclick = loadModel;
270
+ const modelOut = p.querySelector("#model-status");
271
+ const healthOut = p.querySelector("#health-status");
272
+ try {
273
+ const status = await api("/api/model/status");
274
+ modelOut.textContent = JSON.stringify(status, null, 2);
275
+ } catch (e) { modelOut.textContent = "error: " + e.message; }
276
+ try {
277
+ const health = await api("/api/health");
278
+ healthOut.textContent = JSON.stringify(health, null, 2);
279
+ } catch (e) { healthOut.textContent = "error: " + e.message; }
280
+ }
281
+
282
+ // ---------- System prompt ----------
283
+ function buildSystemPromptPanel() {
284
+ return el(`<section id="panel-System prompt" aria-hidden="true">
285
+ <div class="row"><button id="sp-refresh">Refresh</button></div>
286
+ <h3>Terminal</h3>
287
+ <pre id="sp-terminal"></pre>
288
+ <h3>Google Chat</h3>
289
+ <pre id="sp-google"></pre>
290
+ </section>`);
291
+ }
292
+ async function loadSystemPrompt() {
293
+ const p = panel("System prompt");
294
+ if (!p.querySelector("#sp-refresh").onclick) p.querySelector("#sp-refresh").onclick = loadSystemPrompt;
295
+ try {
296
+ const data = await api("/api/system-prompt");
297
+ p.querySelector("#sp-terminal").textContent = data.terminal;
298
+ p.querySelector("#sp-google").textContent = data.googleChat;
299
+ } catch (e) { p.querySelector("#sp-terminal").textContent = "error: " + e.message; }
300
+ }
301
+
302
+ // ---------- Tool log ----------
303
+ function buildToolLogPanel() {
304
+ return el(`<section id="panel-Tool log" aria-hidden="true">
305
+ <div class="row"><button id="log-refresh">Refresh</button></div>
306
+ <table>
307
+ <thead><tr><th>time</th><th>channel</th><th>tool</th><th>input</th><th>output</th></tr></thead>
308
+ <tbody id="log-body"></tbody>
309
+ </table>
310
+ </section>`);
311
+ }
312
+ async function loadToolLog() {
313
+ const p = panel("Tool log");
314
+ if (!p.querySelector("#log-refresh").onclick) p.querySelector("#log-refresh").onclick = loadToolLog;
315
+ const body = p.querySelector("#log-body");
316
+ body.innerHTML = "<tr><td colspan=5>loading...</td></tr>";
317
+ try {
318
+ const { entries } = await api("/api/tool-log");
319
+ body.innerHTML = "";
320
+ for (const e of entries) {
321
+ const tr = document.createElement("tr");
322
+ tr.innerHTML = `<td>${e.timestamp}</td><td>${e.channel}</td><td>${e.toolName}</td><td>${escapeHtml(e.input)}</td><td>${escapeHtml(e.output)}</td>`;
323
+ body.appendChild(tr);
324
+ }
325
+ if (!entries.length) body.innerHTML = "<tr><td colspan=5 class='muted'>(empty — run a turn that calls a tool)</td></tr>";
326
+ } catch (e) { body.innerHTML = `<tr><td colspan=5>error: ${e.message}</td></tr>`; }
327
+ }
328
+ function escapeHtml(s) {
329
+ const d = document.createElement("div");
330
+ d.textContent = s;
331
+ return d.innerHTML;
332
+ }
333
+
334
+ // ---------- Env ----------
335
+ function buildEnvPanel() {
336
+ return el(`<section id="panel-Env" aria-hidden="true">
337
+ <p class="muted">Only these keys are ever read or written here — everything else in .env (including secrets) is never touched or listed.</p>
338
+ <div id="env-fields"></div>
339
+ <p class="muted">Saved to .env — run <code>docker compose up -d</code> to apply. No restart button here on purpose (a plain container restart doesn't reload .env — see plan).</p>
340
+ </section>`);
341
+ }
342
+ async function loadEnv() {
343
+ const p = panel("Env");
344
+ const fields = p.querySelector("#env-fields");
345
+ fields.textContent = "loading...";
346
+ try {
347
+ const [{ keys, values }, modelStatus] = await Promise.all([api("/api/env"), api("/api/model/status")]);
348
+ fields.innerHTML = "";
349
+ for (const key of keys) {
350
+ const row = document.createElement("div");
351
+ row.className = "row";
352
+ const label = document.createElement("label");
353
+ label.textContent = key;
354
+ label.style.width = "20rem";
355
+ row.appendChild(label);
356
+
357
+ let input;
358
+ if (key === "OLLAMA_MODEL") {
359
+ input = document.createElement("select");
360
+ for (const m of modelStatus.availableModels || []) {
361
+ const opt = document.createElement("option");
362
+ opt.value = m; opt.textContent = m;
363
+ if (m === values[key]) opt.selected = true;
364
+ input.appendChild(opt);
365
+ }
366
+ if (!modelStatus.availableModels) {
367
+ const opt = document.createElement("option");
368
+ opt.textContent = "(Ollama unreachable — can't list models)";
369
+ input.appendChild(opt);
370
+ }
371
+ } else {
372
+ input = document.createElement("input");
373
+ input.type = "number";
374
+ input.value = values[key] ?? "";
375
+ }
376
+ row.appendChild(input);
377
+
378
+ const saveBtn = document.createElement("button");
379
+ saveBtn.textContent = "Save";
380
+ saveBtn.onclick = async () => {
381
+ try {
382
+ await api("/api/env", { method: "PUT", headers: jsonHeaders, body: JSON.stringify({ key, value: String(input.value) }) });
383
+ saveBtn.textContent = "Saved";
384
+ setTimeout(() => (saveBtn.textContent = "Save"), 1500);
385
+ } catch (e) { alert("error: " + e.message); }
386
+ };
387
+ row.appendChild(saveBtn);
388
+ fields.appendChild(row);
389
+ }
390
+ } catch (e) { fields.textContent = "error: " + e.message; }
391
+ }
392
+
393
+ const jsonHeaders = { "content-type": "application/json" };
394
+
395
+ const loaders = {
396
+ "Wiki": loadWikiList,
397
+ "CLI": loadCli,
398
+ "Model": loadModel,
399
+ "System prompt": loadSystemPrompt,
400
+ "Tool log": loadToolLog,
401
+ "Env": loadEnv,
402
+ };
403
+
404
+ panelsEl.appendChild(buildWikiPanel());
405
+ panelsEl.appendChild(buildQdrantPanel());
406
+ panelsEl.appendChild(buildCliPanel());
407
+ panelsEl.appendChild(buildModelPanel());
408
+ panelsEl.appendChild(buildSystemPromptPanel());
409
+ panelsEl.appendChild(buildToolLogPanel());
410
+ panelsEl.appendChild(buildEnvPanel());
411
+
412
+ renderNav();
413
+ showPanel(active);
414
+ </script>
415
+ </body>
416
+ </html>
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Read-only point enumeration for the admin panel's Qdrant inspection tab —
3
+ * `episodic-store.ts`/`semantic-facts-store.ts` only wrap similarity
4
+ * `search`, there's no existing way to just list what's in a collection.
5
+ * One page per call (not an auto-paging fetch-everything loop): a real
6
+ * collection can be arbitrarily large, so the caller decides whether to
7
+ * request another page via the returned `nextOffset`.
8
+ */
9
+ // Matches @qdrant/js-client-rest's real ExtendedPointId-based offset type
10
+ // (`string | number | Record<string, unknown> | null`) — a scroll offset
11
+ // is an opaque cursor as far as this module is concerned, only ever
12
+ // forwarded verbatim, but it has to line up with the real client's type
13
+ // for a real `QdrantClient` instance to satisfy this structurally.
14
+ type ScrollOffset = string | number | Record<string, unknown> | null;
15
+
16
+ export type ScrollableQdrantClient = {
17
+ scroll(
18
+ collection: string,
19
+ params: { limit: number; offset?: ScrollOffset; with_payload: boolean },
20
+ ): Promise<{
21
+ points: Array<{ id: string | number; payload?: Record<string, unknown> | null }>;
22
+ next_page_offset?: ScrollOffset;
23
+ }>;
24
+ };
25
+
26
+ export type ScrollPage = {
27
+ points: Array<{ id: string | number; payload: Record<string, unknown> | null }>;
28
+ nextOffset: ScrollOffset;
29
+ };
30
+
31
+ export async function scrollCollection(
32
+ client: ScrollableQdrantClient,
33
+ collectionName: string,
34
+ opts: { limit: number; offset?: ScrollOffset },
35
+ ): Promise<ScrollPage> {
36
+ const result = await client.scroll(collectionName, {
37
+ limit: opts.limit,
38
+ offset: opts.offset,
39
+ with_payload: true,
40
+ });
41
+ return {
42
+ points: result.points.map((p) => ({ id: p.id, payload: p.payload ?? null })),
43
+ nextOffset: result.next_page_offset ?? null,
44
+ };
45
+ }