@cteno/sdk-linux-x64 0.0.0-stage → 2.10.14

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 (116) hide show
  1. package/LICENSE.daemon +31 -0
  2. package/README.md +4 -2
  3. package/agents/flow-analyzer/AGENT.md +71 -0
  4. package/bin/cteno-host-cli +4 -0
  5. package/bin/cteno-wiki +0 -0
  6. package/bin/rg +0 -0
  7. package/bin/secret-exec +0 -0
  8. package/manifest.json +12 -0
  9. package/package.json +33 -4
  10. package/plugins/agent-secrets/.cteno-plugin/plugin.json +133 -0
  11. package/plugins/agent-secrets/bin/linux-x64/fnox +4 -0
  12. package/plugins/agent-secrets/bin/linux-x64/secret-exec +0 -0
  13. package/plugins/agent-secrets/skills/secret/SKILL.md +201 -0
  14. package/plugins/cteno.browser/.cteno-plugin/plugin.json +65 -0
  15. package/plugins/cteno.browser/skills/browser/SKILL.md +28 -0
  16. package/plugins/cteno.inbox/.cteno-plugin/plugin.json +132 -0
  17. package/plugins/cteno.inbox/bin/all/cteno-connector-credential +31 -0
  18. package/plugins/cteno.inbox/bin/all/cteno-telegram-connector +33 -0
  19. package/plugins/cteno.inbox/bin/linux-x64/cteno-inbox +0 -0
  20. package/plugins/cteno.inbox/skills/cteno-inbox/SKILL.md +374 -0
  21. package/plugins/cteno.inbox/tools/inbox_focus/TOOL.md +44 -0
  22. package/plugins/cteno.memory/.cteno-plugin/plugin.json +176 -0
  23. package/plugins/cteno.memory/agents/llm-wiki/AGENT.md +96 -0
  24. package/plugins/cteno.memory/bin/linux-x64/cteno-wiki +0 -0
  25. package/plugins/cteno.memory/skills/codebase-wiki/SKILL.md +52 -0
  26. package/plugins/cteno.memory/skills/cteno.memory.native/SKILL.md +110 -0
  27. package/plugins/cteno.memory/tools/memory/TOOL.md +492 -0
  28. package/plugins/cteno.memory/ui/index.html +822 -0
  29. package/plugins/cteno.models/.cteno-plugin/plugin.json +191 -0
  30. package/plugins/cteno.websearch/.cteno-plugin/plugin.json +45 -0
  31. package/plugins/jev/.cteno-plugin/plugin.json +309 -0
  32. package/plugins/jev/skills/jev-browser/SKILL.md +66 -0
  33. package/plugins/jev/skills/jev-browser/jev_browser.py +335 -0
  34. package/skills/agent-create/SKILL.md +86 -0
  35. package/skills/cteno-host-cli/SKILL.md +256 -0
  36. package/skills/docx/LICENSE +23 -0
  37. package/skills/docx/NOTICE.md +8 -0
  38. package/skills/docx/SKILL.md +69 -0
  39. package/skills/docx/references/cjk_typography.md +357 -0
  40. package/skills/docx/references/design_principles.md +819 -0
  41. package/skills/docx/references/revisions-and-comments.md +88 -0
  42. package/skills/docx/references/track_changes_guide.md +200 -0
  43. package/skills/docx/references/typography_guide.md +294 -0
  44. package/skills/docx/scripts/cjk_fonts.py +244 -0
  45. package/skills/docx/scripts/docx_comments.py +289 -0
  46. package/skills/docx/scripts/docx_common.py +92 -0
  47. package/skills/docx/scripts/docx_create.py +259 -0
  48. package/skills/docx/scripts/docx_edit.py +250 -0
  49. package/skills/docx/scripts/docx_read.py +149 -0
  50. package/skills/docx/scripts/docx_revisions.py +146 -0
  51. package/skills/docx/scripts/docx_template.py +70 -0
  52. package/skills/docx/scripts/docx_validate.py +156 -0
  53. package/skills/docx/scripts/render.py +250 -0
  54. package/skills/docx/tests/test_docx_skill.py +525 -0
  55. package/skills/home-devices/SKILL.md +121 -0
  56. package/skills/mcp/SKILL.md +72 -0
  57. package/skills/mcp/scripts/mcp_setup.py +439 -0
  58. package/skills/pdf/LICENSE +23 -0
  59. package/skills/pdf/NOTICE.md +8 -0
  60. package/skills/pdf/SKILL.md +63 -0
  61. package/skills/pdf/references/design-tokens.md +381 -0
  62. package/skills/pdf/references/forms.md +99 -0
  63. package/skills/pdf/references/nano-pdf-editing.md +40 -0
  64. package/skills/pdf/references/ocr-extraction.md +165 -0
  65. package/skills/pdf/scripts/_raster.py +76 -0
  66. package/skills/pdf/scripts/extract_marker.py +88 -0
  67. package/skills/pdf/scripts/extract_pymupdf.py +101 -0
  68. package/skills/pdf/scripts/pdf_create.py +191 -0
  69. package/skills/pdf/scripts/pdf_fill_form.py +97 -0
  70. package/skills/pdf/scripts/pdf_form_layout.py +168 -0
  71. package/skills/pdf/scripts/pdf_make_form.py +145 -0
  72. package/skills/pdf/scripts/pdf_merge.py +50 -0
  73. package/skills/pdf/scripts/pdf_meta.py +115 -0
  74. package/skills/pdf/scripts/pdf_page_image.py +99 -0
  75. package/skills/pdf/scripts/pdf_read.py +153 -0
  76. package/skills/pdf/scripts/pdf_secure.py +71 -0
  77. package/skills/pdf/scripts/pdf_split.py +84 -0
  78. package/skills/pdf/scripts/pdf_stamp.py +143 -0
  79. package/skills/pdf/scripts/pdf_watermark.py +51 -0
  80. package/skills/pdf/scripts/render.py +250 -0
  81. package/skills/pdf/tests/test_pdf_skill.py +415 -0
  82. package/skills/pptx/LICENSE +23 -0
  83. package/skills/pptx/NOTICE.md +8 -0
  84. package/skills/pptx/SKILL.md +62 -0
  85. package/skills/pptx/references/design-system.md +392 -0
  86. package/skills/pptx/references/pptxgenjs.md +420 -0
  87. package/skills/pptx/references/slide-types.md +413 -0
  88. package/skills/pptx/scripts/cjk_fonts.py +244 -0
  89. package/skills/pptx/scripts/pptx_create.py +240 -0
  90. package/skills/pptx/scripts/pptx_edit.py +436 -0
  91. package/skills/pptx/scripts/pptx_from_template.py +88 -0
  92. package/skills/pptx/scripts/pptx_read.py +131 -0
  93. package/skills/pptx/scripts/pptx_render.py +96 -0
  94. package/skills/pptx/scripts/render.py +250 -0
  95. package/skills/pptx/tests/test_powerpoint_skill.py +477 -0
  96. package/skills/skill-create/SKILL.md +114 -0
  97. package/skills/video-generation/SKILL.md +27 -0
  98. package/skills/xlsx/LICENSE +23 -0
  99. package/skills/xlsx/NOTICE.md +8 -0
  100. package/skills/xlsx/SKILL.md +63 -0
  101. package/skills/xlsx/references/format.md +71 -0
  102. package/skills/xlsx/references/restructuring.md +71 -0
  103. package/skills/xlsx/scripts/csv_to_xlsx.py +110 -0
  104. package/skills/xlsx/scripts/formula_check.py +428 -0
  105. package/skills/xlsx/scripts/render.py +250 -0
  106. package/skills/xlsx/scripts/xlsx_create.py +265 -0
  107. package/skills/xlsx/scripts/xlsx_edit.py +269 -0
  108. package/skills/xlsx/scripts/xlsx_read.py +166 -0
  109. package/skills/xlsx/scripts/xlsx_recalc.py +119 -0
  110. package/skills/xlsx/scripts/xlsx_restructure.py +343 -0
  111. package/skills/xlsx/scripts/xlsx_to_csv.py +75 -0
  112. package/skills/xlsx/tests/test_xlsx_skill.py +544 -0
  113. package/tools/create_goal/TOOL.md +36 -0
  114. package/tools/image_generation/TOOL.md +81 -0
  115. package/tools/skill/TOOL.md +117 -0
  116. package/tools/update_profile_notes/TOOL.md +39 -0
@@ -0,0 +1,822 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <title>记忆</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0f1115; --panel: #171a21; --panel-2: #1e222b; --border: #2a2f3a;
10
+ --text: #e6e8ee; --muted: #9aa3b2; --accent: #8e7cff; --link: #4da6ff;
11
+ --code-bg: #11141a;
12
+ }
13
+ * { box-sizing: border-box; }
14
+ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
15
+ font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; }
16
+ #app { display: flex; flex-direction: column; height: 100%; }
17
+ header { display: flex; align-items: center; gap: 10px; padding: 10px 14px;
18
+ border-bottom: 1px solid var(--border); background: var(--panel); flex: none; }
19
+ header .title { font-weight: 600; letter-spacing: .2px; }
20
+ header .title .dot { color: var(--accent); }
21
+ header .spacer { flex: 1; }
22
+ select, button {
23
+ background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
24
+ border-radius: 8px; padding: 5px 10px; font-size: 13px; cursor: pointer;
25
+ }
26
+ button:hover, select:hover { border-color: var(--accent); }
27
+ main { display: flex; flex: 1; min-height: 0; position: relative; }
28
+ #menu { display: none; padding: 5px 9px; line-height: 1; font-size: 15px; }
29
+ #scrim { display: none; }
30
+ nav { width: 256px; flex: none; border-right: 1px solid var(--border);
31
+ background: var(--panel); overflow-y: auto; padding: 8px; }
32
+ nav .group { color: var(--muted); font-size: 11px; text-transform: uppercase;
33
+ letter-spacing: .6px; padding: 10px 8px 4px; }
34
+ nav .scope-head { color: var(--text); font-size: 12px; font-weight: 600;
35
+ padding: 12px 8px 2px; }
36
+ nav .scope-head:first-child { padding-top: 4px; }
37
+ .empty .hint { display: block; margin-top: 8px; font-size: 13px; }
38
+ nav a { display: block; padding: 6px 8px; border-radius: 7px; color: var(--text);
39
+ text-decoration: none; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
40
+ nav a:hover { background: var(--panel-2); }
41
+ nav .group.toggle { cursor: pointer; display: flex; align-items: center; gap: 4px; user-select: none; }
42
+ nav .group.toggle:hover { color: var(--text); }
43
+ nav .group .caret { width: 10px; display: inline-block; }
44
+ nav .group .count { margin-left: auto; font-size: 11px; color: var(--muted); }
45
+ nav a.more { color: var(--muted); font-size: 12px; }
46
+ #nav-filter { position: sticky; top: 0; z-index: 1; width: 100%; margin: 2px 0 6px; padding: 6px 9px; border-radius: 8px;
47
+ border: 1px solid var(--border); background: var(--panel-2); color: var(--text); font-size: 13px; outline: none; }
48
+ #nav-filter:focus { border-color: var(--accent); }
49
+ article .sentinel { color: var(--muted); text-align: center; padding: 16px; cursor: pointer; font-size: 12px; }
50
+ nav a.active { background: rgba(142,124,255,.18); color: #fff; }
51
+ article { flex: 1; min-width: 0; overflow-y: auto; padding: 22px 30px; overflow-wrap: break-word; }
52
+ article h1 { font-size: 22px; margin: 0 0 14px; }
53
+ article h2 { font-size: 17px; margin: 22px 0 8px; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
54
+ article h3 { font-size: 15px; margin: 16px 0 6px; }
55
+ article a { color: var(--link); text-decoration: none; cursor: pointer; }
56
+ article a:hover { text-decoration: underline; }
57
+ article code { background: var(--code-bg); border: 1px solid var(--border);
58
+ border-radius: 5px; padding: 1px 5px; font-size: 12.5px;
59
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
60
+ article pre { background: var(--code-bg); border: 1px solid var(--border);
61
+ border-radius: 8px; padding: 12px 14px; overflow-x: auto; }
62
+ article pre code { border: 0; padding: 0; }
63
+ article ul, article ol { padding-left: 22px; }
64
+ .meta { color: var(--muted); font-size: 12px; margin: -8px 0 16px; }
65
+ .empty, .err { color: var(--muted); padding: 40px; text-align: center; }
66
+ .err { color: #ff8a8a; }
67
+ .frontmatter { color: var(--muted); font-size: 12px; background: var(--panel);
68
+ border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; margin: 0 0 16px;
69
+ white-space: pre-wrap; overflow-wrap: anywhere;
70
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
71
+
72
+ /* Narrow pane: the sidebar becomes a slide-over drawer toggled by ☰, and the
73
+ article goes full-width. The CEF plugin pane is often ~400px wide. */
74
+ @media (max-width: 600px) {
75
+ #menu { display: inline-flex; align-items: center; }
76
+ header { padding: 9px 10px; gap: 8px; }
77
+ header .title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
78
+ nav {
79
+ position: absolute; top: 0; left: 0; bottom: 0; z-index: 10;
80
+ width: 80%; max-width: 280px;
81
+ transform: translateX(-100%); transition: transform .18s ease;
82
+ box-shadow: 2px 0 16px rgba(0, 0, 0, .45);
83
+ }
84
+ #app.nav-open nav { transform: translateX(0); }
85
+ #scrim { display: block; position: absolute; inset: 0; z-index: 9;
86
+ background: rgba(0, 0, 0, .45); opacity: 0; pointer-events: none;
87
+ transition: opacity .18s ease; }
88
+ #app.nav-open #scrim { opacity: 1; pointer-events: auto; }
89
+ article { padding: 16px 16px; }
90
+ }
91
+ /* Full-text search: header box, results replace the article until cleared. */
92
+ #search { flex: 1 1 220px; max-width: 360px; min-width: 0; padding: 5px 10px; border-radius: 8px;
93
+ border: 1px solid var(--border); background: var(--panel-2); color: var(--text); font-size: 13px; outline: none; }
94
+ #search:focus { border-color: var(--accent); }
95
+ #search + .spacer { flex: 0 1 auto; }
96
+ .search-head { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 12px; margin: 0 0 12px; }
97
+ .search-head label { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; }
98
+ .search-head .grow { flex: 1; }
99
+ .search-head a { color: var(--link); cursor: pointer; }
100
+ .hit { display: block; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px;
101
+ margin: 0 0 8px; cursor: pointer; background: var(--panel); }
102
+ .hit:hover { border-color: var(--accent); }
103
+ .hit .t { color: var(--text); font-weight: 600; }
104
+ .hit .m { color: var(--muted); font-size: 11px; margin-left: 6px; font-weight: 400; }
105
+ .hit .s { color: var(--muted); font-size: 13px; margin-top: 3px; overflow-wrap: anywhere; }
106
+ mark { background: rgba(142, 124, 255, .35); color: inherit; border-radius: 3px; padding: 0 1px; }
107
+ mark.current { background: rgba(255, 196, 0, .55); }
108
+ @media (max-width: 600px) { #search { flex-basis: 120px; } }
109
+ /* Dream outcomes: failed runs need a person; new results are informational. */
110
+ #dreams { flex: none; border-bottom: 1px solid var(--border); background: var(--panel);
111
+ padding: 8px 14px; font-size: 13px; max-height: 38vh; overflow-y: auto; }
112
+ #dreams:empty { display: none; }
113
+ #dreams .row { display: flex; align-items: flex-start; gap: 8px; padding: 6px 0; }
114
+ #dreams .row + .row { border-top: 1px solid var(--border); }
115
+ #dreams .badge { flex: none; width: 8px; height: 8px; border-radius: 4px; margin-top: 6px; }
116
+ #dreams .failed .badge { background: #ff9500; }
117
+ #dreams .ok .badge { background: var(--link); }
118
+ #dreams .body { flex: 1; min-width: 0; }
119
+ #dreams .what { color: var(--text); }
120
+ #dreams .detail { color: var(--muted); font-size: 12px; margin-top: 2px; word-break: break-word; }
121
+ #dreams .pages a { color: var(--link); cursor: pointer; margin-right: 8px; }
122
+ #dreams .actions { flex: none; display: flex; gap: 6px; }
123
+ </style>
124
+ </head>
125
+ <body>
126
+ <div id="app">
127
+ <header>
128
+ <button id="menu" title="页面" aria-label="页面">☰</button>
129
+ <span class="title">记忆</span>
130
+ <input id="search" type="search" placeholder="搜索记忆内容" autocomplete="off" data-testid="memory-search" hidden />
131
+ <span class="spacer"></span>
132
+ <select id="scope" title="记忆范围">
133
+ <option value="auto" selected>全部</option>
134
+ <option value="private">私有</option>
135
+ <option value="global">共享</option>
136
+ </select>
137
+ <button id="refresh" title="刷新">刷新</button>
138
+ </header>
139
+ <div id="dreams" data-testid="memory-dream-alerts"></div>
140
+ <main>
141
+ <nav id="nav">
142
+ <input id="nav-filter" type="search" placeholder="筛选页面" autocomplete="off" data-testid="memory-nav-filter" />
143
+ <div id="nav-list" data-testid="memory-nav"></div>
144
+ </nav>
145
+ <article id="page"><div class="empty">加载中…</div></article>
146
+ <div id="scrim"></div>
147
+ </main>
148
+ </div>
149
+
150
+ <script>
151
+ /* ---------------------------------------------------------------------------
152
+ * window.cteno — the unified plugin-page bridge. The page NEVER touches RPC or
153
+ * sockets directly; it calls cteno.call(method, params) and the host container
154
+ * implements the transport. A container that can inject the bridge BEFORE this
155
+ * script (the CEF pane: the host injects a CDP-binding window.cteno) wins; this
156
+ * inline postMessage transport is the fallback for iframe (desktop) / RN webview
157
+ * (mobile) containers that can't pre-inject. Either way the page only ever uses
158
+ * window.cteno.call — identical contract across all containers.
159
+ * ------------------------------------------------------------------------- */
160
+ if (!window.cteno) (function () {
161
+ var seq = 0;
162
+ var pending = {};
163
+ function post(msg) {
164
+ // Desktop: iframe → parent. Mobile (react-native-webview): ReactNativeWebView.
165
+ if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
166
+ window.ReactNativeWebView.postMessage(JSON.stringify(msg));
167
+ } else if (window.parent && window.parent !== window) {
168
+ window.parent.postMessage(msg, '*');
169
+ }
170
+ }
171
+ window.addEventListener('message', function (ev) {
172
+ var d = ev.data;
173
+ if (typeof d === 'string') { try { d = JSON.parse(d); } catch (e) { return; } }
174
+ if (!d || !d.__ctenoReply || d.id == null) return;
175
+ var p = pending[d.id];
176
+ if (!p) return;
177
+ delete pending[d.id];
178
+ if (d.success) p.resolve(d.data); else p.reject(new Error(d.error || 'bridge error'));
179
+ });
180
+ window.cteno = {
181
+ call: function (method, params) {
182
+ return new Promise(function (resolve, reject) {
183
+ var id = ++seq;
184
+ pending[id] = { resolve: resolve, reject: reject };
185
+ post({ __cteno: true, id: id, method: method, params: params || {} });
186
+ setTimeout(function () {
187
+ if (pending[id]) { delete pending[id]; reject(new Error('bridge timeout: ' + method)); }
188
+ }, 20000);
189
+ });
190
+ }
191
+ };
192
+ })();
193
+
194
+ /* --------------------------- tiny markdown render -------------------------- */
195
+ /* @pure-begin */
196
+ function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]); }); }
197
+ function splitFrontmatter(md) {
198
+ var m = /^---\n([\s\S]*?)\n---\n?/.exec(md);
199
+ if (m) return { fm: m[1], body: md.slice(m[0].length) };
200
+ return { fm: null, body: md };
201
+ }
202
+ function renderInline(s) {
203
+ s = esc(s);
204
+ s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
205
+ s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
206
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_, txt, href) {
207
+ var ext = /^https?:\/\//.test(href);
208
+ return '<a data-href="' + esc(href) + '"' + (ext ? ' data-ext="1"' : '') + '>' + txt + '</a>';
209
+ });
210
+ return s;
211
+ }
212
+ function renderMarkdown(md) {
213
+ var lines = md.split('\n'), out = [], i = 0;
214
+ while (i < lines.length) {
215
+ var ln = lines[i];
216
+ if (/^```/.test(ln)) {
217
+ var buf = []; i++;
218
+ while (i < lines.length && !/^```/.test(lines[i])) { buf.push(esc(lines[i])); i++; }
219
+ i++; out.push('<pre><code>' + buf.join('\n') + '</code></pre>'); continue;
220
+ }
221
+ var h = /^(#{1,6})\s+(.*)$/.exec(ln);
222
+ if (h) { var lvl = h[1].length; out.push('<h' + lvl + '>' + renderInline(h[2]) + '</h' + lvl + '>'); i++; continue; }
223
+ if (/^\s*[-*]\s+/.test(ln)) {
224
+ var items = [];
225
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) { items.push('<li>' + renderInline(lines[i].replace(/^\s*[-*]\s+/, '')) + '</li>'); i++; }
226
+ out.push('<ul>' + items.join('') + '</ul>'); continue;
227
+ }
228
+ if (/^\s*\d+\.\s+/.test(ln)) {
229
+ var oitems = [];
230
+ while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) { oitems.push('<li>' + renderInline(lines[i].replace(/^\s*\d+\.\s+/, '')) + '</li>'); i++; }
231
+ out.push('<ol>' + oitems.join('') + '</ol>'); continue;
232
+ }
233
+ if (ln.trim() === '') { i++; continue; }
234
+ var para = [ln]; i++;
235
+ while (i < lines.length && lines[i].trim() !== '' && !/^(#{1,6}\s|```|\s*[-*]\s|\s*\d+\.\s)/.test(lines[i])) { para.push(lines[i]); i++; }
236
+ out.push('<p>' + renderInline(para.join(' ')) + '</p>');
237
+ }
238
+ return out.join('\n');
239
+ }
240
+
241
+ /* ------------------------- pure view helpers (tested) ----------------------- */
242
+ var SCOPE_LABELS = { 'private': '私有记忆', 'global': '共享记忆' };
243
+ var GROUP_LABELS = {
244
+ knowledge: '知识', logs: '日志', project: '项目', workflow: '流程',
245
+ incidents: '复盘', raw: '文档', docs: '文档', pages: '页面'
246
+ };
247
+ var PAGE_LABELS = { 'index.md': '索引', 'log.md': '变更记录', 'USER.md': '关于我', 'MEMORY.md': '旧版记忆' };
248
+ function groupLabel(g) { return GROUP_LABELS[g] || g; }
249
+ function pageLabel(p) {
250
+ if (PAGE_LABELS[p]) return PAGE_LABELS[p];
251
+ return p.replace(/^.*\//, '').replace(/\.md$/, '');
252
+ }
253
+ /** Bookkeeping pages every scope has; they are not "things remembered". */
254
+ function isBookkeeping(p) { return p === 'index.md' || p === 'log.md' || /^logs\//.test(p); }
255
+ /**
256
+ * Which entries the browser shows. Explicit scope: only that scope. Auto: BOTH
257
+ * scopes — a Jelly with shared memory writes its facts to the global space
258
+ * while its private space still holds index/log bookkeeping, so "private wins"
259
+ * used to hide everything it remembered. Untagged entries belong to the
260
+ * requested scope (older backends).
261
+ */
262
+ function visibleEntries(tagged, scope) {
263
+ var fallback = scope === 'global' ? 'global' : 'private';
264
+ var out = [];
265
+ (tagged || []).forEach(function (e) {
266
+ if (!/\.md$/.test(e.path)) return;
267
+ if (isHiddenPath(e.path)) return;
268
+ var s = e.scope || fallback;
269
+ if (scope !== 'auto' && s !== scope) return;
270
+ out.push({ scope: s, path: e.path });
271
+ });
272
+ out.sort(function (a, b) {
273
+ if (a.scope !== b.scope) return a.scope === 'private' ? -1 : 1;
274
+ return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
275
+ });
276
+ return out;
277
+ }
278
+ /** Landing page: the index of the first scope that actually remembers something. */
279
+ function landingEntry(entries) {
280
+ if (!entries.length) return null;
281
+ var scopes = ['private', 'global'];
282
+ for (var i = 0; i < scopes.length; i++) {
283
+ var s = scopes[i];
284
+ var mine = entries.filter(function (e) { return e.scope === s; });
285
+ if (!mine.some(function (e) { return !isBookkeeping(e.path); })) continue;
286
+ var idx = mine.filter(function (e) { return e.path === 'index.md'; })[0];
287
+ return idx || mine.filter(function (e) { return !isBookkeeping(e.path); })[0];
288
+ }
289
+ return entries.filter(function (e) { return e.path === 'index.md'; })[0] || entries[0];
290
+ }
291
+ /** Nav groups: scope header (only when both scopes show) → path group → pages. */
292
+ function navModel(entries) {
293
+ var scopes = [];
294
+ entries.forEach(function (e) { if (scopes.indexOf(e.scope) < 0) scopes.push(e.scope); });
295
+ var showScope = scopes.length > 1;
296
+ return scopes.map(function (s) {
297
+ var groups = {};
298
+ entries.filter(function (e) { return e.scope === s; }).forEach(function (e) {
299
+ var g = e.path === 'index.md' || e.path === 'log.md' ? '' : (e.path.indexOf('/') >= 0 ? e.path.split('/')[0] : 'pages');
300
+ (groups[g] = groups[g] || []).push(e);
301
+ });
302
+ return {
303
+ scope: s,
304
+ title: showScope ? SCOPE_LABELS[s] : null,
305
+ groups: Object.keys(groups).sort().map(function (g) {
306
+ return { title: g ? groupLabel(g) : null, pages: groups[g].map(function (e) {
307
+ return { scope: e.scope, path: e.path, label: pageLabel(e.path) };
308
+ }) };
309
+ })
310
+ };
311
+ });
312
+ }
313
+ var EMPTY_MEMORY_HTML = '<div class="empty" data-testid="memory-empty">还没有记忆' +
314
+ '<span class="hint">聊天时让我「记住」一件事,它就会出现在这里。</span></div>';
315
+ /**
316
+ * The backend generates index.md with an English heading and bare file-stem
317
+ * link titles ("# Memory Index", "[USER](USER.md)"). Present it in Chinese —
318
+ * view-only: the file on disk is the machine-maintained canonical index and
319
+ * stays untouched. Only the generated index page is rewritten.
320
+ */
321
+ function localizeIndexMarkdown(path, md) {
322
+ if (path !== 'index.md' || typeof md !== 'string') return md;
323
+ return md
324
+ .replace(/^#\s+Memory Index\s*$/m, '# 记忆索引')
325
+ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, function (all, title, href) {
326
+ var file = href.replace(/^\.\//, '');
327
+ var stem = file.replace(/^.*\//, '').replace(/\.md$/, '');
328
+ if (PAGE_LABELS[file] && title === stem) return '[' + PAGE_LABELS[file] + '](' + href + ')';
329
+ return all;
330
+ });
331
+ }
332
+ /**
333
+ * Dot segments are bookkeeping (`.memory-changes/<run>/pre/0.md` = dream
334
+ * journal pre-images). New backends no longer list them; older ones do.
335
+ */
336
+ function isHiddenPath(p) { return /(^|\/)\./.test(p); }
337
+
338
+ /* Lazy loading. A mature wiki has thousands of pages and an index.md of
339
+ * ~2k lines; rendering all of it on open is what made the page stall. */
340
+ var NAV_GROUP_PAGE = 100; // rows rendered per nav group before 「显示更多」
341
+ var PAGE_CHUNK_LINES = 300; // markdown lines rendered per article chunk
342
+
343
+ function navGroupKey(scope, title) { return scope + ':' + (title || ''); }
344
+ /** Case-insensitive match on label or path; empty query keeps everything. */
345
+ function filterEntries(entries, query) {
346
+ var q = String(query || '').trim().toLowerCase();
347
+ if (!q) return entries;
348
+ return entries.filter(function (e) {
349
+ return e.path.toLowerCase().indexOf(q) >= 0 || pageLabel(e.path).toLowerCase().indexOf(q) >= 0;
350
+ });
351
+ }
352
+ /**
353
+ * Nav markup. Groups are collapsed unless open (`ui.open[key]`), the
354
+ * untitled root group (索引/变更记录) or a filter is active; an open group
355
+ * renders at most `ui.shown[key]` (default NAV_GROUP_PAGE) rows plus a
356
+ * 「显示更多」 row. Collapsed groups cost one header line.
357
+ */
358
+ function navHtml(model, ui) {
359
+ var html = '';
360
+ model.forEach(function (sec) {
361
+ if (sec.title) html += '<div class="scope-head">' + esc(sec.title) + '</div>';
362
+ sec.groups.forEach(function (g) {
363
+ var key = navGroupKey(sec.scope, g.title);
364
+ var open = !g.title || ui.filtering || !!(ui.open && ui.open[key]);
365
+ if (g.title) {
366
+ html += '<div class="group toggle" data-group="' + esc(key) + '">' +
367
+ '<span class="caret">' + (open ? '▾' : '▸') + '</span>' + esc(g.title) +
368
+ '<span class="count">' + g.pages.length + '</span></div>';
369
+ }
370
+ if (!open) return;
371
+ var limit = (ui.shown && ui.shown[key]) || NAV_GROUP_PAGE;
372
+ g.pages.slice(0, limit).forEach(function (pg) {
373
+ var active = pg.path === ui.current && (!ui.currentScope || pg.scope === ui.currentScope);
374
+ html += '<a data-path="' + esc(pg.path) + '" data-scope="' + esc(pg.scope) + '"' +
375
+ (active ? ' class="active"' : '') + ' title="' + esc(pg.path) + '">' + esc(pg.label) + '</a>';
376
+ });
377
+ if (g.pages.length > limit) {
378
+ html += '<a class="more" data-more="' + esc(key) + '">显示更多(还有 ' + (g.pages.length - limit) + ' 页)</a>';
379
+ }
380
+ });
381
+ });
382
+ return html;
383
+ }
384
+ /** Nav group key that holds a page, so opening a page opens its group. */
385
+ function groupKeyOf(model, path, scope) {
386
+ for (var i = 0; i < model.length; i++) {
387
+ for (var j = 0; j < model[i].groups.length; j++) {
388
+ var g = model[i].groups[j];
389
+ if (g.pages.some(function (p) { return p.path === path && (!scope || p.scope === scope); })) {
390
+ return navGroupKey(model[i].scope, g.title);
391
+ }
392
+ }
393
+ }
394
+ return null;
395
+ }
396
+ /**
397
+ * One chunk of a page by lines; mirrors the backend `read` paging
398
+ * (`limit_lines`): a boundary inside a ``` fence moves to the fence end, so
399
+ * every chunk renders as valid markdown on its own.
400
+ */
401
+ function pageLines(content, offset, limit) {
402
+ var lines = String(content).split('\n');
403
+ var total = lines.length;
404
+ var start = Math.min(offset, total);
405
+ var end = Math.min(start + Math.max(1, limit), total);
406
+ var fences = 0;
407
+ for (var i = 0; i < end; i++) if (/^\s*```/.test(lines[i])) fences++;
408
+ while (end < total && fences % 2 === 1) { if (/^\s*```/.test(lines[end])) fences++; end++; }
409
+ return { content: lines.slice(start, end).join('\n'), offsetLines: start,
410
+ nextOffsetLines: end < total ? end : null, totalLines: total };
411
+ }
412
+ /** Search terms: whitespace-separated, lowercased, de-duplicated (mirrors the backend AND match). */
413
+ function searchTerms(q) {
414
+ var seen = {}, out = [];
415
+ String(q || '').toLowerCase().split(/\s+/).forEach(function (t) { if (t && !seen[t]) { seen[t] = 1; out.push(t); } });
416
+ return out;
417
+ }
418
+ /** Escaped HTML of `text` with every term wrapped in <mark>; longest term wins on overlap. */
419
+ function markTerms(text, terms) {
420
+ var s = String(text == null ? '' : text);
421
+ if (!terms || !terms.length) return esc(s);
422
+ var lower = s.toLowerCase(), out = '', i = 0;
423
+ var sorted = terms.slice().sort(function (a, b) { return b.length - a.length; });
424
+ while (i < s.length) {
425
+ var hit = null;
426
+ for (var k = 0; k < sorted.length; k++) { if (lower.substr(i, sorted[k].length) === sorted[k]) { hit = sorted[k]; break; } }
427
+ if (hit) { out += '<mark>' + esc(s.substr(i, hit.length)) + '</mark>'; i += hit.length; }
428
+ else { out += esc(s[i]); i++; }
429
+ }
430
+ return out;
431
+ }
432
+ var SEARCH_GROUP_LABELS = { root: '页面', logs: '日志', raw: '文档', knowledge: '知识', workflow: '做事方法', project: '项目', incidents: '经验教训', reference: '参考', feedback: '反馈' };
433
+ /** Search results markup (pure). */
434
+ function searchResultsHtml(res, terms, opts) {
435
+ var results = (res && res.results) || [];
436
+ var head = '<div class="search-head"><span class="grow" data-testid="memory-search-count">' +
437
+ (results.length ? ('找到 ' + res.total + ' 页' + (res.truncated ? ',显示前 ' + results.length + ' 页' : '')) : '没有找到包含「' + esc(terms.join(' ')) + '」的页面') +
438
+ '</span><label><input type="checkbox" id="search-logs"' + (opts && opts.includeLogs ? ' checked' : '') + ' /> 包括日志</label></div>';
439
+ return head + results.map(function (r) {
440
+ var meta = (SEARCH_GROUP_LABELS[r.group] || r.group) + (r.scope === 'global' ? ' · 共享' : '');
441
+ return '<a class="hit" data-testid="memory-search-hit" data-hit-path="' + esc(r.path) + '" data-hit-scope="' + esc(r.scope) + '">' +
442
+ '<div class="t">' + markTerms(r.title, terms) + '<span class="m">' + esc(meta) + '</span></div>' +
443
+ (r.snippet ? '<div class="s">' + markTerms(r.snippet, terms) + '</div>' : '') + '</a>';
444
+ }).join('');
445
+ }
446
+ /* @pure-end */
447
+
448
+ /* ------------------------------- wiki state ------------------------------- */
449
+ var state = { scope: 'auto', files: [], entries: [], current: 'index.md', currentScope: null, error: null,
450
+ filter: '', model: [], navUi: { open: {}, shown: {} }, pagedRead: null, readSeq: 0 };
451
+ var navEl = document.getElementById('nav');
452
+ var navListEl = document.getElementById('nav-list');
453
+ var navFilterEl = document.getElementById('nav-filter');
454
+ var pageEl = document.getElementById('page');
455
+
456
+ function cleanPath(tagged) {
457
+ return tagged.replace(/^\[(private[^\]]*|global)\]\s+/, '');
458
+ }
459
+ function taggedScope(tagged) {
460
+ var match = tagged.match(/^\[(private[^\]]*|global)\]\s+/);
461
+ return match ? (match[1].indexOf('private') === 0 ? 'private' : 'global') : null;
462
+ }
463
+ function resolveLink(fromPath, href) {
464
+ // Resolve a page-relative markdown link to a scope-relative path.
465
+ if (/^https?:\/\//.test(href)) return href;
466
+ var dir = fromPath.indexOf('/') >= 0 ? fromPath.replace(/\/[^/]*$/, '') : '';
467
+ var parts = (dir ? dir.split('/') : []).concat(href.split('/'));
468
+ var stack = [];
469
+ for (var k = 0; k < parts.length; k++) {
470
+ var seg = parts[k];
471
+ if (seg === '.' || seg === '') continue;
472
+ if (seg === '..') stack.pop(); else stack.push(seg);
473
+ }
474
+ return stack.join('/');
475
+ }
476
+
477
+ async function loadFiles() {
478
+ state.error = null;
479
+ try {
480
+ var data = await window.cteno.call('list', { scope: state.scope });
481
+ var tagged = (Array.isArray(data) ? data : []).map(function (entry) {
482
+ return { scope: taggedScope(entry), path: cleanPath(entry) };
483
+ });
484
+ // The core list contract returns project + global pages (agent recall
485
+ // searches both). Keep the scope tag on every entry so two files with the
486
+ // same path in different scopes never mix; Auto shows both scopes.
487
+ state.entries = visibleEntries(tagged, state.scope);
488
+ state.files = state.entries.map(function (e) { return e.path; });
489
+ } catch (e) {
490
+ state.entries = [];
491
+ state.files = [];
492
+ state.error = String(e.message || e);
493
+ }
494
+ rebuildNav();
495
+ }
496
+ /** Docs catch-up runs behind the page, never in front of it. Older daemons may
497
+ * not expose sync_docs; a mismatch must never turn into an empty page. */
498
+ async function syncDocsInBackground() {
499
+ if (state.scope === 'global') return;
500
+ var res;
501
+ try { res = await window.cteno.call('sync_docs', { scope: state.scope }); }
502
+ catch (e) { console.warn('Memory docs catch-up unavailable:', e); return; }
503
+ var changed = res && ['created', 'removed', 'updated'].some(function (k) { return (res[k] || []).length; });
504
+ if (changed) await loadFiles();
505
+ }
506
+ function rebuildNav() {
507
+ state.model = navModel(filterEntries(state.entries, state.filter));
508
+ renderNav();
509
+ }
510
+ function renderNav() {
511
+ if (state.error) { navListEl.innerHTML = '<div class="err">加载失败:' + esc(state.error) + '</div>'; return; }
512
+ if (!state.entries.length) { navListEl.innerHTML = '<div class="group">暂无页面</div>'; return; }
513
+ if (!state.model.length) { navListEl.innerHTML = '<div class="group">没有匹配的页面</div>'; return; }
514
+ navListEl.innerHTML = navHtml(state.model, {
515
+ open: state.navUi.open, shown: state.navUi.shown, filtering: !!state.filter.trim(),
516
+ current: state.current, currentScope: state.currentScope
517
+ });
518
+ }
519
+ /** Move the highlight without rebuilding the nav; open the page's group if needed. */
520
+ function markActive() {
521
+ var key = groupKeyOf(state.model, state.current, state.currentScope);
522
+ if (key && !state.navUi.open[key] && !state.filter.trim()) {
523
+ state.navUi.open[key] = true;
524
+ renderNav();
525
+ return;
526
+ }
527
+ var prev = navListEl.querySelectorAll('a.active');
528
+ for (var i = 0; i < prev.length; i++) prev[i].classList.remove('active');
529
+ var links = navListEl.querySelectorAll('a[data-path]');
530
+ for (var j = 0; j < links.length; j++) {
531
+ var a = links[j];
532
+ if (a.getAttribute('data-path') === state.current &&
533
+ (!state.currentScope || a.getAttribute('data-scope') === state.currentScope)) {
534
+ a.classList.add('active');
535
+ break;
536
+ }
537
+ }
538
+ }
539
+
540
+ /* ---- article: rendered chunk by chunk as the reader scrolls ---------------- */
541
+ var pageObserver = null;
542
+ var capsPromise = null;
543
+ /** Plugin capabilities, probed once per page load; older daemons answer {} or fail. */
544
+ function loadCaps() {
545
+ if (!capsPromise) {
546
+ capsPromise = Promise.resolve().then(function () { return window.cteno.call('capabilities', {}); })
547
+ .then(function (c) { return c || {}; }, function () { return {}; });
548
+ }
549
+ return capsPromise;
550
+ }
551
+ async function hasPagedRead() {
552
+ if (state.pagedRead === null) state.pagedRead = ((await loadCaps()).pagedRead || 0) >= 1;
553
+ return state.pagedRead;
554
+ }
555
+ /** One chunk: from the backend when it pages, else from the whole page read once. */
556
+ async function readChunk(ctx, offset) {
557
+ if (ctx.whole != null) return pageLines(ctx.whole, offset, PAGE_CHUNK_LINES);
558
+ if (await hasPagedRead()) {
559
+ return window.cteno.call('read', { file_path: ctx.path, scope: ctx.scope, offset_lines: offset, limit_lines: PAGE_CHUNK_LINES });
560
+ }
561
+ var content = await window.cteno.call('read', { file_path: ctx.path, scope: ctx.scope });
562
+ if (content == null) return null;
563
+ ctx.whole = String(content);
564
+ return pageLines(ctx.whole, offset, PAGE_CHUNK_LINES);
565
+ }
566
+ function appendChunk(ctx, chunk) {
567
+ var md = chunk.content;
568
+ var html = '';
569
+ if (chunk.offsetLines === 0) {
570
+ var split = splitFrontmatter(md);
571
+ if (split.fm) html += '<div class="frontmatter">' + esc(split.fm) + '</div>';
572
+ md = split.body;
573
+ }
574
+ html += renderMarkdown(localizeIndexMarkdown(ctx.path, md));
575
+ var old = pageEl.querySelector('.sentinel');
576
+ if (old) old.remove();
577
+ pageEl.insertAdjacentHTML('beforeend', html);
578
+ if (ctx.terms) markInPage(ctx.terms);
579
+ ctx.next = chunk.nextOffsetLines;
580
+ if (ctx.next != null) {
581
+ pageEl.insertAdjacentHTML('beforeend', '<div class="sentinel" data-testid="memory-page-more">加载更多…(' +
582
+ ctx.next + ' / ' + chunk.totalLines + ' 行)</div>');
583
+ var s = pageEl.querySelector('.sentinel');
584
+ if (pageObserver) pageObserver.observe(s);
585
+ }
586
+ }
587
+ async function loadMore(ctx) {
588
+ if (ctx.loading || ctx.next == null || ctx.seq !== state.readSeq) return;
589
+ ctx.loading = true;
590
+ try {
591
+ var chunk = await readChunk(ctx, ctx.next);
592
+ if (ctx.seq !== state.readSeq) return;
593
+ if (chunk && typeof chunk === 'object') appendChunk(ctx, chunk);
594
+ } catch (e) {
595
+ var s = pageEl.querySelector('.sentinel');
596
+ if (s) s.textContent = '加载失败,滚动重试:' + String(e.message || e);
597
+ } finally { ctx.loading = false; }
598
+ }
599
+ var currentCtx = null;
600
+ if ('IntersectionObserver' in window) {
601
+ pageObserver = new IntersectionObserver(function (items) {
602
+ items.forEach(function (it) { if (it.isIntersecting && currentCtx) loadMore(currentCtx); });
603
+ }, { root: pageEl, rootMargin: '600px 0px' });
604
+ }
605
+ async function loadPage(path, scopeHint, opts) {
606
+ opts = opts || {};
607
+ if (!opts.terms) state.fromSearch = false;
608
+ state.current = path;
609
+ state.currentScope = scopeHint || (state.scope === 'auto' ? null : state.scope);
610
+ markActive();
611
+ if (!state.entries.length && !state.error) { pageEl.innerHTML = EMPTY_MEMORY_HTML; return; }
612
+ var seq = ++state.readSeq;
613
+ var ctx = { seq: seq, path: path, scope: state.currentScope || undefined, whole: null, next: null, loading: false, terms: opts.terms || null };
614
+ currentCtx = ctx;
615
+ if (pageObserver) pageObserver.disconnect();
616
+ pageEl.innerHTML = '<div class="empty">加载中…</div>';
617
+ try {
618
+ var chunk = await readChunk(ctx, 0);
619
+ if (seq !== state.readSeq) return; // a newer page was opened meanwhile
620
+ if (chunk == null) { pageEl.innerHTML = '<div class="empty">这一页不存在或还没有内容</div>'; return; }
621
+ pageEl.innerHTML = state.fromSearch
622
+ ? '<div class="search-head"><a data-testid="memory-search-back" data-back="1">← 返回搜索结果</a><span class="grow"></span><span data-testid="memory-search-hitcount"></span></div>' : '';
623
+ pageEl.scrollTop = 0;
624
+ appendChunk(ctx, chunk);
625
+ if (!pageEl.textContent.trim()) pageEl.innerHTML = '<div class="empty">这一页还没有内容</div>';
626
+ if (ctx.terms) await seekFirstHit(ctx);
627
+ } catch (e) {
628
+ if (seq !== state.readSeq) return;
629
+ pageEl.innerHTML = '<div class="err">加载失败:' + esc(pageLabel(path)) + '<br>' + esc(String(e.message || e)) + '</div>';
630
+ }
631
+ }
632
+ // One delegated listener for every rendered link (index.md alone has ~2k).
633
+ pageEl.addEventListener('click', function (ev) {
634
+ if (ev.target.closest('[data-back]')) { showSearchResults(); return; }
635
+ var hit = ev.target.closest('[data-hit-path]');
636
+ if (hit) {
637
+ state.fromSearch = true;
638
+ loadPage(hit.getAttribute('data-hit-path'), hit.getAttribute('data-hit-scope'), { terms: searchTerms(state.search.query) });
639
+ return;
640
+ }
641
+ var s = ev.target.closest('.sentinel');
642
+ if (s && currentCtx) { loadMore(currentCtx); return; }
643
+ var a = ev.target.closest('a[data-href]');
644
+ if (!a || a.getAttribute('data-ext')) return; // external: ignore in v1
645
+ var target = resolveLink(state.current, a.getAttribute('data-href'));
646
+ if (!/\.md$/.test(target)) target += '.md';
647
+ loadPage(target, state.currentScope);
648
+ });
649
+
650
+ /* ---- full-text search --------------------------------------------------- */
651
+ var searchEl = document.getElementById('search');
652
+ state.search = { query: '', includeLogs: false, seq: 0, res: null };
653
+ state.fromSearch = false;
654
+ /** Wrap every term occurrence in the rendered page with <mark> (skips code fences already marked). */
655
+ function markInPage(terms) {
656
+ if (!terms || !terms.length) return;
657
+ var walker = document.createTreeWalker(pageEl, NodeFilter.SHOW_TEXT, {
658
+ acceptNode: function (n) {
659
+ if (!n.nodeValue.trim() || (n.parentNode && n.parentNode.closest && n.parentNode.closest('mark,.search-head,.sentinel,.frontmatter'))) return NodeFilter.FILTER_REJECT;
660
+ var low = n.nodeValue.toLowerCase();
661
+ for (var i = 0; i < terms.length; i++) if (low.indexOf(terms[i]) >= 0) return NodeFilter.FILTER_ACCEPT;
662
+ return NodeFilter.FILTER_REJECT;
663
+ }
664
+ });
665
+ var nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode);
666
+ nodes.forEach(function (n) {
667
+ var span = document.createElement('span');
668
+ span.innerHTML = markTerms(n.nodeValue, terms);
669
+ var frag = document.createDocumentFragment();
670
+ while (span.firstChild) frag.appendChild(span.firstChild);
671
+ n.parentNode.replaceChild(frag, n);
672
+ });
673
+ }
674
+ /** Load further chunks until the first hit is rendered (bounded), then scroll to it. */
675
+ async function seekFirstHit(ctx) {
676
+ for (var i = 0; i < 40 && !pageEl.querySelector('mark') && ctx.next != null && ctx.seq === state.readSeq; i++) {
677
+ await loadMore(ctx);
678
+ }
679
+ if (ctx.seq !== state.readSeq) return;
680
+ var marks = pageEl.querySelectorAll('mark');
681
+ var cnt = pageEl.querySelector('[data-testid="memory-search-hitcount"]');
682
+ if (cnt) cnt.textContent = marks.length ? ('本页已显示 ' + marks.length + ' 处') : '命中在页面标题或路径里';
683
+ if (marks.length) { marks[0].classList.add('current'); marks[0].scrollIntoView({ block: 'center' }); }
684
+ }
685
+ function showSearchResults() {
686
+ state.readSeq++; currentCtx = null; if (pageObserver) pageObserver.disconnect();
687
+ var terms = searchTerms(state.search.query);
688
+ pageEl.innerHTML = searchResultsHtml(state.search.res, terms, { includeLogs: state.search.includeLogs });
689
+ pageEl.scrollTop = 0;
690
+ }
691
+ async function runSearch() {
692
+ var q = state.search.query, terms = searchTerms(q);
693
+ var seq = ++state.search.seq;
694
+ if (!terms.length) { state.search.res = null; state.fromSearch = false; loadPage(state.current, state.currentScope); return; }
695
+ state.readSeq++; currentCtx = null;
696
+ pageEl.innerHTML = '<div class="empty">搜索中…</div>';
697
+ try {
698
+ var res = await window.cteno.call('search', { scope: state.scope, query: q, limit: 50, include_logs: state.search.includeLogs });
699
+ if (seq !== state.search.seq) return; // a newer query is in flight
700
+ state.search.res = res || { results: [], total: 0 };
701
+ showSearchResults();
702
+ } catch (e) {
703
+ if (seq !== state.search.seq) return;
704
+ pageEl.innerHTML = '<div class="err">搜索失败:' + esc(String(e.message || e)) + '</div>';
705
+ }
706
+ }
707
+ var searchTimer = null;
708
+ searchEl.addEventListener('input', function () {
709
+ clearTimeout(searchTimer);
710
+ searchTimer = setTimeout(function () { state.search.query = searchEl.value; runSearch(); }, 250);
711
+ });
712
+ searchEl.addEventListener('keydown', function (ev) {
713
+ if (ev.key === 'Escape') { searchEl.value = ''; clearTimeout(searchTimer); state.search.query = ''; runSearch(); }
714
+ if (ev.key === 'Enter') { clearTimeout(searchTimer); state.search.query = searchEl.value; runSearch(); }
715
+ });
716
+ pageEl.addEventListener('change', function (ev) {
717
+ if (ev.target && ev.target.id === 'search-logs') { state.search.includeLogs = ev.target.checked; runSearch(); }
718
+ });
719
+
720
+ var appEl = document.getElementById('app');
721
+ function closeNav() { appEl.classList.remove('nav-open'); }
722
+
723
+ navListEl.addEventListener('click', function (ev) {
724
+ var g = ev.target.closest('[data-group]');
725
+ if (g) { var k = g.getAttribute('data-group'); state.navUi.open[k] = !state.navUi.open[k]; renderNav(); return; }
726
+ var more = ev.target.closest('[data-more]');
727
+ if (more) { var mk = more.getAttribute('data-more'); state.navUi.shown[mk] = ((state.navUi.shown[mk]) || NAV_GROUP_PAGE) + NAV_GROUP_PAGE; renderNav(); return; }
728
+ var a = ev.target.closest('a[data-path]');
729
+ if (a) { loadPage(a.getAttribute('data-path'), a.getAttribute('data-scope')); closeNav(); }
730
+ });
731
+ var filterTimer = null;
732
+ navFilterEl.addEventListener('input', function () {
733
+ clearTimeout(filterTimer);
734
+ filterTimer = setTimeout(function () { state.filter = navFilterEl.value; rebuildNav(); }, 120);
735
+ });
736
+ document.getElementById('menu').addEventListener('click', function () {
737
+ appEl.classList.toggle('nav-open');
738
+ });
739
+ document.getElementById('scrim').addEventListener('click', closeNav);
740
+ document.getElementById('scope').addEventListener('change', function (ev) {
741
+ state.scope = ev.target.value; refresh();
742
+ });
743
+ document.getElementById('refresh').addEventListener('click', refresh);
744
+
745
+ // ---- Dream outcomes -------------------------------------------------------
746
+ var dreamsEl = document.getElementById('dreams');
747
+ function dreamScope() { return state.scope === 'global' ? 'global' : 'private'; }
748
+ function fmtTime(ms) { try { return new Date(ms).toLocaleString(); } catch (e) { return ''; } }
749
+ async function loadDreams() {
750
+ var status;
751
+ try { status = await window.cteno.call('dream_status', { scope: dreamScope() }); }
752
+ catch (e) { dreamsEl.innerHTML = ''; return; } // older backend: no section
753
+ var seenAt = (status && status.seenAtMs) || 0;
754
+ var runs = (status && status.runs) || [];
755
+ var failed = runs.filter(function (r) { return r.status === 'failed' && !r.dismissed; });
756
+ var fresh = runs.filter(function (r) { return r.status === 'ok' && (r.finishedAtMs || 0) > seenAt; });
757
+ var html = '';
758
+ failed.forEach(function (r) {
759
+ html += '<div class="row failed" data-testid="memory-dream-failed">' +
760
+ '<span class="badge"></span><div class="body">' +
761
+ '<div class="what">记忆整理失败 · ' + esc(fmtTime(r.finishedAtMs || r.startedAtMs)) + '</div>' +
762
+ '<div class="detail">' + esc(r.error || '未知错误') + (r.summary ? '<br>' + esc(r.summary) : '') + '</div>' +
763
+ '</div><div class="actions">' +
764
+ (r.hasSession ? '<button data-open="' + esc(r.runId) + '" data-testid="memory-dream-open">进入对话</button>' : '') +
765
+ '<button data-dismiss="' + esc(r.runId) + '" data-testid="memory-dream-dismiss">忽略</button>' +
766
+ '</div></div>';
767
+ });
768
+ fresh.forEach(function (r) {
769
+ var pages = (r.pages || []).map(function (p) {
770
+ return '<a data-page="' + esc(p) + '">' + esc(p) + '</a>';
771
+ }).join('');
772
+ html += '<div class="row ok" data-testid="memory-dream-new">' +
773
+ '<span class="badge"></span><div class="body">' +
774
+ '<div class="what">新的记忆整理结果 · ' + esc(fmtTime(r.finishedAtMs)) + '</div>' +
775
+ (r.summary ? '<div class="detail">' + esc(r.summary) + '</div>' : '') +
776
+ (pages ? '<div class="detail pages">' + pages + '</div>' : '') +
777
+ '</div><div class="actions">' +
778
+ (r.hasSession ? '<button data-open="' + esc(r.runId) + '">查看对话</button>' : '') +
779
+ '</div></div>';
780
+ });
781
+ dreamsEl.innerHTML = html;
782
+ // Opening the page counts as seeing new results; failures stay until
783
+ // dismissed or fixed (a later ok report under the same session).
784
+ if (fresh.length) {
785
+ try { await window.cteno.call('dream_mark_seen', { scope: dreamScope() }); } catch (e) {}
786
+ }
787
+ }
788
+ dreamsEl.addEventListener('click', async function (ev) {
789
+ var t = ev.target.closest('[data-open],[data-dismiss],[data-page]');
790
+ if (!t) return;
791
+ if (t.hasAttribute('data-page')) { loadPage(t.getAttribute('data-page')); return; }
792
+ if (t.hasAttribute('data-dismiss')) {
793
+ try { await window.cteno.call('dream_dismiss', { scope: dreamScope(), run_id: t.getAttribute('data-dismiss') }); }
794
+ catch (e) { console.warn('dismiss failed', e); }
795
+ loadDreams();
796
+ return;
797
+ }
798
+ try {
799
+ var res = await window.cteno.call('platform.session.open', { session_id: t.getAttribute('data-open') });
800
+ // The CEF pane cannot reach the app router: the host hands back the
801
+ // pane's own conversation URL and the page navigates there.
802
+ if (res && res.url) window.location.href = res.url;
803
+ } catch (e) { alert('无法打开对话:' + (e.message || e)); }
804
+ });
805
+
806
+ async function refresh() {
807
+ loadDreams();
808
+ var caps = loadCaps(); // in flight alongside list: no extra round trip
809
+ await loadFiles();
810
+ if (((await caps).search || 0) >= 1) searchEl.hidden = false;
811
+ await hasPagedRead();
812
+ var landing = landingEntry(state.entries);
813
+ var opened = loadPage(landing ? landing.path : 'index.md', landing ? landing.scope : null);
814
+ syncDocsInBackground();
815
+ await opened;
816
+ // A scope switch / refresh keeps an active search: re-run it in the new scope.
817
+ if (searchTerms(state.search.query).length) runSearch();
818
+ }
819
+ refresh();
820
+ </script>
821
+ </body>
822
+ </html>