fluncle 0.130.0 → 0.132.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 (2) hide show
  1. package/bin/fluncle.mjs +153 -6
  2. package/package.json +1 -1
package/bin/fluncle.mjs CHANGED
@@ -561,7 +561,7 @@ function parseVersion(version) {
561
561
  var currentVersion;
562
562
  var init_version = __esm(() => {
563
563
  init_output();
564
- currentVersion = "0.130.0".trim() ? "0.130.0".trim() : "0.1.0";
564
+ currentVersion = "0.132.0".trim() ? "0.132.0".trim() : "0.1.0";
565
565
  });
566
566
 
567
567
  // src/update-notifier.ts
@@ -2189,7 +2189,7 @@ function parseVersion2(version) {
2189
2189
  var currentVersion2, latestReleaseUrl = "https://api.github.com/repos/mauricekleine/fluncle/releases/latest";
2190
2190
  var init_version2 = __esm(() => {
2191
2191
  init_output();
2192
- currentVersion2 = "0.130.0".trim() ? "0.130.0".trim() : "0.1.0";
2192
+ currentVersion2 = "0.132.0".trim() ? "0.132.0".trim() : "0.1.0";
2193
2193
  });
2194
2194
 
2195
2195
  // ../../packages/registry/src/index.ts
@@ -2537,6 +2537,21 @@ var init_src = __esm(() => {
2537
2537
  url: `${SITE}/api/v1/search`,
2538
2538
  weights: { web: "secondary" }
2539
2539
  },
2540
+ {
2541
+ apiFormat: "application/json",
2542
+ discoveryUrl: `${SITE}/api/v1/openapi.json`,
2543
+ exposedContent: [
2544
+ "search Fluncle's archive: a coordinate (004.7.2I), an artist/label/album name, a bare word (FTS5), or a natural-language query",
2545
+ "sonic search — 'tracks that sound like <a real track>', ranked by MuQ embedding distance"
2546
+ ],
2547
+ kind: "api",
2548
+ name: "api.search.archive",
2549
+ operatorNotes: "The public read behind the ⌘K dialog (search_archive), and the primary navigation once the archive is deep. Four resolution tiers; only the fourth reaches an LLM (OpenRouter, 3s deadline) and it emits FILTERS, never rows. With no OPENROUTER_API_KEY it degrades to full-text and still answers — so it probes green unprovisioned.",
2550
+ probeConfig: { cadenceMs: PROBE_CADENCE_MS, kind: "http", timeoutMs: PROBE_TIMEOUT_MS },
2551
+ route: "/api/v1/search/archive",
2552
+ url: `${SITE}/api/v1/search/archive?q=netsky`,
2553
+ weights: { web: "primary" }
2554
+ },
2540
2555
  {
2541
2556
  apiFormat: "application/json",
2542
2557
  discoveryUrl: `${SITE}/api/v1/openapi.json`,
@@ -2821,6 +2836,15 @@ var init_src = __esm(() => {
2821
2836
  name: "cli.tracks-get",
2822
2837
  weights: { cli: "tertiary" }
2823
2838
  },
2839
+ {
2840
+ command: "fluncle tracks similar",
2841
+ exposedContent: [
2842
+ "the findings that sound nearest to one (the sonic neighbourhood, off the MuQ audio embedding), each with its note"
2843
+ ],
2844
+ kind: "cli",
2845
+ name: "cli.tracks-similar",
2846
+ weights: { cli: "tertiary" }
2847
+ },
2824
2848
  {
2825
2849
  command: "fluncle about",
2826
2850
  exposedContent: ["Fluncle, and where to find him"],
@@ -2888,6 +2912,28 @@ var init_src = __esm(() => {
2888
2912
  probeConfig: { cadenceMs: 24 * 60 * MINUTE_MS, cronName: "fluncle-cluster", kind: "cron" },
2889
2913
  weights: { status: "hidden" }
2890
2914
  },
2915
+ {
2916
+ command: "fluncle admin catalogue crawl",
2917
+ exposedContent: [
2918
+ "walk the MusicBrainz release graph outward from the operator's enabled seed labels → uncertified catalogue rows"
2919
+ ],
2920
+ kind: "cron",
2921
+ name: "cron.crawl",
2922
+ operatorNotes: "every 10m, run by a rave-02 HOST systemd timer (docs/agents/hermes/crawl-timer/). METADATA ONLY — it writes a `tracks` row with no `findings` row, so it certifies nothing (no Log ID, no note, no video, no public surface) and it captures no audio. Worker-paced (the box holds no MusicBrainz budget): one bounded pass per tick over the durable `crawl_frontier`, so a crawl is a marathon the SCHEDULE finishes and a reboot mid-label costs one node, not one crawl. The boundary gate is the operator's seed-label allowlist + graph distance (hop 0-2), never genre inference; a label the walk discovers enters `undecided` and is NOT crawled until he rules on it. Zero LLM tokens. Source: docs/agents/hermes/scripts/crawl-sweep.*. See docs/catalogue-crawler.md.",
2923
+ probeConfig: { cadenceMs: 10 * MINUTE_MS, cronName: "fluncle-crawl", kind: "cron" },
2924
+ weights: { status: "hidden" }
2925
+ },
2926
+ {
2927
+ command: "fluncle admin catalogue rank",
2928
+ exposedContent: [
2929
+ "score each stale catalogue track against every embedded finding → its nearest finding + capture priority"
2930
+ ],
2931
+ kind: "cron",
2932
+ name: "cron.rank",
2933
+ operatorNotes: "every 30m, run by a rave-02 HOST systemd timer (docs/agents/hermes/rank-timer/). THE EAR's schedule, and it lands with the crawler on purpose: a timer ranking an empty table would be a /status row that means nothing, and the crawler is what creates rows. All the vector arithmetic runs in SQL inside the Worker; the sweep DRAINS (it loops while `remaining > 0` up to a tick budget), so a crawl that just landed 700 rows is ranked by the next tick. Self-healing: staleness is a fingerprint of the finding corpus, so logging or embedding a finding re-ranks the catalogue with no invalidation call. Writes DERIVED columns on catalogue rows only — it cannot certify. Zero LLM tokens. Source: docs/agents/hermes/scripts/rank-sweep.*. See docs/the-ear.md.",
2934
+ probeConfig: { cadenceMs: 30 * MINUTE_MS, cronName: "fluncle-rank", kind: "cron" },
2935
+ weights: { status: "hidden" }
2936
+ },
2891
2937
  {
2892
2938
  command: "fluncle admin tracks capture-audio --queue",
2893
2939
  exposedContent: [
@@ -3184,6 +3230,7 @@ __export(exports_track, {
3184
3230
  trackSocialUpdateCommand: () => trackSocialUpdateCommand,
3185
3231
  trackSocialShowCommand: () => trackSocialShowCommand,
3186
3232
  trackSocialCaptureCommand: () => trackSocialCaptureCommand,
3233
+ trackSimilarCommand: () => trackSimilarCommand,
3187
3234
  trackRequeueVideoCommand: () => trackRequeueVideoCommand,
3188
3235
  trackPurgeVideoCommand: () => trackPurgeVideoCommand,
3189
3236
  trackObserveCommand: () => trackObserveCommand,
@@ -3198,6 +3245,10 @@ __export(exports_track, {
3198
3245
  async function trackGetCommand(idOrLogId) {
3199
3246
  return publicApiGet(`/api/tracks/${encodeURIComponent(idOrLogId)}`);
3200
3247
  }
3248
+ async function trackSimilarCommand(idOrLogId, limit) {
3249
+ const query = limit === undefined ? "" : `?limit=${encodeURIComponent(String(limit))}`;
3250
+ return publicApiGet(`/api/tracks/${encodeURIComponent(idOrLogId)}/similar${query}`);
3251
+ }
3201
3252
  async function trackGetAdminCommand(idOrLogId) {
3202
3253
  return adminApiGet(`/api/admin/tracks/${encodeURIComponent(idOrLogId)}`);
3203
3254
  }
@@ -3401,6 +3452,9 @@ async function trackContextCommand(idOrLogId, options = {}) {
3401
3452
  }
3402
3453
  async function trackNoteCommand(idOrLogId, options) {
3403
3454
  const body = { note: options.note };
3455
+ if (options.dryRun) {
3456
+ body.dryRun = true;
3457
+ }
3404
3458
  return adminApiPost(`/api/admin/tracks/${encodeURIComponent(idOrLogId)}/note`, body);
3405
3459
  }
3406
3460
  var DEFAULT_VIDEO_MODEL = "anthropic/claude-opus-4-8", DEFAULT_VIDEO_REASONING = "high", FOUND_BASE = "https://found.fluncle.com", VIDEO_FIELDS, RERENDER_CONTRACT_FIELDS, RERENDER_ADVISORY_FIELDS, FOOTAGE_FIELDS, PLATE_FIELDS, NON_FILE_OPTIONS;
@@ -5364,6 +5418,8 @@ var init_auth_lastfm = __esm(() => {
5364
5418
  // src/commands/admin-catalogue.ts
5365
5419
  var exports_admin_catalogue = {};
5366
5420
  __export(exports_admin_catalogue, {
5421
+ crawlStatusCommand: () => crawlStatusCommand,
5422
+ crawlCatalogueCommand: () => crawlCatalogueCommand,
5367
5423
  catalogueRankCommand: () => catalogueRankCommand,
5368
5424
  catalogueListCommand: () => catalogueListCommand
5369
5425
  });
@@ -5380,6 +5436,16 @@ async function catalogueListCommand(options) {
5380
5436
  }
5381
5437
  return adminApiGet(`/api/admin/catalogue?${params.toString()}`);
5382
5438
  }
5439
+ async function crawlCatalogueCommand(limit, maxHop, dryRun) {
5440
+ const params = new URLSearchParams({ limit: String(limit), maxHop: String(maxHop) });
5441
+ if (dryRun) {
5442
+ params.set("dryRun", "true");
5443
+ }
5444
+ return adminApiPost(`/api/admin/catalogue/crawl?${params.toString()}`);
5445
+ }
5446
+ async function crawlStatusCommand() {
5447
+ return adminApiGet("/api/admin/catalogue/crawl");
5448
+ }
5383
5449
  var init_admin_catalogue = __esm(() => {
5384
5450
  init_api();
5385
5451
  });
@@ -7970,6 +8036,10 @@ function addTrackCommands(program2) {
7970
8036
  const { trackGetCommand: trackGetCommand2 } = await Promise.resolve().then(() => (init_track(), exports_track));
7971
8037
  await runTrackGet(idOrLogId, options, trackGetCommand2);
7972
8038
  });
8039
+ tracks.command("similar").description("The findings that sound nearest to one (the sonic neighbourhood)").argument("[idOrLogId]").option("--limit <limit>", "Number of neighbours to show", "6").option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
8040
+ const { trackSimilarCommand: trackSimilarCommand2 } = await Promise.resolve().then(() => (init_track(), exports_track));
8041
+ await runTrackSimilar(idOrLogId, options, trackSimilarCommand2);
8042
+ });
7973
8043
  }
7974
8044
  function addAdminCommands(program2) {
7975
8045
  const admin = configureCommand(program2.command("admin", { hidden: true }).description("Operator commands"));
@@ -8098,7 +8168,7 @@ function addAdminCommands(program2) {
8098
8168
  const { trackContextCommand: trackContextCommand2 } = await Promise.resolve().then(() => (init_track(), exports_track));
8099
8169
  await runTrackContext(idOrLogId, options, trackContextCommand2);
8100
8170
  });
8101
- adminTrack.command("note").description("Author the editorial note for a finding (fills an empty note only)").argument("[idOrLogId]").option("--queue", "Show the note worklist (context'd findings with no note yet), oldest first", false).option("--limit <limit>", "Number of findings to show with --queue", "10").option("--script <text>", "The voice-gated editorial note").option("--script-file <file>", "Read the editorial note from a file").option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
8171
+ adminTrack.command("note").description("Author the editorial note for a finding (fills an empty note only)").argument("[idOrLogId]").option("--queue", "Show the note worklist (context'd findings with no note yet), oldest first", false).option("--limit <limit>", "Number of findings to show with --queue", "10").option("--script <text>", "The voice-gated editorial note").option("--script-file <file>", "Read the editorial note from a file").option("--dry-run", "Run the voice + echo gates and report the verdict without storing anything", false).option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
8102
8172
  if (options.queue) {
8103
8173
  const { noteQueueCommand: noteQueueCommand2 } = await Promise.resolve().then(() => (init_admin_tracks(), exports_admin_tracks));
8104
8174
  await runAdminNoteQueue(options, noteQueueCommand2);
@@ -8321,7 +8391,7 @@ function addAdminCommands(program2) {
8321
8391
  const { galaxyMapWriteCommand: galaxyMapWriteCommand2 } = await Promise.resolve().then(() => (init_galaxies(), exports_galaxies));
8322
8392
  await runGalaxyMapWrite(options, galaxyMapWriteCommand2);
8323
8393
  });
8324
- const catalogue = configureCommand(admin.command("catalogue").description("The catalogue (uncertified tracks) + its ranking"));
8394
+ const catalogue = configureCommand(admin.command("catalogue").description("The catalogue (uncertified tracks): the crawler + its ranking"));
8325
8395
  catalogue.action(() => {
8326
8396
  catalogue.outputHelp();
8327
8397
  });
@@ -8333,6 +8403,14 @@ function addAdminCommands(program2) {
8333
8403
  const { catalogueListCommand: catalogueListCommand2 } = await Promise.resolve().then(() => (init_admin_catalogue(), exports_admin_catalogue));
8334
8404
  await runCatalogueList(options, catalogueListCommand2);
8335
8405
  });
8406
+ catalogue.command("crawl").description("Run one bounded, resumable pass of the catalogue crawler").option("--dry-run", "Report the seed plan and write nothing at all", false).option("--limit <limit>", "Frontier nodes to expand this pass", "10").option("--max-hop <maxHop>", "Graph distance from a seed label (0-3)", "2").option("--json", "Print JSON", false).action(async (options) => {
8407
+ const { crawlCatalogueCommand: crawlCatalogueCommand2 } = await Promise.resolve().then(() => (init_admin_catalogue(), exports_admin_catalogue));
8408
+ await runCrawlCatalogue(options, crawlCatalogueCommand2);
8409
+ });
8410
+ catalogue.command("status").description("The crawl frontier's state, the catalogue's size, and the seed set").option("--json", "Print JSON", false).action(async (options) => {
8411
+ const { crawlStatusCommand: crawlStatusCommand2 } = await Promise.resolve().then(() => (init_admin_catalogue(), exports_admin_catalogue));
8412
+ await runCrawlStatus(options, crawlStatusCommand2);
8413
+ });
8336
8414
  const backfill = configureCommand(admin.command("backfills").description("Backfill operator-only archives"));
8337
8415
  backfill.action(() => {
8338
8416
  backfill.outputHelp();
@@ -8452,9 +8530,12 @@ async function runTrackContext(idOrLogId, options, trackContextCommand2) {
8452
8530
  async function runTrackNote(idOrLogId, options, trackNoteCommand2) {
8453
8531
  const note = options.scriptFile ? readFileSync6(options.scriptFile, "utf8") : options.script;
8454
8532
  if (!idOrLogId || !note || !note.trim()) {
8455
- throw new Error("Usage: fluncle admin tracks note <track_id|log_id> (--script <text> | --script-file <file>) [--json]");
8533
+ throw new Error("Usage: fluncle admin tracks note <track_id|log_id> (--script <text> | --script-file <file>) [--dry-run] [--json]");
8456
8534
  }
8457
- const result = await trackNoteCommand2(idOrLogId, { note: note.trim() });
8535
+ const result = await trackNoteCommand2(idOrLogId, {
8536
+ dryRun: options.dryRun,
8537
+ note: note.trim()
8538
+ });
8458
8539
  if (options.json) {
8459
8540
  printJson(result);
8460
8541
  return;
@@ -8463,9 +8544,39 @@ async function runTrackNote(idOrLogId, options, trackNoteCommand2) {
8463
8544
  console.log(`A note is already on file for ${result.logId}. The operator's note stands.`);
8464
8545
  return;
8465
8546
  }
8547
+ if (result.dryRun) {
8548
+ console.log(`Both gates passed for ${result.logId}. Nothing was stored (dry run).`);
8549
+ console.log(` ${result.note}`);
8550
+ if (result.echo?.logId) {
8551
+ const reading = result.echo.phrase ? `lifts "${result.echo.phrase}" from ${result.echo.logId}` : `${Math.round(result.echo.overlap * 100)}% word overlap with ${result.echo.logId}`;
8552
+ console.log(` echo: ${reading}`);
8553
+ }
8554
+ return;
8555
+ }
8466
8556
  console.log(`Authored the note for ${result.logId}:`);
8467
8557
  console.log(` ${result.note}`);
8468
8558
  }
8559
+ async function runTrackSimilar(idOrLogId, options, trackSimilarCommand2) {
8560
+ if (!idOrLogId) {
8561
+ throw new Error("Missing id. Usage: fluncle tracks similar <track_id|log_id> [--limit <n>] [--json]");
8562
+ }
8563
+ const limit = options.limit ? Number.parseInt(options.limit, 10) : undefined;
8564
+ const result = await trackSimilarCommand2(idOrLogId, limit !== undefined && Number.isFinite(limit) ? limit : undefined);
8565
+ if (options.json) {
8566
+ printJson(result);
8567
+ return;
8568
+ }
8569
+ if (result.findings.length === 0) {
8570
+ console.log("Nothing sounds near this one yet. It may still be waiting on its embedding.");
8571
+ return;
8572
+ }
8573
+ for (const finding of result.findings) {
8574
+ console.log(`${finding.logId ?? "\u2014"} ${finding.artists.join(", ")} \u2014 ${finding.title}`);
8575
+ if (finding.note) {
8576
+ console.log(` ${finding.note}`);
8577
+ }
8578
+ }
8579
+ }
8469
8580
  function printSweepJson(payload, failedCount) {
8470
8581
  printJson({ ...payload, failedCount, ok: failedCount === 0 });
8471
8582
  if (failedCount > 0) {
@@ -8630,6 +8741,41 @@ async function runBackfillLastfm(options, backfillLastfmCommand2) {
8630
8741
  process.exitCode = 1;
8631
8742
  }
8632
8743
  }
8744
+ async function runCrawlCatalogue(options, crawlCatalogueCommand2) {
8745
+ const limit = parseListLimit(options.limit);
8746
+ const maxHop = Number.parseInt(options.maxHop ?? "2", 10);
8747
+ const result = await crawlCatalogueCommand2(limit, Number.isInteger(maxHop) && maxHop >= 0 ? maxHop : 2, options.dryRun);
8748
+ if (options.json) {
8749
+ printJson(result);
8750
+ } else if (result.dryRun) {
8751
+ console.log(`Dry run: would seed ${result.seeded} enabled label(s); ${result.frontierPending} node(s) pending. Nothing written.`);
8752
+ } else {
8753
+ console.log(`Expanded ${result.expanded} node(s) at hop \u2264 ${result.maxHop}: ${result.tracksFound} track(s) found, ${result.tracksWritten} written, ${result.tracksSkipped} already held.`);
8754
+ console.log(` +${result.seeded} seed(s), +${result.nodesEnqueued} node(s) enqueued, ${result.frontierPending} pending, ${result.failed} failed.`);
8755
+ console.log(` ${result.anchorsFilled} Spotify anchor(s) filled.`);
8756
+ if (result.labelsDiscovered.length > 0) {
8757
+ console.log(` New labels to rule on: ${result.labelsDiscovered.join(", ")} (they are NOT crawled until enabled).`);
8758
+ }
8759
+ if (result.rateLimited) {
8760
+ console.log(" MusicBrainz throttled the pass. Stopped clean; the next tick resumes.");
8761
+ }
8762
+ }
8763
+ if (result.rateLimited) {
8764
+ process.exitCode = 1;
8765
+ }
8766
+ }
8767
+ async function runCrawlStatus(options, crawlStatusCommand2) {
8768
+ const result = await crawlStatusCommand2();
8769
+ if (options.json) {
8770
+ printJson(result);
8771
+ return;
8772
+ }
8773
+ const { frontier, frontierByKind } = result;
8774
+ console.log(`Catalogue: ${result.catalogueTracks} uncertified track(s); ${result.anchorsPending} awaiting a Spotify anchor.`);
8775
+ console.log(`Frontier: ${frontier.pending} pending, ${frontier.done} done, ${frontier.failed} failed, ${frontier.skipped} skipped (${frontierByKind.label} label, ${frontierByKind.artist} artist, ${frontierByKind.release} release).`);
8776
+ console.log(`Seeds (${result.seedLabels.length}): ${result.seedLabels.join(", ") || "none enabled"}.`);
8777
+ console.log(`Awaiting your ruling: ${result.labelsUndecided} label(s).`);
8778
+ }
8633
8779
  async function runBackfillDiscogs(options, backfillDiscogsCommand2) {
8634
8780
  const limit = parseListLimit(options.limit);
8635
8781
  const resolved = [];
@@ -10105,6 +10251,7 @@ var stringOptions = new Set([
10105
10251
  "--kind",
10106
10252
  "--lens",
10107
10253
  "--limit",
10254
+ "--max-hop",
10108
10255
  "--metrics",
10109
10256
  "--mime",
10110
10257
  "--model",
package/package.json CHANGED
@@ -31,5 +31,5 @@
31
31
  "url": "git+https://github.com/mauricekleine/fluncle.git"
32
32
  },
33
33
  "type": "module",
34
- "version": "0.130.0"
34
+ "version": "0.132.0"
35
35
  }