eklavya 1.25.3 → 1.27.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 (46) hide show
  1. package/dist/artifacts.js +214 -0
  2. package/dist/artifacts.js.map +1 -0
  3. package/dist/assets/artifact-template.html +144 -0
  4. package/dist/assets/dashboard.html +119 -2
  5. package/dist/assets/tutor/SKILL.md +7 -0
  6. package/dist/cli-memory.js +6 -3
  7. package/dist/cli-memory.js.map +1 -1
  8. package/dist/cli.js +185 -5
  9. package/dist/cli.js.map +1 -1
  10. package/dist/config.js +10 -2
  11. package/dist/config.js.map +1 -1
  12. package/dist/dashboard.js +41 -1
  13. package/dist/dashboard.js.map +1 -1
  14. package/dist/hooks/memory-lib.js +1 -1
  15. package/dist/hooks/memory-lib.js.map +1 -1
  16. package/dist/hooks/session-start.js +34 -3
  17. package/dist/hooks/session-start.js.map +1 -1
  18. package/dist/install-lock.js +155 -0
  19. package/dist/install-lock.js.map +1 -0
  20. package/dist/install.js +82 -60
  21. package/dist/install.js.map +1 -1
  22. package/dist/memory/reservation.js +15 -5
  23. package/dist/memory/reservation.js.map +1 -1
  24. package/dist/memory/worker.js +72 -31
  25. package/dist/memory/worker.js.map +1 -1
  26. package/dist/migrations/015_event_link_indexes.sql +24 -0
  27. package/dist/onboard.js +29 -15
  28. package/dist/onboard.js.map +1 -1
  29. package/dist/paths.js +8 -0
  30. package/dist/paths.js.map +1 -1
  31. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  32. package/dist/plugin/agents/explainer.md +55 -0
  33. package/dist/plugin/agents/tutor.md +7 -0
  34. package/dist/plugin/hooks/CLAUDE.md +15 -3
  35. package/dist/plugin/hooks/run.mjs +104 -21
  36. package/dist/plugin/skills/CLAUDE.md +7 -0
  37. package/dist/plugin/skills/tutor/SKILL.md +7 -0
  38. package/dist/tools/config_tools.js +8 -0
  39. package/dist/tools/config_tools.js.map +1 -1
  40. package/dist/tools/record_attempt.js +25 -1
  41. package/dist/tools/record_attempt.js.map +1 -1
  42. package/dist/update.js +284 -0
  43. package/dist/update.js.map +1 -0
  44. package/dist/user-skill/eklavya/SKILL.md +18 -1
  45. package/dist/user-skill/eklavya-artifacts/SKILL.md +116 -0
  46. package/package.json +1 -1
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Artifacts: self-contained HTML pages an agent writes to explain something —
3
+ * a concept the developer got wrong, a design, a report — kept under
4
+ * `~/.eklavya/artifacts/<project slug>/`.
5
+ *
6
+ * Three rules shape this file.
7
+ *
8
+ * **The files are the index.** There is no table. `listArtifacts` reads each
9
+ * page's `<head>` for the metadata `createArtifact` stamped into it, so a page
10
+ * deleted, renamed or copied in by hand is reflected on the next read, and
11
+ * there is nothing to fall out of step with the disk. The ceiling is a scan of
12
+ * every file per dashboard load; `HEAD_BYTES` keeps each read small, and a few
13
+ * thousand pages is still milliseconds.
14
+ *
15
+ * **The design tokens are inlined at creation, not copied into the template.**
16
+ * `tokens.css` is the one palette the site and the dashboard share; a second
17
+ * hand-kept copy inside a template is the copy nobody updates. Inlining makes
18
+ * the page self-contained — its "download HTML" button hands over a file that
19
+ * still themes and prints — without a second source.
20
+ *
21
+ * **Every path in is untrusted.** `resolveArtifact` is what the dashboard
22
+ * serves through, and it refuses anything that does not land on an `.html`
23
+ * file directly inside one project folder.
24
+ */
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { findRepoConfig, mainRepoRoot } from './config.js';
29
+ import { artifactsDir, projectSlug } from './paths.js';
30
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
31
+ /** Enough of a file to hold its `<head>`: the metadata is written first. */
32
+ const HEAD_BYTES = 16 * 1024;
33
+ /** What a page is filed under: the main checkout behind a worktree, else the directory itself. */
34
+ export function artifactProject(cwd = process.cwd()) {
35
+ const { repoRoot } = findRepoConfig(cwd);
36
+ if (repoRoot)
37
+ return mainRepoRoot(repoRoot);
38
+ try {
39
+ return fs.realpathSync(path.resolve(cwd));
40
+ }
41
+ catch {
42
+ return path.resolve(cwd);
43
+ }
44
+ }
45
+ export function kebab(title) {
46
+ const s = title
47
+ .normalize('NFKD')
48
+ .replace(/[̀-ͯ]/g, '')
49
+ .toLowerCase()
50
+ .replace(/[^a-z0-9]+/g, '-')
51
+ .replace(/^-+|-+$/g, '')
52
+ .slice(0, 60)
53
+ .replace(/-+$/, '');
54
+ return s || 'artifact';
55
+ }
56
+ const escHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
57
+ function assetPath(name) {
58
+ return path.join(moduleDir, 'assets', name);
59
+ }
60
+ /** The shared tokens, minus the remote font import (the template links the fonts itself). */
61
+ function inlineTokens() {
62
+ try {
63
+ const css = fs.readFileSync(assetPath('tokens.css'), 'utf8');
64
+ return css.replace(/@import\s+url\([^)]*\)[^;]*;\s*/gi, '');
65
+ }
66
+ catch {
67
+ // A build without the tokens still yields a readable page: the template's
68
+ // own fallbacks cover the roles it names.
69
+ return '';
70
+ }
71
+ }
72
+ /**
73
+ * Writes the template, filled in, to a fresh file and returns its path. Never
74
+ * overwrites: a second page with the same title on the same day gets `-2`.
75
+ */
76
+ export function createArtifact(opts) {
77
+ const title = opts.title.trim();
78
+ if (!title)
79
+ throw new Error('an artifact needs a title');
80
+ const now = opts.now ?? new Date();
81
+ const project = artifactProject(opts.cwd);
82
+ const folder = projectSlug(project);
83
+ const dir = path.join(artifactsDir(), folder);
84
+ fs.mkdirSync(dir, { recursive: true });
85
+ // The local date, not UTC: the filename is for the person reading their folder.
86
+ const pad = (n) => String(n).padStart(2, '0');
87
+ const day = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
88
+ const stem = `${day}-${kebab(title)}`;
89
+ let file = `${stem}.html`;
90
+ for (let n = 2; fs.existsSync(path.join(dir, file)); n++)
91
+ file = `${stem}-${n}.html`;
92
+ const description = (opts.description ?? '').trim();
93
+ const kind = opts.kind ?? 'artifact';
94
+ const fill = {
95
+ '{{TITLE}}': escHtml(title),
96
+ '{{DESCRIPTION}}': escHtml(description),
97
+ '{{PROJECT}}': escHtml(project),
98
+ '{{PROJECT_NAME}}': escHtml(path.basename(project) || project),
99
+ '{{KIND}}': kind,
100
+ '{{CONCEPT}}': escHtml(opts.concept ?? ''),
101
+ '{{CREATED}}': now.toISOString(),
102
+ '{{EYEBROW}}': kind === 'explainer' ? 'Explainer' : 'Artifact',
103
+ };
104
+ let html = fs.readFileSync(assetPath('artifact-template.html'), 'utf8');
105
+ // Tokens first: the CSS contains no placeholders, but user text could.
106
+ html = html.replace('/*{{TOKENS}}*/', () => inlineTokens());
107
+ html = html.replace(/\{\{[A-Z_]+\}\}/g, (m) => fill[m] ?? m);
108
+ const target = path.join(dir, file);
109
+ fs.writeFileSync(target, html, { flag: 'wx' });
110
+ return { path: target, id: `${folder}/${file}`, project };
111
+ }
112
+ function meta(head, name) {
113
+ const re = new RegExp(`<meta\\s+name="${name}"\\s+content="([^"]*)"`, 'i');
114
+ const m = re.exec(head);
115
+ return m ? unescHtml(m[1]) : null;
116
+ }
117
+ function unescHtml(s) {
118
+ return s.replace(/&(amp|lt|gt|quot|#39);/g, (_, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" })[e]);
119
+ }
120
+ function readHead(file) {
121
+ const fd = fs.openSync(file, 'r');
122
+ try {
123
+ const buf = Buffer.alloc(HEAD_BYTES);
124
+ const n = fs.readSync(fd, buf, 0, HEAD_BYTES, 0);
125
+ return buf.subarray(0, n).toString('utf8');
126
+ }
127
+ finally {
128
+ fs.closeSync(fd);
129
+ }
130
+ }
131
+ /**
132
+ * Every artifact on disk, newest first. A page with no Eklavya metadata — one
133
+ * copied in by hand — is still listed, titled from its `<title>` or filename.
134
+ * Never throws: an unreadable file costs that one row.
135
+ */
136
+ export function listArtifacts(root = artifactsDir()) {
137
+ const rows = [];
138
+ let folders;
139
+ try {
140
+ folders = fs.readdirSync(root, { withFileTypes: true });
141
+ }
142
+ catch {
143
+ return rows;
144
+ }
145
+ for (const folder of folders) {
146
+ // The same rules `resolveArtifact` serves by, so nothing is listed that
147
+ // the dashboard would then refuse: no dot-names, no symlinks — a link's
148
+ // `<head>` would otherwise be read from wherever it points.
149
+ if (!folder.isDirectory() || folder.name.startsWith('.'))
150
+ continue;
151
+ let files;
152
+ try {
153
+ files = fs.readdirSync(path.join(root, folder.name));
154
+ }
155
+ catch {
156
+ continue;
157
+ }
158
+ for (const name of files) {
159
+ if (!/\.html?$/i.test(name) || name.startsWith('.'))
160
+ continue;
161
+ const file = path.join(root, folder.name, name);
162
+ try {
163
+ const st = fs.lstatSync(file);
164
+ if (!st.isFile())
165
+ continue;
166
+ const head = readHead(file);
167
+ const title = /<title>([^<]*)<\/title>/i.exec(head)?.[1]?.trim();
168
+ const kind = meta(head, 'eklavya:kind');
169
+ rows.push({
170
+ id: `${folder.name}/${name}`,
171
+ title: title ? unescHtml(title) : name.replace(/\.html?$/i, ''),
172
+ description: meta(head, 'description') ?? '',
173
+ project: meta(head, 'eklavya:project') || null,
174
+ kind: kind === 'explainer' ? 'explainer' : 'artifact',
175
+ concept: meta(head, 'eklavya:concept') || null,
176
+ created: meta(head, 'eklavya:created') || st.mtime.toISOString(),
177
+ bytes: st.size,
178
+ });
179
+ }
180
+ catch {
181
+ continue;
182
+ }
183
+ }
184
+ }
185
+ return rows.sort((a, b) => (a.created < b.created ? 1 : a.created > b.created ? -1 : 0));
186
+ }
187
+ /**
188
+ * The file behind an id, or null. Exactly `<folder>/<file>.html`, both plain
189
+ * names — no separators, no `..`, no leading dot — and the resolved path must
190
+ * still sit inside the root after symlinks, so a link planted in a project
191
+ * folder cannot serve a file from elsewhere on the machine.
192
+ */
193
+ export function resolveArtifact(id, root = artifactsDir()) {
194
+ const parts = id.split('/');
195
+ if (parts.length !== 2)
196
+ return null;
197
+ const plain = /^[^/\\\0]+$/;
198
+ for (const p of parts)
199
+ if (!plain.test(p) || p.startsWith('.'))
200
+ return null;
201
+ if (!/\.html?$/i.test(parts[1]))
202
+ return null;
203
+ try {
204
+ const realRoot = fs.realpathSync(root);
205
+ const real = fs.realpathSync(path.join(root, parts[0], parts[1]));
206
+ if (path.dirname(path.dirname(real)) !== realRoot)
207
+ return null;
208
+ return fs.statSync(real).isFile() ? real : null;
209
+ }
210
+ catch {
211
+ return null;
212
+ }
213
+ }
214
+ //# sourceMappingURL=artifacts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"artifacts.js","sourceRoot":"","sources":["../src/artifacts.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,144 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{{TITLE}}</title>
7
+ <meta name="description" content="{{DESCRIPTION}}">
8
+ <meta name="eklavya:project" content="{{PROJECT}}">
9
+ <meta name="eklavya:kind" content="{{KIND}}">
10
+ <meta name="eklavya:concept" content="{{CONCEPT}}">
11
+ <meta name="eklavya:created" content="{{CREATED}}">
12
+ <!-- Eklavya type. Offline, the stacks fall back to system faces; nothing breaks. -->
13
+ <link rel="preconnect" href="https://fonts.googleapis.com">
14
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
15
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;800;900&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap">
16
+ <style>
17
+ /* Eklavya design tokens, inlined from web/public/tokens.css when this page was
18
+ created. Ink (dark) is the default on screen and in the PDF; data-mode="paper"
19
+ on <html> switches to the paper ground. Components below name ROLES only. */
20
+ /*{{TOKENS}}*/
21
+ </style>
22
+ <style>
23
+ :root{ --ok:var(--spot); --maxw:880px; }
24
+
25
+ *{box-sizing:border-box}
26
+ body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.65 var(--font-body)}
27
+ ::selection{background:var(--spot);color:var(--spot-ink)}
28
+ main{max-width:var(--maxw);margin:0 auto;padding:64px 16px 96px}
29
+
30
+ .eyebrow{margin:0 0 14px;font:400 11px/1 var(--font-mono);letter-spacing:var(--tracking-caps-wide);
31
+ text-transform:uppercase;color:var(--spot)}
32
+ h1{font:900 clamp(34px,5.4vw,64px)/.95 var(--font-disp);letter-spacing:var(--tracking-display);margin:0 0 .35em}
33
+ h2{font:800 clamp(24px,3vw,34px)/1 var(--font-disp);letter-spacing:var(--tracking-tight);
34
+ margin:2.4em 0 .7em;padding-top:1em;border-top:1px solid var(--line-2)}
35
+ h3{font:800 18px/1.3 var(--font-disp);letter-spacing:var(--tracking-snug);margin:1.8em 0 .4em}
36
+ p,li{color:var(--ink)}
37
+ .lede{color:var(--dim);font-size:17px;max-width:620px;margin:0 0 2.4em}
38
+ .muted{color:var(--dim)}
39
+ a{color:var(--spot);text-decoration:none;border-bottom:1px solid var(--spot)}
40
+ strong{font-weight:600}
41
+
42
+ code{font-family:var(--font-mono);font-size:.88em;background:var(--mass);padding:.1em .38em}
43
+ pre{background:var(--panel);border:1px solid var(--line-2);padding:14px 16px;overflow:auto;font-size:13px}
44
+ pre code{background:none;padding:0}
45
+
46
+ table{width:100%;border-collapse:collapse;margin:1.2em 0;font-size:14px}
47
+ th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line-2);vertical-align:top}
48
+ th{font:400 11px var(--font-mono);letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--faint)}
49
+ td.num,th.num{text-align:right;font-family:var(--font-mono);font-variant-numeric:tabular-nums}
50
+ blockquote{margin:1.2em 0;padding:.2em 0 .2em 16px;border-left:2px solid var(--spot);color:var(--dim)}
51
+
52
+ .card{background:var(--panel);border:1px solid var(--line);padding:24px}
53
+ .card h3{margin-top:0}
54
+ .card p{color:var(--dim);font-size:14.5px;margin:0}
55
+ .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin:1.4em 0}
56
+ .chip{display:inline-block;font:12px var(--font-mono);color:var(--spot);background:var(--spot-soft);
57
+ border:1px solid var(--spot);padding:2px 9px}
58
+ .chip.warn{color:var(--warning);border-color:var(--warning);background:color-mix(in srgb,var(--warning) 12%,transparent)}
59
+ .chip.bad{color:var(--error);border-color:var(--error);background:color-mix(in srgb,var(--error) 12%,transparent)}
60
+ .stat{border:1px solid var(--line-2);padding:18px 20px}
61
+ .stat .k{font:400 11px var(--font-mono);letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--faint)}
62
+ .stat .v{font:900 40px/1.1 var(--font-disp);letter-spacing:var(--tracking-tight);margin-top:6px}
63
+ .stat .d{color:var(--dim);font-size:13px}
64
+ .callout{border:1px solid var(--spot);background:var(--spot-soft);padding:14px 18px;margin:1.4em 0}
65
+
66
+ figure{margin:1.8em 0;background:var(--panel);border:1px solid var(--line-2);
67
+ padding:20px;text-align:center;break-inside:avoid}
68
+ figure svg{max-width:100%;height:auto}
69
+ figcaption{margin-top:12px;color:var(--dim);font-size:13px;text-align:center}
70
+ .svg-ink{fill:var(--ink)} .svg-muted{fill:var(--dim)}
71
+ .svg-box{fill:var(--mass);stroke:var(--line-2);stroke-width:1}
72
+ .svg-box-spot{fill:var(--spot-soft);stroke:var(--spot);stroke-width:1.5}
73
+ .svg-line{stroke:var(--faint);stroke-width:1.5;fill:none}
74
+ .svg-line-spot{stroke:var(--spot);stroke-width:1.5;fill:none}
75
+ .svg-label{font:500 13px var(--font-body);fill:var(--ink)}
76
+ .svg-small{font:11px var(--font-mono);fill:var(--dim)}
77
+
78
+ .actions{position:fixed;top:16px;right:16px;z-index:50;display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}
79
+ .actions button{display:inline-flex;gap:7px;align-items:center;padding:8px 14px;
80
+ border:1px solid var(--line-2);border-radius:0;background:var(--panel);color:var(--ink);
81
+ font:500 12px var(--font-mono);letter-spacing:.06em;cursor:pointer}
82
+ .actions button:hover{border-color:var(--spot);color:var(--spot)}
83
+ .actions button:focus-visible{outline:none;box-shadow:0 0 0 2px var(--bg),0 0 0 4px var(--spot)}
84
+
85
+ @media screen{
86
+ body{background-image:linear-gradient(var(--grid) 1px,transparent 1px),
87
+ linear-gradient(90deg,var(--grid) 1px,transparent 1px);background-size:72px 72px}
88
+ main{background:var(--bg);border-left:1px solid var(--line-2);border-right:1px solid var(--line-2);
89
+ padding-left:40px;padding-right:40px;min-height:100vh}
90
+ }
91
+ @media (max-width:640px){ main{padding:76px 16px 64px!important} .actions{top:12px;right:12px} }
92
+
93
+ @media print{
94
+ /* Dark in the PDF too. The @page margin is canvas and prints white, so the
95
+ page has none, a fixed layer covers the sheet, and the gutter lives on
96
+ <main>, repeated on every page fragment by box-decoration-break. */
97
+ *{-webkit-print-color-adjust:exact;print-color-adjust:exact}
98
+ html,body{background:var(--bg);color:var(--ink)}
99
+ @page{margin:0}
100
+ body::before{content:"";position:fixed;inset:0;background:var(--bg);z-index:-1}
101
+ .actions{display:none}
102
+ body{font-size:11pt}
103
+ main{padding:12mm 16mm;max-width:none;-webkit-box-decoration-break:clone;box-decoration-break:clone}
104
+ h1,h2,h3{break-after:avoid;break-inside:avoid}
105
+ p,li{orphans:3;widows:3}
106
+ /* Anything that can outgrow a page must be allowed to split. */
107
+ table,pre,section,div{break-inside:auto}
108
+ thead{display:table-header-group}
109
+ tfoot{display:table-footer-group}
110
+ tr,td,th,li,.card,.stat{break-inside:avoid}
111
+ figure,blockquote{break-inside:avoid}
112
+ figure svg,figure img{max-height:170mm}
113
+ a{color:inherit;border:0}
114
+ }
115
+ </style>
116
+ </head>
117
+ <body>
118
+ <div class="actions">
119
+ <button type="button" onclick="window.print()" aria-label="Download this page as PDF">PDF</button>
120
+ <button type="button" onclick="saveHtml()" aria-label="Download this page as an HTML file">HTML</button>
121
+ </div>
122
+ <script>
123
+ function saveHtml(){
124
+ var name=(location.pathname.split('/').pop()||'').replace(/\.html?$/,'')||'artifact';
125
+ var blob=new Blob(['<!DOCTYPE html>\n'+document.documentElement.outerHTML],{type:'text/html'});
126
+ var a=document.createElement('a');
127
+ a.href=URL.createObjectURL(blob); a.download=name+'.html';
128
+ document.body.appendChild(a); a.click(); a.remove();
129
+ setTimeout(function(){URL.revokeObjectURL(a.href)},1000);
130
+ }
131
+ </script>
132
+
133
+ <main>
134
+ <p class="eyebrow">{{EYEBROW}} · {{PROJECT_NAME}}</p>
135
+ <h1>{{TITLE}}</h1>
136
+ <p class="lede">{{DESCRIPTION}}</p>
137
+
138
+ <!-- CONTENT: replace this comment with the page. Sections under <h2>, a
139
+ <figure> with an inline SVG (the .svg-* classes) for every sequence,
140
+ branch or before/after, a <figcaption> that states the takeaway. -->
141
+
142
+ </main>
143
+ </body>
144
+ </html>
@@ -566,6 +566,7 @@ const T = {
566
566
  session: { page:1 },
567
567
  memory: { page:1, q:'', tag:'all', since:'', until:'' },
568
568
  reuse: { page:1, only:'all' },
569
+ artifacts:{ page:1, q:'' },
569
570
  };
570
571
  const PER = 20;
571
572
 
@@ -2162,8 +2163,8 @@ function viewHealth() {
2162
2163
  /* ============================================================
2163
2164
  Workflows and routes
2164
2165
 
2165
- Two workflows, one shell. The registry below is the only description of
2166
- either: the sidebar, the picker, the route resolver and the page renderer all
2166
+ Three workflows, one shell. The registry below is the only description of
2167
+ any of them: the sidebar, the picker, the route resolver and the page renderer all
2167
2168
  read it, so the selector, the highlighted item and the screen cannot disagree
2168
2169
  about where the reader is.
2169
2170
 
@@ -2171,7 +2172,106 @@ function viewHealth() {
2171
2172
  workflow, the page, the highlighted item and the project scope, so a refresh,
2172
2173
  a pasted link or Back/Forward rebuilds the same screen from nothing.
2173
2174
  ============================================================ */
2175
+ /* ============================================================
2176
+ Artifacts — the pages an agent wrote under ~/.eklavya/artifacts/.
2177
+ `S.artifacts` is read from the files on every load; opening one is a plain
2178
+ link to `/artifacts/<id>`, which the server serves sandboxed, in a new tab.
2179
+ ============================================================ */
2180
+ const ARTIFACT_KINDS = ['all', 'explainer', 'artifact'];
2181
+ const artifactsInScope = () => (S.artifacts ?? []).filter((a) => inScope(a.project));
2182
+ const artifactUrl = (a) => '/artifacts/' + a.id.split('/').map(encodeURIComponent).join('/');
2183
+
2184
+ function artifactRow(a) {
2185
+ const known = a.concept && S.concepts.some((c) => c.slug === a.concept);
2186
+ const concept = !a.concept ? '<span class="slug">—</span>'
2187
+ : known ? `<a class="link slug" data-go="${esc(LH('concept', a.concept))}">${esc(a.concept)}</a>`
2188
+ : `<span class="slug">${esc(a.concept)}</span>`;
2189
+ return `<tr>
2190
+ <td style="min-width:260px"><a class="link title" href="${esc(artifactUrl(a))}" target="_blank" rel="noopener">${esc(a.title)}</a>
2191
+ ${a.description ? `<div class="ctx">${esc(a.description)}</div>` : ''}</td>
2192
+ <td><span class="pill">${esc(a.kind)}</span></td>
2193
+ <td>${concept}</td>
2194
+ <td class="num slug" style="white-space:nowrap">${esc(stamp(a.created))}</td></tr>`;
2195
+ }
2196
+
2197
+ function viewArtifacts(param) {
2198
+ const t = T.artifacts;
2199
+ const kind = ARTIFACT_KINDS.includes(param) ? param : 'all';
2200
+ const all = artifactsInScope();
2201
+ const q = t.q.trim().toLowerCase();
2202
+ const list = all.filter((a) => {
2203
+ if (kind !== 'all' && a.kind !== kind) return false;
2204
+ if (q && ![a.title, a.description, a.concept ?? '', pname(a.project), a.id].join(' ').toLowerCase().includes(q)) return false;
2205
+ return true;
2206
+ });
2207
+ const p = page('artifacts', list.length);
2208
+ // The page's slice, grouped by project in the order it arrives (newest first).
2209
+ const groups = new Map();
2210
+ for (const a of slice(list, p)) {
2211
+ const id = pid(a.project);
2212
+ if (!groups.has(id)) groups.set(id, []);
2213
+ groups.get(id).push(a);
2214
+ }
2215
+ const counts = { all: all.length, explainer: all.filter((a) => a.kind === 'explainer').length };
2216
+ counts.artifact = all.length - counts.explainer;
2217
+ const chip = (k, label) => `<button class="chip" data-go="${esc(AH('dashboard', k === 'all' ? null : k))}" aria-pressed="${kind === k}">${label} ${counts[k]}</button>`;
2218
+ const cols = [{t:'Page'},{t:'Kind'},{t:'Concept'},{t:'Written',n:1}];
2219
+ const body = !list.length
2220
+ ? `<p class="empty">${all.length ? 'Nothing matches that search.' : 'No artifacts yet. Ask for an Eklavya artifact, or turn on explain_on_wrong, and they land here.'}</p>`
2221
+ : [...groups].map(([id, rows]) =>
2222
+ `<h3 class="slug" style="margin:var(--space-5) 0 var(--space-2)">${esc(projectById(id)?.name ?? (id === '~' ? 'no project' : base(id)))}</h3>`
2223
+ + table(cols, rows.map(artifactRow))).join('') + pager('artifacts', list.length, p);
2224
+ return [
2225
+ `<div>
2226
+ <h1 class="page__title">Artifacts</h1>
2227
+ <p class="page__sub">Pages your agent wrote for you — explainers after a missed question, and anything you asked
2228
+ to have turned into a page. Each opens in a new tab, from <code>~/.eklavya/artifacts/</code>.</p>
2229
+ </div>`,
2230
+ tiles([
2231
+ ['Artifacts', all.length, 'in scope'],
2232
+ ['Explainers', counts.explainer, 'written after a question', null, counts.explainer ? AH('dashboard', 'explainer') : null],
2233
+ ['Projects', new Set(all.map((a) => pid(a.project))).size, 'with at least one page', null, AH('projects')],
2234
+ ]),
2235
+ card(null, null,
2236
+ `<div class="filters">
2237
+ <div class="grow"><input type="search" id="aq" value="${esc(t.q)}" placeholder="Search title, description, concept or project…" aria-label="Search artifacts"></div>
2238
+ </div>
2239
+ <div class="chips">${chip('all','All')}${chip('explainer','Explainers')}${chip('artifact','Other pages')}</div>
2240
+ <div style="margin-top:var(--space-4)">${body}</div>`),
2241
+ ].join('');
2242
+ }
2243
+
2244
+ function viewArtifactProjects() {
2245
+ const by = new Map();
2246
+ for (const a of artifactsInScope()) {
2247
+ const id = pid(a.project);
2248
+ const r = by.get(id) ?? { id, n: 0, explainers: 0, last: a.created };
2249
+ r.n += 1; if (a.kind === 'explainer') r.explainers += 1;
2250
+ if (a.created > r.last) r.last = a.created;
2251
+ by.set(id, r);
2252
+ }
2253
+ // ponytail: a project the inventory has never seen (a directory with no
2254
+ // Eklavya activity) cannot be a scope, so its row is not a link. Upgrade:
2255
+ // teach `projectInventory` to count artifact projects.
2256
+ const rows = [...by.values()].sort((a, b) => (a.last < b.last ? 1 : -1)).map((r) => {
2257
+ const p = projectById(r.id);
2258
+ return `<tr${p ? ` class="row" data-go="${esc(AH('dashboard', null, { project: r.id }))}"` : ''}>
2259
+ <td style="min-width:220px"><span class="title">${esc(p?.name ?? base(r.id))}</span><div class="slug">${esc(r.id)}</div></td>
2260
+ <td class="num">${r.n}</td><td class="num">${r.explainers || '—'}</td>
2261
+ <td class="num slug">${esc(ago(r.last))}</td></tr>`;
2262
+ });
2263
+ return [
2264
+ `<div>
2265
+ <h1 class="page__title">Projects</h1>
2266
+ <p class="page__sub">Artifacts are filed by the project they were written in — the main checkout, for a worktree.
2267
+ Open one to see only its pages.</p>
2268
+ </div>`,
2269
+ card(null, null, table([{t:'Project'},{t:'Pages',n:1},{t:'Explainers',n:1},{t:'Last written',n:1}], rows)),
2270
+ ].join('');
2271
+ }
2272
+
2174
2273
  const ICONS = {
2274
+ artifacts: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5"/><path d="M9 13h7M9 17h5"/>',
2175
2275
  learning: '<path d="M4 19V6a2 2 0 0 1 2-2h13v13H6a2 2 0 0 0-2 2z"/><path d="M6 21h13v-4"/><path d="M9 8h6"/>',
2176
2276
  memory: '<ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6"/><path d="M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/>',
2177
2277
  dashboard: '<path d="M3 12l9-9 9 9"/><path d="M5 10v10h14V10"/>',
@@ -2230,6 +2330,16 @@ const WORKFLOWS = {
2230
2330
  health: { label: 'Health', view: () => viewHealth() },
2231
2331
  },
2232
2332
  },
2333
+ artifacts: {
2334
+ name: 'Artifacts',
2335
+ groups: [
2336
+ { id: 'library', label: 'Library', items: ['projects'] },
2337
+ ],
2338
+ pages: {
2339
+ dashboard: { label: 'Dashboard', view: (p) => viewArtifacts(p) },
2340
+ projects: { label: 'Projects', view: () => viewArtifactProjects() },
2341
+ },
2342
+ },
2233
2343
  };
2234
2344
 
2235
2345
  /**
@@ -2247,6 +2357,7 @@ function href(wf, page, param, q = {}) {
2247
2357
  }
2248
2358
  const LH = (page, param, q) => href('learning', page, param, q);
2249
2359
  const MH = (page, param, q) => href('memory', page, param, q);
2360
+ const AH = (page, param, q) => href('artifacts', page, param, q);
2250
2361
  /** A session opened from Memory stays in Memory, and from Learning in Learning. */
2251
2362
  const sessHref = (id) => href(CUR.wf, 'session', id);
2252
2363
 
@@ -2355,6 +2466,7 @@ function navCounts(wf) {
2355
2466
  domains: new Set(conceptsInScope().map((c) => c.domain)).size,
2356
2467
  };
2357
2468
  }
2469
+ if (wf === 'artifacts') return { projects: new Set(artifactsInScope().map((a) => pid(a.project))).size };
2358
2470
  return {
2359
2471
  timeline: sum((p) => p.memory.entries),
2360
2472
  sessions: sum((p) => p.memory.sessions),
@@ -2694,6 +2806,11 @@ $('view').addEventListener('input', (e) => {
2694
2806
  T.concepts.page = 1;
2695
2807
  render({ id:'q', pos:e.target.selectionStart });
2696
2808
  }
2809
+ if (e.target.id === 'aq') {
2810
+ T.artifacts.q = e.target.value;
2811
+ T.artifacts.page = 1;
2812
+ render({ id:'aq', pos:e.target.selectionStart });
2813
+ }
2697
2814
  if (e.target.id === 'mq') {
2698
2815
  T.memory.q = e.target.value;
2699
2816
  T.memory.page = 1;
@@ -168,6 +168,13 @@ tool exists to prevent. **Read `references/grading.md` before you grade** — bo
168
168
  scales, how long feedback may be, the sequence a blank earns, and what
169
169
  `already_taught` changes.
170
170
 
171
+ **A missed answer can come back with `explain`.** That is `explain_on_wrong` at
172
+ work: follow its `instruction` exactly. The page is written in the background
173
+ and opens by itself, so give the verdict, say the page is on its way, and go
174
+ back to the task — no longer explanation, no waiting. When the developer asks
175
+ for something to be explained as a page ("explain this to me", "make me a page
176
+ on this"), start the same background explainer whatever the setting says.
177
+
171
178
  ## The dials
172
179
 
173
180
  **Mode** is how hard to push, **focus** is what to teach, **cadence** is when to
@@ -451,11 +451,14 @@ function memoryPrune() {
451
451
  try {
452
452
  const { config } = loadConfig();
453
453
  if (!config.memory.retention_days) {
454
- process.stdout.write('memory.retention_days is not set, so raw evidence is kept until deleted by hand.\n');
454
+ process.stdout.write('memory.retention_days is not set for this project, so its raw evidence is kept until deleted by hand.\n');
455
455
  return;
456
456
  }
457
- const removed = pruneEvidence(db, config);
458
- process.stdout.write(`Deleted ${removed} raw evidence events older than ${config.memory.retention_days} days.\n`);
457
+ // This project only, under this project's resolved retention: another
458
+ // project may keep its evidence (retention unset) or a different window.
459
+ const project = currentProject();
460
+ const removed = pruneEvidence(db, config, { project });
461
+ process.stdout.write(`Deleted ${removed} raw evidence events older than ${config.memory.retention_days} days in ${project}.\n`);
459
462
  }
460
463
  finally {
461
464
  db.close();