artifacty 0.2.0 → 0.3.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.
package/AGENTS.md CHANGED
@@ -23,6 +23,8 @@ Artifacty shares LLM artifacts over HTTP, CLI, and MCP.
23
23
  - `node src/cli.js import --agent claude --file artifact.html`: convert and store an external artifact.
24
24
  - `node src/cli.js install claude --dry-run`: preview generated MCP config.
25
25
  - `node src/cli.js check`: verify MCP tool discovery.
26
+ - `node src/cli.js index rebuild`: rebuild the optional SQLite FTS5 search index.
27
+ - `node src/cli.js integrity`: verify version files, hashes, and orphaned files.
26
28
 
27
29
  ## Coding Style & Naming Conventions
28
30
 
package/README.md CHANGED
@@ -165,6 +165,7 @@ List artifacts:
165
165
 
166
166
  ```bash
167
167
  artifacty list
168
+ artifacty list --query review --limit 20 --offset 20
168
169
  ```
169
170
 
170
171
  Run the MCP server:
@@ -179,6 +180,8 @@ Operational commands:
179
180
 
180
181
  ```bash
181
182
  artifacty audit --limit 20
183
+ artifacty index rebuild
184
+ artifacty integrity
182
185
  artifacty backup
183
186
  artifacty export --file ./artifacty-backup.json
184
187
  artifacty import-store --file ./artifacty-backup.json
@@ -200,6 +203,13 @@ ARTIFACTY_HOME=/path/to/shared/store artifacty serve
200
203
 
201
204
  Artifact metadata is stored in `artifacty.sqlite`; artifact content is stored as immutable version files under `artifacts/`. The current browser server URL is written to `server.json` so MCP tools can return the correct links when the default port falls back. Existing `index.json` stores are migrated automatically on first access.
202
205
 
206
+ Search uses a SQLite FTS5 index when the local Node SQLite build supports it. The index covers the latest version body plus title, tags, source agent, artifact type, format, and metadata summary. If FTS5 is unavailable, Artifacty keeps working with metadata search. Rebuild or check the store when needed:
207
+
208
+ ```bash
209
+ artifacty index rebuild
210
+ artifacty integrity
211
+ ```
212
+
203
213
  ## API Example
204
214
 
205
215
  Start a protected server in another terminal, or generate a reusable shell token first:
@@ -245,6 +255,13 @@ Browser routes:
245
255
  - `/artifacts/:id/diff`: compare versions.
246
256
  - `/api/audit`: list audit events.
247
257
 
258
+ List APIs support pagination with `limit` and `offset`. Responses keep the top-level `artifacts` array and include `pagination` and `search` metadata:
259
+
260
+ ```bash
261
+ curl -s "http://127.0.0.1:8787/api/artifacts?q=handoff&limit=20&offset=0" \
262
+ -H "x-artifacty-token: $ARTIFACTY_API_TOKEN"
263
+ ```
264
+
248
265
  ## Interface Language
249
266
 
250
267
  The browser UI defaults to English. Add `?lang=ko` to any browser route to use Korean, for example `http://127.0.0.1:8787/new?lang=ko`. Forms and in-app links preserve the selected language. Documentation is maintained in English only.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "artifacty",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Local artifact exchange for heterogeneous LLM agents via HTTP and MCP.",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/cli.js CHANGED
@@ -4,11 +4,13 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import {
6
6
  archiveArtifact,
7
+ checkStoreIntegrity,
7
8
  createArtifact,
8
9
  createStore,
9
10
  getArtifact,
10
11
  listAuditEvents,
11
- listArtifacts,
12
+ listArtifactsPage,
13
+ rebuildSearchIndex,
12
14
  restoreArtifact,
13
15
  updateArtifact
14
16
  } from "./lib/storage.js";
@@ -200,14 +202,37 @@ async function main() {
200
202
  }
201
203
 
202
204
  if (command === "list") {
203
- const artifacts = await listArtifacts(store, {
205
+ const page = await listArtifactsPage(store, {
204
206
  query: options.query,
205
207
  tag: Array.isArray(options.tag) ? options.tag[0] : options.tag,
206
208
  sourceAgent: options.source,
207
209
  includeArchived: options.includeArchived,
208
- limit: options.limit
210
+ limit: options.limit,
211
+ offset: options.offset
212
+ });
213
+ printJson({
214
+ artifacts: page.artifacts,
215
+ pagination: paginationJson(page),
216
+ search: page.search
209
217
  });
210
- printJson({ artifacts });
218
+ return;
219
+ }
220
+
221
+ if ((command === "index" || command === "search") && options._[0] === "rebuild") {
222
+ const result = await rebuildSearchIndex(store);
223
+ printJson(result);
224
+ if (!result.fts5) {
225
+ process.exitCode = 1;
226
+ }
227
+ return;
228
+ }
229
+
230
+ if (command === "integrity" || command === "check-store") {
231
+ const result = await checkStoreIntegrity(store);
232
+ printJson(result);
233
+ if (!result.ok) {
234
+ process.exitCode = 1;
235
+ }
211
236
  return;
212
237
  }
213
238
 
@@ -298,7 +323,7 @@ function parseArgs(args) {
298
323
 
299
324
  if (key === "tag") {
300
325
  options.tag = [...(options.tag || []), value];
301
- } else if (key === "port" || key === "limit" || key === "version" || key === "schema-version" || key === "timeout" || key === "bytes") {
326
+ } else if (key === "port" || key === "limit" || key === "offset" || key === "version" || key === "schema-version" || key === "timeout" || key === "bytes") {
302
327
  options[toCamelCase(key)] = Number(value);
303
328
  } else {
304
329
  options[toCamelCase(key)] = value;
@@ -342,6 +367,17 @@ async function withUrls(store, artifact) {
342
367
  };
343
368
  }
344
369
 
370
+ function paginationJson(page) {
371
+ return {
372
+ total: page.total,
373
+ limit: page.limit,
374
+ offset: page.offset,
375
+ hasMore: page.hasMore,
376
+ nextOffset: page.nextOffset,
377
+ previousOffset: page.previousOffset
378
+ };
379
+ }
380
+
345
381
  function requireOption(options, name) {
346
382
  if (!options[name]) {
347
383
  throw new Error(`Missing required option --${name}`);
@@ -370,11 +406,13 @@ Usage:
370
406
  artifacty archive <id>
371
407
  artifacty restore <id>
372
408
  artifacty audit [--artifact <id>] [--limit 100]
409
+ artifacty index rebuild
410
+ artifacty integrity
373
411
  artifacty export --file <path>
374
412
  artifacty backup [--file <path>]
375
413
  artifacty import-store --file <path>
376
414
  artifacty service plist|install|uninstall [--dry-run] [--plist <path>]
377
- artifacty list [--query text] [--tag tag] [--source agent] [--limit 50] [--include-archived]
415
+ artifacty list [--query text] [--tag tag] [--source agent] [--limit 50] [--offset 0] [--include-archived]
378
416
  artifacty show <id> [--version n] [--raw]
379
417
 
380
418
  Environment:
package/src/lib/i18n.js CHANGED
@@ -15,7 +15,11 @@ const TRANSLATIONS = {
15
15
  "language.english": "English",
16
16
  "language.korean": "Korean",
17
17
  "dashboard.count": "{count} artifacts",
18
+ "dashboard.range": "{start}-{end} of {total} artifacts",
19
+ "dashboard.searchBackend": "{backend} search",
18
20
  "dashboard.empty": "No artifacts published yet.",
21
+ "dashboard.previous": "Previous",
22
+ "dashboard.next": "Next",
19
23
  "filter.search": "Search",
20
24
  "filter.tag": "Tag",
21
25
  "filter.source": "Source",
@@ -69,7 +73,11 @@ const TRANSLATIONS = {
69
73
  "language.english": "영어",
70
74
  "language.korean": "한국어",
71
75
  "dashboard.count": "아티팩트 {count}개",
76
+ "dashboard.range": "아티팩트 {total}개 중 {start}-{end}",
77
+ "dashboard.searchBackend": "{backend} 검색",
72
78
  "dashboard.empty": "아직 게시된 아티팩트가 없습니다.",
79
+ "dashboard.previous": "이전",
80
+ "dashboard.next": "다음",
73
81
  "filter.search": "검색",
74
82
  "filter.tag": "태그",
75
83
  "filter.source": "소스",
package/src/lib/render.js CHANGED
@@ -2,17 +2,26 @@ import { EDITOR_CLIENT_PATH, VIEWER_CLIENT_PATH, editorImportMapJson } from "./e
2
2
  import { createI18n, DEFAULT_LOCALE, editorMessages, localizedHref, switchLocaleHref } from "./i18n.js";
3
3
  import { ARTIFACT_FORMATS, ARTIFACT_TYPES } from "./storage.js";
4
4
 
5
- export function renderDashboard({ artifacts, baseUrl, filters = {}, locale = DEFAULT_LOCALE, currentPath = "/" }) {
5
+ export function renderDashboard({ artifacts, baseUrl, filters = {}, pagination, locale = DEFAULT_LOCALE, currentPath = "/" }) {
6
6
  const view = viewContext(locale, currentPath);
7
+ const total = pagination?.total ?? artifacts.length;
8
+ const start = artifacts.length ? (pagination?.offset ?? 0) + 1 : 0;
9
+ const end = artifacts.length ? (pagination?.offset ?? 0) + artifacts.length : 0;
10
+ const searchBackend = pagination?.search?.backend;
11
+ const pager = renderDashboardPager({ pagination, filters, view });
7
12
  const rows = artifacts
8
13
  .map((artifact) => {
9
14
  const tags = artifact.tags.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`).join("");
10
15
  const status = artifact.archivedAt ? statusBadge("archived") : "";
16
+ const snippet = artifact.searchSnippet
17
+ ? `<span class="row-snippet">${escapeHtml(artifact.searchSnippet)}</span>`
18
+ : "";
11
19
  return `
12
20
  <a class="artifact-row" href="${view.href(`/artifacts/${encodeURIComponent(artifact.id)}`)}">
13
21
  <span class="row-main">
14
22
  <strong>${escapeHtml(artifact.title)}</strong>
15
23
  <span>${escapeHtml(artifact.id)}</span>
24
+ ${snippet}
16
25
  </span>
17
26
  <span>${escapeHtml(artifact.sourceAgent)}</span>
18
27
  ${typeBadge(artifact.artifactType || "document")}
@@ -44,10 +53,12 @@ export function renderDashboard({ artifacts, baseUrl, filters = {}, locale = DEF
44
53
  </header>
45
54
  <main class="dashboard">
46
55
  <section class="toolbar">
47
- <span>${view.text("dashboard.count", { count: artifacts.length })}</span>
56
+ <span>${view.text("dashboard.range", { start, end, total })}</span>
57
+ ${searchBackend ? `<span>${view.text("dashboard.searchBackend", { backend: searchBackend })}</span>` : ""}
48
58
  </section>
49
59
  <form class="filter-form" method="get" action="/">
50
60
  ${localeInput(view.locale)}
61
+ ${filters.limit ? `<input type="hidden" name="limit" value="${escapeAttribute(filters.limit)}">` : ""}
51
62
  <input name="q" value="${escapeAttribute(filters.query || "")}" placeholder="${view.attr("filter.search")}">
52
63
  <input name="tag" value="${escapeAttribute(filters.tag || "")}" placeholder="${view.attr("filter.tag")}">
53
64
  <input name="sourceAgent" value="${escapeAttribute(filters.sourceAgent || "")}" placeholder="${view.attr("filter.source")}">
@@ -58,12 +69,52 @@ export function renderDashboard({ artifacts, baseUrl, filters = {}, locale = DEF
58
69
  <section class="artifact-list">
59
70
  ${rows || `<div class="empty">${view.text("dashboard.empty")}</div>`}
60
71
  </section>
72
+ ${pager}
61
73
  </main>
62
74
  `,
63
75
  locale: view.locale
64
76
  });
65
77
  }
66
78
 
79
+ function renderDashboardPager({ pagination, filters, view }) {
80
+ if (!pagination || pagination.total <= pagination.limit) {
81
+ return "";
82
+ }
83
+
84
+ const previous = pagination.previousOffset === null
85
+ ? `<span class="pager-disabled">${view.text("dashboard.previous")}</span>`
86
+ : `<a href="${view.href(dashboardPageHref(filters, pagination.previousOffset))}">${view.text("dashboard.previous")}</a>`;
87
+ const next = pagination.nextOffset === null
88
+ ? `<span class="pager-disabled">${view.text("dashboard.next")}</span>`
89
+ : `<a href="${view.href(dashboardPageHref(filters, pagination.nextOffset))}">${view.text("dashboard.next")}</a>`;
90
+
91
+ return `<nav class="pager" aria-label="Pagination">${previous}${next}</nav>`;
92
+ }
93
+
94
+ function dashboardPageHref(filters, offset) {
95
+ const params = new URLSearchParams();
96
+ if (filters.query) {
97
+ params.set("q", filters.query);
98
+ }
99
+ if (filters.tag) {
100
+ params.set("tag", filters.tag);
101
+ }
102
+ if (filters.sourceAgent) {
103
+ params.set("sourceAgent", filters.sourceAgent);
104
+ }
105
+ if (filters.includeArchived) {
106
+ params.set("includeArchived", "true");
107
+ }
108
+ if (filters.limit) {
109
+ params.set("limit", filters.limit);
110
+ }
111
+ if (offset > 0) {
112
+ params.set("offset", String(offset));
113
+ }
114
+ const query = params.toString();
115
+ return query ? `/?${query}` : "/";
116
+ }
117
+
67
118
  export function renderArtifactFormPage({ mode, baseUrl, artifact, version, content, authToken = "", locale = DEFAULT_LOCALE, currentPath = "/" }) {
68
119
  const view = viewContext(locale, currentPath);
69
120
  const isEdit = mode === "edit";
@@ -705,6 +756,34 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
705
756
  background: var(--panel);
706
757
  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
707
758
  }
759
+ .pager {
760
+ display: flex;
761
+ justify-content: flex-end;
762
+ gap: 8px;
763
+ margin-top: 14px;
764
+ font-family: var(--mono);
765
+ font-size: 12.5px;
766
+ }
767
+ .pager a,
768
+ .pager-disabled {
769
+ display: inline-flex;
770
+ min-height: 34px;
771
+ align-items: center;
772
+ justify-content: center;
773
+ padding: 5px 12px;
774
+ border: 1px solid var(--line-2);
775
+ border-radius: 8px;
776
+ background: var(--panel);
777
+ color: var(--muted);
778
+ }
779
+ .pager a:hover {
780
+ border-color: var(--accent);
781
+ color: var(--text);
782
+ text-decoration: none;
783
+ }
784
+ .pager-disabled {
785
+ opacity: 0.55;
786
+ }
708
787
  .editor-form {
709
788
  display: grid;
710
789
  gap: 16px;
@@ -889,6 +968,13 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
889
968
  font-size: 12px;
890
969
  color: var(--faint);
891
970
  }
971
+ .row-main .row-snippet {
972
+ color: var(--muted);
973
+ white-space: normal;
974
+ overflow: visible;
975
+ text-overflow: clip;
976
+ overflow-wrap: anywhere;
977
+ }
892
978
  .artifact-row > span:not(.row-main):not(.tags):not(.badge) {
893
979
  font-family: var(--mono);
894
980
  font-size: 12.5px;
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import path from "node:path";
@@ -112,6 +112,7 @@ export async function writeIndex(store, index) {
112
112
  const db = openDatabase(store);
113
113
  try {
114
114
  transaction(db, () => {
115
+ clearSearchIndex(db);
115
116
  db.prepare("DELETE FROM artifact_versions").run();
116
117
  db.prepare("DELETE FROM artifacts").run();
117
118
  for (const artifact of index.artifacts) {
@@ -120,6 +121,7 @@ export async function writeIndex(store, index) {
120
121
  insertVersionRecord(db, artifact.id, version);
121
122
  }
122
123
  }
124
+ rebuildSearchIndexInDb(db, store);
123
125
  });
124
126
  } finally {
125
127
  db.close();
@@ -155,6 +157,7 @@ export async function createArtifact(store = createStore(), input = {}) {
155
157
 
156
158
  insertArtifactRecord(db, artifact);
157
159
  insertVersionRecord(db, id, version);
160
+ upsertSearchIndex(db, artifact, version, normalized.content);
158
161
  insertAuditRecord(db, {
159
162
  action: input.auditAction || "create",
160
163
  artifactId: id,
@@ -209,6 +212,7 @@ export async function updateArtifact(store = createStore(), id, input = {}) {
209
212
  artifact.id
210
213
  );
211
214
  insertVersionRecord(db, artifact.id, version);
215
+ upsertSearchIndex(db, artifact, version, normalized.content);
212
216
  insertAuditRecord(db, {
213
217
  action: input.auditAction || "update",
214
218
  artifactId: artifact.id,
@@ -280,30 +284,316 @@ export async function restoreArtifact(store = createStore(), id, options = {}) {
280
284
  }
281
285
 
282
286
  export async function listArtifacts(store = createStore(), filters = {}) {
283
- const index = await loadIndex(store);
287
+ return (await listArtifactsPage(store, filters)).artifacts;
288
+ }
289
+
290
+ export async function listArtifactsPage(store = createStore(), filters = {}) {
291
+ const db = openDatabase(store);
284
292
  const limit = clampInteger(filters.limit, 1, 200, 50);
285
- const query = normalizeOptionalString(filters.query).toLowerCase();
293
+ const offset = clampInteger(filters.offset, 0, 1_000_000, 0);
294
+ const query = normalizeOptionalString(filters.query);
295
+ const normalizedQuery = query.toLowerCase();
286
296
  const tag = normalizeOptionalString(filters.tag).toLowerCase();
287
297
  const sourceAgent = normalizeOptionalString(filters.sourceAgent).toLowerCase();
288
298
 
289
- return index.artifacts
290
- .filter((artifact) => {
291
- if (!filters.includeArchived && artifact.archivedAt) {
292
- return false;
299
+ try {
300
+ if (query && searchIndexAvailable(db)) {
301
+ const ftsQuery = toFtsQuery(query);
302
+ if (ftsQuery) {
303
+ try {
304
+ const page = listArtifactsPageWithFts(db, {
305
+ ftsQuery,
306
+ tag,
307
+ sourceAgent,
308
+ includeArchived: filters.includeArchived,
309
+ limit,
310
+ offset
311
+ });
312
+ if (page.total > 0) {
313
+ return page;
314
+ }
315
+ } catch {
316
+ // Keep search usable even if the SQLite FTS parser rejects a query.
317
+ }
293
318
  }
294
- if (query && !artifactMatchesQuery(artifact, query)) {
295
- return false;
319
+ }
320
+
321
+ return listArtifactsPageWithSql(db, {
322
+ query: normalizedQuery,
323
+ tag,
324
+ sourceAgent,
325
+ includeArchived: filters.includeArchived,
326
+ limit,
327
+ offset
328
+ });
329
+ } finally {
330
+ db.close();
331
+ }
332
+ }
333
+
334
+ export async function rebuildSearchIndex(store = createStore()) {
335
+ const db = openDatabase(store);
336
+ try {
337
+ if (!searchIndexAvailable(db)) {
338
+ return {
339
+ ok: false,
340
+ fts5: false,
341
+ indexed: 0,
342
+ skipped: [],
343
+ message: "SQLite FTS5 is unavailable; metadata search fallback remains active."
344
+ };
345
+ }
346
+
347
+ return transaction(db, () => rebuildSearchIndexInDb(db, store));
348
+ } finally {
349
+ db.close();
350
+ }
351
+ }
352
+
353
+ export async function checkStoreIntegrity(store = createStore()) {
354
+ const db = openDatabase(store);
355
+ const checkedAt = new Date().toISOString();
356
+ try {
357
+ const artifacts = loadArtifacts(db);
358
+ const referencedPaths = new Set();
359
+ const missingFiles = [];
360
+ const hashMismatches = [];
361
+ const sizeMismatches = [];
362
+ const dbInconsistencies = [];
363
+ let totalBytes = 0;
364
+ let versionCount = 0;
365
+
366
+ for (const artifact of artifacts) {
367
+ if (!artifact.versions.length) {
368
+ dbInconsistencies.push({
369
+ artifactId: artifact.id,
370
+ issue: "artifact has no version rows"
371
+ });
296
372
  }
297
- if (tag && !artifact.tags.some((item) => item.toLowerCase() === tag)) {
298
- return false;
373
+ if (!artifact.versions.some((version) => version.version === artifact.latestVersion)) {
374
+ dbInconsistencies.push({
375
+ artifactId: artifact.id,
376
+ issue: `latest version ${artifact.latestVersion} has no version row`
377
+ });
299
378
  }
300
- if (sourceAgent && artifact.sourceAgent.toLowerCase() !== sourceAgent) {
301
- return false;
379
+
380
+ for (const version of artifact.versions) {
381
+ versionCount += 1;
382
+ const absolutePath = path.resolve(store.home, version.path);
383
+ referencedPaths.add(absolutePath);
384
+ if (!existsSync(absolutePath)) {
385
+ missingFiles.push({
386
+ artifactId: artifact.id,
387
+ version: version.version,
388
+ path: version.path
389
+ });
390
+ continue;
391
+ }
392
+
393
+ const content = readFileSync(absolutePath);
394
+ const actualSize = content.byteLength;
395
+ const actualSha256 = createHash("sha256").update(content).digest("hex");
396
+ totalBytes += actualSize;
397
+
398
+ if (actualSize !== version.sizeBytes) {
399
+ sizeMismatches.push({
400
+ artifactId: artifact.id,
401
+ version: version.version,
402
+ path: version.path,
403
+ expected: version.sizeBytes,
404
+ actual: actualSize
405
+ });
406
+ }
407
+ if (actualSha256 !== version.sha256) {
408
+ hashMismatches.push({
409
+ artifactId: artifact.id,
410
+ version: version.version,
411
+ path: version.path,
412
+ expected: version.sha256,
413
+ actual: actualSha256
414
+ });
415
+ }
302
416
  }
303
- return true;
304
- })
305
- .slice(0, limit)
306
- .map(toArtifactSummary);
417
+ }
418
+
419
+ const orphanFiles = listStoreFiles(store.artifactsDir)
420
+ .filter((filePath) => !referencedPaths.has(filePath))
421
+ .map((filePath) => {
422
+ const stat = statSync(filePath);
423
+ return {
424
+ path: path.relative(store.home, filePath),
425
+ sizeBytes: stat.size
426
+ };
427
+ });
428
+ const orphanBytes = orphanFiles.reduce((sum, file) => sum + file.sizeBytes, 0);
429
+ const ok =
430
+ missingFiles.length === 0 &&
431
+ hashMismatches.length === 0 &&
432
+ sizeMismatches.length === 0 &&
433
+ orphanFiles.length === 0 &&
434
+ dbInconsistencies.length === 0;
435
+
436
+ return {
437
+ ok,
438
+ checkedAt,
439
+ store: store.home,
440
+ artifactCount: artifacts.length,
441
+ versionCount,
442
+ totalBytes,
443
+ orphanBytes,
444
+ missingFiles,
445
+ hashMismatches,
446
+ sizeMismatches,
447
+ orphanFiles,
448
+ dbInconsistencies
449
+ };
450
+ } finally {
451
+ db.close();
452
+ }
453
+ }
454
+
455
+ function listArtifactsPageWithSql(db, filters) {
456
+ const { clauses, params } = artifactWhereClauses(filters);
457
+ if (filters.query) {
458
+ const like = `%${escapeLike(filters.query)}%`;
459
+ clauses.push(`(
460
+ LOWER(id) LIKE ? ESCAPE '\\' OR
461
+ LOWER(title) LIKE ? ESCAPE '\\' OR
462
+ LOWER(source_agent) LIKE ? ESCAPE '\\' OR
463
+ LOWER(tags_json) LIKE ? ESCAPE '\\'
464
+ )`);
465
+ params.push(like, like, like, like);
466
+ }
467
+
468
+ const whereSql = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
469
+ const total = db.prepare(`SELECT COUNT(*) AS total FROM artifacts ${whereSql}`).get(...params).total;
470
+ const rows = db.prepare(`
471
+ SELECT id, title, artifact_type, schema_version, source_agent, tags_json, created_at, updated_at, latest_version, archived_at
472
+ FROM artifacts
473
+ ${whereSql}
474
+ ORDER BY updated_at DESC, created_at DESC
475
+ LIMIT ? OFFSET ?
476
+ `).all(...params, filters.limit, filters.offset);
477
+
478
+ return pagedResult({
479
+ artifacts: rows.map((row) => toArtifactSummary(artifactFromRow(db, row))),
480
+ total,
481
+ limit: filters.limit,
482
+ offset: filters.offset,
483
+ searchBackend: filters.query ? "metadata" : "sqlite"
484
+ });
485
+ }
486
+
487
+ function listArtifactsPageWithFts(db, filters) {
488
+ const { clauses, params } = artifactWhereClauses(filters, "a");
489
+ clauses.unshift("artifact_search MATCH ?");
490
+ params.unshift(filters.ftsQuery);
491
+ const whereSql = `WHERE ${clauses.join(" AND ")}`;
492
+ const total = db.prepare(`
493
+ SELECT COUNT(*) AS total
494
+ FROM artifact_search
495
+ JOIN artifacts a ON a.id = artifact_search.artifact_id
496
+ ${whereSql}
497
+ `).get(...params).total;
498
+ const rows = db.prepare(`
499
+ SELECT
500
+ a.id,
501
+ a.title,
502
+ a.artifact_type,
503
+ a.schema_version,
504
+ a.source_agent,
505
+ a.tags_json,
506
+ a.created_at,
507
+ a.updated_at,
508
+ a.latest_version,
509
+ a.archived_at,
510
+ bm25(artifact_search) AS search_rank,
511
+ snippet(artifact_search, 7, '', '', '...', 24) AS search_snippet
512
+ FROM artifact_search
513
+ JOIN artifacts a ON a.id = artifact_search.artifact_id
514
+ ${whereSql}
515
+ ORDER BY search_rank ASC, a.updated_at DESC, a.created_at DESC
516
+ LIMIT ? OFFSET ?
517
+ `).all(...params, filters.limit, filters.offset);
518
+
519
+ return pagedResult({
520
+ artifacts: rows.map((row) => ({
521
+ ...toArtifactSummary(artifactFromRow(db, row)),
522
+ searchScore: row.search_rank,
523
+ searchSnippet: normalizeWhitespace(row.search_snippet)
524
+ })),
525
+ total,
526
+ limit: filters.limit,
527
+ offset: filters.offset,
528
+ searchBackend: "fts5"
529
+ });
530
+ }
531
+
532
+ function artifactWhereClauses(filters, alias = "") {
533
+ const prefix = alias ? `${alias}.` : "";
534
+ const clauses = [];
535
+ const params = [];
536
+
537
+ if (!filters.includeArchived) {
538
+ clauses.push(`${prefix}archived_at IS NULL`);
539
+ }
540
+ if (filters.tag) {
541
+ clauses.push(`LOWER(${prefix}tags_json) LIKE ? ESCAPE '\\'`);
542
+ params.push(`%"${escapeLike(filters.tag)}"%`);
543
+ }
544
+ if (filters.sourceAgent) {
545
+ clauses.push(`LOWER(${prefix}source_agent) = ?`);
546
+ params.push(filters.sourceAgent);
547
+ }
548
+
549
+ return { clauses, params };
550
+ }
551
+
552
+ function pagedResult({ artifacts, total, limit, offset, searchBackend }) {
553
+ return {
554
+ artifacts,
555
+ total,
556
+ limit,
557
+ offset,
558
+ hasMore: offset + artifacts.length < total,
559
+ nextOffset: offset + artifacts.length < total ? offset + limit : null,
560
+ previousOffset: offset > 0 ? Math.max(0, offset - limit) : null,
561
+ search: {
562
+ backend: searchBackend
563
+ }
564
+ };
565
+ }
566
+
567
+ function artifactFromRow(db, row) {
568
+ return {
569
+ id: row.id,
570
+ title: row.title,
571
+ artifactType: row.artifact_type,
572
+ schemaVersion: row.schema_version,
573
+ sourceAgent: row.source_agent,
574
+ tags: parseJson(row.tags_json, []),
575
+ createdAt: row.created_at,
576
+ updatedAt: row.updated_at,
577
+ archivedAt: row.archived_at,
578
+ latestVersion: row.latest_version,
579
+ versions: loadVersions(db, row.id)
580
+ };
581
+ }
582
+
583
+ function escapeLike(value) {
584
+ return String(value).replace(/[\\%_]/g, (match) => `\\${match}`);
585
+ }
586
+
587
+ function normalizeWhitespace(value) {
588
+ return normalizeOptionalString(value).replace(/\s+/g, " ");
589
+ }
590
+
591
+ function toFtsQuery(value) {
592
+ const tokens = normalizeOptionalString(value).match(/[\p{L}\p{N}_-]+/gu) || [];
593
+ return tokens
594
+ .slice(0, 12)
595
+ .map((token) => `"${token.replaceAll("\"", "\"\"")}"`)
596
+ .join(" AND ");
307
597
  }
308
598
 
309
599
  export async function getArtifact(store = createStore(), id, options = {}) {
@@ -458,6 +748,7 @@ function openDatabase(store) {
458
748
  `);
459
749
  initializeSchema(db);
460
750
  migrateJsonIndex(db, store);
751
+ syncSearchIndexIfEmpty(db, store);
461
752
  return db;
462
753
  }
463
754
 
@@ -516,6 +807,7 @@ function initializeSchema(db) {
516
807
  ensureColumn(db, "artifacts", "schema_version", "INTEGER NOT NULL DEFAULT 1");
517
808
  ensureColumn(db, "artifacts", "archived_at", "TEXT");
518
809
  db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('store_version', ?)").run(String(STORE_VERSION));
810
+ ensureSearchTable(db);
519
811
  }
520
812
 
521
813
  function migrateJsonIndex(db, store) {
@@ -551,6 +843,135 @@ function migrateJsonIndex(db, store) {
551
843
  });
552
844
  }
553
845
 
846
+ function ensureSearchTable(db) {
847
+ try {
848
+ db.exec(`
849
+ CREATE VIRTUAL TABLE IF NOT EXISTS artifact_search USING fts5(
850
+ artifact_id UNINDEXED,
851
+ title,
852
+ source_agent,
853
+ artifact_type,
854
+ tags,
855
+ format,
856
+ metadata,
857
+ content
858
+ );
859
+ `);
860
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('fts5_enabled', 'true')").run();
861
+ return true;
862
+ } catch (error) {
863
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('fts5_enabled', ?)").run(`false:${error.message}`);
864
+ return false;
865
+ }
866
+ }
867
+
868
+ function searchIndexAvailable(db) {
869
+ const row = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'artifact_search'").get();
870
+ return Boolean(row) || ensureSearchTable(db);
871
+ }
872
+
873
+ function syncSearchIndexIfEmpty(db, store) {
874
+ if (!searchIndexAvailable(db)) {
875
+ return;
876
+ }
877
+ const artifactCount = db.prepare("SELECT COUNT(*) AS count FROM artifacts").get().count;
878
+ if (artifactCount === 0) {
879
+ return;
880
+ }
881
+ const indexedCount = db.prepare("SELECT COUNT(*) AS count FROM artifact_search").get().count;
882
+ if (indexedCount === 0) {
883
+ transaction(db, () => rebuildSearchIndexInDb(db, store));
884
+ }
885
+ }
886
+
887
+ function clearSearchIndex(db) {
888
+ if (searchIndexAvailable(db)) {
889
+ db.prepare("DELETE FROM artifact_search").run();
890
+ }
891
+ }
892
+
893
+ function rebuildSearchIndexInDb(db, store) {
894
+ if (!searchIndexAvailable(db)) {
895
+ return {
896
+ ok: false,
897
+ fts5: false,
898
+ indexed: 0,
899
+ skipped: []
900
+ };
901
+ }
902
+
903
+ db.prepare("DELETE FROM artifact_search").run();
904
+ const skipped = [];
905
+ let indexed = 0;
906
+ for (const artifact of loadArtifacts(db)) {
907
+ const latest = artifact.versions.find((version) => version.version === artifact.latestVersion);
908
+ if (!latest) {
909
+ skipped.push({ artifactId: artifact.id, reason: "latest version row missing" });
910
+ continue;
911
+ }
912
+ const absolutePath = path.join(store.home, latest.path);
913
+ if (!existsSync(absolutePath)) {
914
+ skipped.push({ artifactId: artifact.id, version: latest.version, reason: "version file missing" });
915
+ continue;
916
+ }
917
+ upsertSearchIndex(db, artifact, latest, readFileSync(absolutePath, "utf8"));
918
+ indexed += 1;
919
+ }
920
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('search_index_built_at', ?)").run(new Date().toISOString());
921
+ return {
922
+ ok: skipped.length === 0,
923
+ fts5: true,
924
+ indexed,
925
+ skipped
926
+ };
927
+ }
928
+
929
+ function upsertSearchIndex(db, artifact, version, content) {
930
+ if (!searchIndexAvailable(db)) {
931
+ return;
932
+ }
933
+ db.prepare("DELETE FROM artifact_search WHERE artifact_id = ?").run(artifact.id);
934
+ db.prepare(`
935
+ INSERT INTO artifact_search (
936
+ artifact_id, title, source_agent, artifact_type, tags, format, metadata, content
937
+ )
938
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
939
+ `).run(
940
+ artifact.id,
941
+ artifact.title,
942
+ artifact.sourceAgent,
943
+ artifact.artifactType,
944
+ artifact.tags.join(" "),
945
+ version.format,
946
+ metadataSearchText(version.metadata),
947
+ content
948
+ );
949
+ }
950
+
951
+ function metadataSearchText(metadata) {
952
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
953
+ return "";
954
+ }
955
+ return JSON.stringify(metadata).slice(0, 64 * 1024);
956
+ }
957
+
958
+ function listStoreFiles(root) {
959
+ if (!existsSync(root)) {
960
+ return [];
961
+ }
962
+
963
+ const files = [];
964
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
965
+ const fullPath = path.join(root, entry.name);
966
+ if (entry.isDirectory()) {
967
+ files.push(...listStoreFiles(fullPath));
968
+ } else if (entry.isFile()) {
969
+ files.push(path.resolve(fullPath));
970
+ }
971
+ }
972
+ return files;
973
+ }
974
+
554
975
  function loadArtifacts(db) {
555
976
  const rows = db.prepare(`
556
977
  SELECT id, title, artifact_type, schema_version, source_agent, tags_json, created_at, updated_at, latest_version, archived_at
@@ -914,19 +1335,6 @@ function makeArtifactId(title) {
914
1335
  return `${slug}-${randomUUID().slice(0, 8)}`;
915
1336
  }
916
1337
 
917
- function artifactMatchesQuery(artifact, query) {
918
- const haystack = [
919
- artifact.id,
920
- artifact.title,
921
- artifact.sourceAgent,
922
- ...artifact.tags
923
- ]
924
- .join(" ")
925
- .toLowerCase();
926
-
927
- return haystack.includes(query);
928
- }
929
-
930
1338
  function clampInteger(value, min, max, fallback) {
931
1339
  const parsed = Number.parseInt(value, 10);
932
1340
  if (Number.isNaN(parsed)) {
package/src/mcp-server.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  createStore,
9
9
  getArtifact,
10
10
  listAuditEvents,
11
- listArtifacts,
11
+ listArtifactsPage,
12
12
  restoreArtifact,
13
13
  updateArtifact
14
14
  } from "./lib/storage.js";
@@ -86,7 +86,8 @@ const tools = [
86
86
  tag: { type: "string" },
87
87
  sourceAgent: { type: "string" },
88
88
  includeArchived: { type: "boolean" },
89
- limit: { type: "number" }
89
+ limit: { type: "number" },
90
+ offset: { type: "number" }
90
91
  }
91
92
  },
92
93
  annotations: {
@@ -315,7 +316,7 @@ async function handleRequest(message) {
315
316
  serverInfo: {
316
317
  name: "artifacty",
317
318
  title: "Artifacty",
318
- version: "0.1.0"
319
+ version: "0.3.0"
319
320
  },
320
321
  instructions: "Use Artifacty to create, import, list, read, and update local artifacts that other agents can reuse."
321
322
  };
@@ -346,12 +347,21 @@ async function callTool(name, args) {
346
347
 
347
348
  if (name === "artifacty_list") {
348
349
  const publicBaseUrl = await resolvePublicBaseUrl(store);
349
- const artifacts = await listArtifacts(store, args);
350
+ const page = await listArtifactsPage(store, args);
350
351
  return toolResult({
351
- artifacts: artifacts.map((artifact) => ({
352
+ artifacts: page.artifacts.map((artifact) => ({
352
353
  ...artifact,
353
354
  url: `${publicBaseUrl}/artifacts/${encodeURIComponent(artifact.id)}`
354
- }))
355
+ })),
356
+ pagination: {
357
+ total: page.total,
358
+ limit: page.limit,
359
+ offset: page.offset,
360
+ hasMore: page.hasMore,
361
+ nextOffset: page.nextOffset,
362
+ previousOffset: page.previousOffset
363
+ },
364
+ search: page.search
355
365
  });
356
366
  }
357
367
 
package/src/server.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  createArtifact,
10
10
  createStore,
11
11
  getArtifact,
12
- listArtifacts,
12
+ listArtifactsPage,
13
13
  listAuditEvents,
14
14
  MAX_ARTIFACT_BYTES,
15
15
  restoreArtifact,
@@ -177,15 +177,19 @@ export async function handleRequest({ request, response, store, host, port, secu
177
177
  query: url.searchParams.get("q") || "",
178
178
  tag: url.searchParams.get("tag") || "",
179
179
  sourceAgent: url.searchParams.get("sourceAgent") || "",
180
- includeArchived: url.searchParams.get("includeArchived") === "true"
180
+ includeArchived: url.searchParams.get("includeArchived") === "true",
181
+ limit: url.searchParams.get("limit") || undefined,
182
+ offset: url.searchParams.get("offset") || undefined
181
183
  };
182
- const artifacts = await listArtifacts(store, {
184
+ const page = await listArtifactsPage(store, {
183
185
  query: filters.query || undefined,
184
186
  tag: filters.tag || undefined,
185
187
  sourceAgent: filters.sourceAgent || undefined,
186
- includeArchived: filters.includeArchived
188
+ includeArchived: filters.includeArchived,
189
+ limit: filters.limit,
190
+ offset: filters.offset
187
191
  });
188
- return sendHtml(response, renderDashboard({ artifacts, baseUrl, filters, locale, currentPath }), 200, headOnly);
192
+ return sendHtml(response, renderDashboard({ artifacts: page.artifacts, baseUrl, filters, pagination: page, locale, currentPath }), 200, headOnly);
189
193
  }
190
194
 
191
195
  if (method === "GET" && pathname === "/new") {
@@ -240,14 +244,19 @@ export async function handleRequest({ request, response, store, host, port, secu
240
244
  }
241
245
 
242
246
  if (method === "GET" && pathname === "/api/artifacts") {
243
- const artifacts = await listArtifacts(store, {
247
+ const page = await listArtifactsPage(store, {
244
248
  query: url.searchParams.get("q") || undefined,
245
249
  tag: url.searchParams.get("tag") || undefined,
246
250
  sourceAgent: url.searchParams.get("sourceAgent") || undefined,
247
251
  includeArchived: url.searchParams.get("includeArchived") === "true",
248
- limit: url.searchParams.get("limit") || undefined
252
+ limit: url.searchParams.get("limit") || undefined,
253
+ offset: url.searchParams.get("offset") || undefined
249
254
  });
250
- return sendJson(response, { artifacts }, 200, headOnly);
255
+ return sendJson(response, {
256
+ artifacts: page.artifacts,
257
+ pagination: paginationJson(page),
258
+ search: page.search
259
+ }, 200, headOnly);
251
260
  }
252
261
 
253
262
  if (method === "GET" && pathname === "/api/audit") {
@@ -442,6 +451,17 @@ export function decorateArtifactUrls(artifact, baseUrl) {
442
451
  };
443
452
  }
444
453
 
454
+ function paginationJson(page) {
455
+ return {
456
+ total: page.total,
457
+ limit: page.limit,
458
+ offset: page.offset,
459
+ hasMore: page.hasMore,
460
+ nextOffset: page.nextOffset,
461
+ previousOffset: page.previousOffset
462
+ };
463
+ }
464
+
445
465
  export async function readJsonBody(request) {
446
466
  const raw = await readBody(request, MAX_ARTIFACT_BYTES + 1024);
447
467
  if (!raw.trim()) {