Public: anyone can read this; the crew can change it.

The explorer ranks apps by likes: `sort:likes` and a Liked tab, with each row's like count #8377

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7753-the-explorer-ranks-apps-by-likes-sort-li → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/7753-the-explorer-ranks-apps-by-likes-sort-li was merged into main as 7431294.

Review

Sign in to review this change.
10 files changed +172 −6
docs/MCP.md +1 −1
@@ -285,7 +285,7 @@ longer sentences:
285285 - `tell.from`: a path on this forge the issue links to, like `/alice/notes/pulls/3`. `tell.context`: where this was told from, for the builder.
286286 - `spawn.reveal`, `claim_link.reveal`: put the live claim link in the answer (default false); it is a secret, only when its person reads it. `spawn.fresh`: a fresh app beside the copied ones. `spawn.followMode`: `tell-me` (default) tells the owner in their inbox when this forge's code moves; `take-updates` opens a pull with the new code, and a person merges it. `spawn.size`: recorded only, not enforced. `spawn.keepAccess`: stay an owner once claimed.
287287 - `commits.ref`: branch, tag or commit; the default branch when absent. `report.page`: the http(s) address where the problem was seen. `plan.issues.pillar`: the pillar served, as `"P0"` or `"P0 Trust"`; `later: true` means no pillar and needs `why`.
288 - `explore.query`: words, plus `kind:app|person|forge` and `sort:score|forks|ships|use|fresh|name`, `updated:1d|7d|30d` (only what moved in that window) and `archived:yes`.
288 - `explore.query`: words, plus `kind:app|person|forge` and `sort:score|forks|ships|likes|use|fresh|name`, `updated:1d|7d|30d` (only what moved in that window) and `archived:yes`.
289289 - `archive.repos`: several repositories, as `owner/name`, to archive in one operation.
290290 - `milestones.issue`: the number of an issue that waits for others; the limbs when absent. `watch.issue` is the same number; `watch.watch` false stops watching, anything else watches.
291291 - `limb.reason` (close): why it is not being done, in one sentence. `limb.pillar` (resume): the pillar to return it under. `limb.under` (raise, lower): `top` or a milestone number, only when the limb sits in several places.
docs/features/explore.md added +25
@@ -0,0 +1,25 @@
1 # The explorer's sorts (#7753)
2
3 `/explore`, `GET /api/v1/explore`, the MCP tool `explore` and the terminal's search line (`/`, then a line with `kind:` or `sort:`) are one action, `explore` in `src/actions/explore.ts`. The line is `kind:app sort:likes updated:7d owner:name archived:yes words`.
4
5 `sort:` takes one of:
6
7 | sort | ranks by |
8 |---|---|
9 | `score` (default) | the front page's score: forks, ships, use and freshness at the admin's weights, plus a modest lift for likes |
10 | `forks` | public forks by people |
11 | `ships` | pulls merged in the last 30 days |
12 | `likes` | likes on posts about the app |
13 | `use` | hours awake in the last 30 days |
14 | `fresh` | the last merge |
15 | `name` | A to Z |
16
17 The page's tabs are Best, Forks, Shipped, Liked, Use and Fresh; a row shows `N likes` when N > 0, on the web and in the terminal.
18
19 ## How likes count
20
21 A like is a `feed_like` row (one per person and post, `src/feed/likes.ts`). An app's count is the likes on posts about its repository, read only for repositories the viewer may read: a private app's likes are never in a public row, and a stranger never sees the private app at all. A person's count is the sum over the apps shown to the viewer.
22
23 In `score`, likes add `0.5 × ln(1 + likes)`, small next to forks (3) and ships (2). The weight is a constant in `explore.ts`, not one of the front page's admin weights.
24
25 An unknown `sort:` word is ignored with a note naming the sorts above.
src/actions/explore.ts +29 −2
@@ -38,6 +38,7 @@ export type ExploreSort =
3838 | "score"
3939 | "forks"
4040 | "ships"
41 | "likes"
4142 | "use"
4243 | "fresh"
4344 | "name";
@@ -74,6 +75,7 @@ const SORTS: readonly ExploreSort[] = [
7475 "score",
7576 "forks",
7677 "ships",
78 "likes",
7779 "use",
7880 "fresh",
7981 "name",
@@ -189,6 +191,8 @@ export interface ExploreRow {
189191 readonly numbers: {
190192 readonly forks: number;
191193 readonly ships: number;
194 /** Likes on posts about the app, as the viewer may read them. */
195 readonly likes: number;
192196 readonly awakeHours: number;
193197 readonly apps: number;
194198 };
@@ -332,7 +336,7 @@ async function forges(app: App, viewer: User | undefined, query: ExploreQuery):
332336 name: child.name,
333337 url: child.url,
334338 description: child.done ? "spawned from this forge" : "being spawned",
335 numbers: { forks: 0, ships: 0, awakeHours: 0, apps: 0 },
339 numbers: { forks: 0, ships: 0, likes: 0, awakeHours: 0, apps: 0 },
336340 shippedAt: null,
337341 score: 0,
338342 }));
@@ -386,6 +390,8 @@ function rank(
386390 : { ...s, awakeHours: 0 };
387391 };
388392
393 const likes = likesByRepo(db, appRepos.map((r) => r.id));
394
389395 const copyable = (repo: Repo): boolean => {
390396 // A signed-out visitor is offered only public apps; the page they land on
391397 // sends them to sign in first, and the copy is made after.
@@ -400,6 +406,7 @@ function rank(
400406 };
401407 const appRows: ExploreRow[] = appRepos.map((repo) => {
402408 const s = told(repo);
409 const liked = likes.get(repo.id) ?? 0;
403410 return {
404411 kind: "app",
405412 name: `${repo.ownerName}/${repo.name}`,
@@ -408,11 +415,12 @@ function rank(
408415 numbers: {
409416 forks: s.forks,
410417 ships: s.ships,
418 likes: liked,
411419 awakeHours: Math.round(s.awakeHours * 10) / 10,
412420 apps: 1,

The issue asked for a sort:likes and a Liked tab with each row's count. Adding LIKE_WEIGHT * log1p(liked) to the default 'score' sort changes the main ranking, with a weight that isn't admin-tunable like the other front weights. The direction says to do what the issue asked and nothing more. Drop it from score, or split it into its own pull where the weight is a front setting. If it stays, say so in the pull.

413421 },
414422 shippedAt: s.shippedAt,
415 score: frontScore(s, now, weights),
423 score: frontScore(s, now, weights) + LIKE_WEIGHT * Math.log1p(liked),
416424 repo: { owner: repo.ownerName, name: repo.name },
417425 copyable: copyable(repo),
418426 };
@@ -437,6 +445,7 @@ function rank(
437445 numbers: {
438446 forks: (was?.numbers.forks ?? 0) + row.numbers.forks,
439447 ships: (was?.numbers.ships ?? 0) + row.numbers.ships,
448 likes: (was?.numbers.likes ?? 0) + row.numbers.likes,
440449 awakeHours:
441450 Math.round(((was?.numbers.awakeHours ?? 0) + row.numbers.awakeHours) * 10) / 10,
442451 apps: (was?.numbers.apps ?? 0) + 1,
@@ -467,11 +476,29 @@ function rank(
467476 );
468477 }
469478
479 /** What a like adds to the score sort: modest next to forks (3) and ships (2). */
480 const LIKE_WEIGHT = 0.5;
481
482 /** Likes on posts about each repository; the caller passes only repositories the viewer may read. */
483 function likesByRepo(db: Database, repoIds: readonly string[]): Map<string, number> {
484 const out = new Map<string, number>();
485 if (repoIds.length === 0) return out;
486 const rows = db
487 .query<{ repo_id: string; n: number }, [string]>(
488 `SELECT repo_id, COUNT(*) AS n FROM feed_like
489 WHERE repo_id IN (SELECT value FROM json_each(?1)) GROUP BY repo_id`,
490 )
491 .all(JSON.stringify(repoIds));
492 for (const r of rows) out.set(r.repo_id, r.n);
493 return out;
494 }
495
470496 function sortKey(r: ExploreRow, sort: ExploreSort): number | string {
471497 switch (sort) {
472498 case "score": return r.score;
473499 case "forks": return r.numbers.forks;
474500 case "ships": return r.numbers.ships;
501 case "likes": return r.numbers.likes;
475502 case "use": return r.numbers.awakeHours;
476503 case "fresh": return r.shippedAt === null ? 0 : Date.parse(r.shippedAt) || 0;
477504 case "name": return r.name.toLowerCase();
src/core/rank_inputs.ts +1
@@ -49,6 +49,7 @@ const SQL = `SELECT
4949 FROM team JOIN team_member ON team_member.team_id = team.id) AS team,
5050 (SELECT group_concat(repo_id || char(31) || user_id, char(30)) FROM repo_crew) AS crew,
5151 (SELECT group_concat(app_id || char(31) || user_id, char(30)) FROM app_member) AS named,
52 (SELECT count(*) || char(31) || COALESCE(max(rowid), 0) FROM feed_like) AS likes,
5253 (SELECT count(*) || char(31) || COALESCE(max(rowid), 0) || char(31) || count(merged_at) || char(31) || COALESCE(max(merged_at), '')
5354 FROM pull) AS pull,
5455 (SELECT group_concat(key || char(31) || value, char(30)) FROM front_setting) AS setting`;
src/tui/screens/dashboard.ts +1
@@ -1389,6 +1389,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
13891389 return metaOf(
13901390 row.kind === "person" ? plural(n.apps, "app") : "",
13911391 n.forks > 0 ? plural(n.forks, "fork") : "",
1392 n.likes > 0 ? plural(n.likes, "like") : "",
13921393 n.ships > 0 ? `${n.ships} shipped this month` : "",
13931394 n.awakeHours > 0 ? `${n.awakeHours} h in use` : "",
13941395 row.shippedAt !== null ? `shipped ${ago(row.shippedAt)}` : "",
src/web/standards.ts +1 −1
@@ -414,7 +414,7 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s
414414 ]),
415415 "## Find things (no account)",
416416 "",
417 `- Explore repositories: GET ${o}/api/v1/explore?q=kind:app+sort:forks+garden. MCP: the tool "explore" with {"q"}. In q, kind: is app, person or forge; sort: is score, forks, ships, use, fresh or name; any other word is free text matched against name, owner and description.`,
417 `- Explore repositories: GET ${o}/api/v1/explore?q=kind:app+sort:forks+garden. MCP: the tool "explore" with {"q"}. In q, kind: is app, person or forge; sort: is score, forks, ships, likes, use, fresh or name; any other word is free text matched against name, owner and description.`,

The sort list at this line (and the same list wherever the explore API, OpenAPI or MCP tool documents sort values) should name likes. Grep for 'fresh or name' / 'ships, use' across src/contract and src/http/mcp and update every copy. Only llms.txt is done here.

418418 `- Search issues, pulls, repositories and code: GET ${o}/api/v1/search?q=websocket+timeout. MCP: the tool "search" with {"q"}.`,
419419 "- Both read only what is public to you.",
420420 "",
src/web/studio/explore.ts +2
@@ -31,6 +31,7 @@ function numbers(row: ExploreRow): { apps: string; rest: string } {
3131 const n = row.numbers;
3232 const rest = [
3333 n.forks > 0 ? `${n.forks} fork${n.forks === 1 ? "" : "s"}` : "",
34 n.likes > 0 ? `${n.likes} like${n.likes === 1 ? "" : "s"}` : "",

Only the web row gets the like count. Put the same text in the terminal's numbersOf (src/tui/screens/dashboard.ts:1387) so both doors show it, with a test.

3435 n.ships > 0 ? `${n.ships} shipped this month` : "",
3536 n.awakeHours > 0 ? `${n.awakeHours} h in use` : "",
3637 row.shippedAt !== null ? `last shipped ${timeAgo(row.shippedAt)}` : "",
@@ -142,6 +143,7 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
142143 { label: "Best", sort: "score" as const },
143144 { label: "Forks", sort: "forks" as const },
144145 { label: "Shipped", sort: "ships" as const },
146 { label: "Liked", sort: "likes" as const },
145147 { label: "Use", sort: "use" as const },
146148 { label: "Fresh", sort: "fresh" as const },
147149 ];
test/explore-likes.test.ts added +99
@@ -0,0 +1,99 @@
1 /** The explorer ranks by likes (#7753): the order, what a viewer may count, and the line's round trip. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { explore, exploreLine, parseExploreQuery } from "../src/actions/explore.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { createApp as insertApp } from "../src/core/apps.ts";
10 import { createUser, type User } from "../src/core/users.ts";
11 import { quietLogs } from "./helpers/globals.ts";
12 import { promote } from "./helpers/members.ts";
13
14 quietLogs();
15
16 let dataDir: string;
17 let app: App;
18 let alice: User;
19 let bob: User;
20 const ids = new Map<string, string>();
21
22 async function mk(username: string, first = false): Promise<User> {
23 const u = await createUser(app.db, {
24 origin: "seed",
25 trusted: true,
26 username,
27 email: `${username}@forge.local`,
28 password: "a-good-passphrase",
29 adminIfFirst: first,
30 });
31 if (!u.ok) throw new Error(u.error);
32 promote(app.db, u.value);
33 return u.value;
34 }
35
36 async function made(name: string, visibility: "public" | "private"): Promise<void> {
37 const repo = await createRepo(app, alice, { name, visibility });
38 if (!repo.ok) throw new Error(String(repo.error));
39 const rec = insertApp(app.db, { repoId: repo.value.id, slug: `alice-${name}`, access: visibility });
40 if (!rec.ok) throw new Error(rec.error);
41 ids.set(name, repo.value.id);
42 }
43
44 function like(name: string, n: number, who: User[]): void {
45 for (let i = 0; i < n; i++)
46 app.db.run("INSERT INTO feed_like (viewer_id, item_id, repo_id) VALUES (?1, ?2, ?3)", [
47 who[i % who.length]!.id,
48 `${name}-${i}`,
49 ids.get(name)!,
50 ]);
51 }
52
53 beforeAll(async () => {
54 dataDir = await mkdtemp(join(tmpdir(), "forge-explore-likes-"));
55 app = createApp({ dataDir, port: 0 });
56 await mk("founder", true);
57 alice = await mk("alice");
58 bob = await mk("bob");
59 await made("quiet", "public");
60 await made("loved", "public");
61 await made("hidden", "private");
62 like("loved", 3, [alice]);
63 like("quiet", 1, [alice]);
64 like("hidden", 5, [alice]);
65 });
66 afterAll(async () => {
67 app.db.close();
68 await rm(dataDir, { recursive: true, force: true });
69 });
70
71 const apps = parseExploreQuery("kind:app sort:likes");
72
73 test("sort:likes orders apps by their likes and shows the count", async () => {
74 const rows = await explore(app, bob, apps);
75 expect(rows.map((r) => r.name)).toEqual(["alice/loved", "alice/quiet"]);
76 expect(rows.map((r) => r.numbers.likes)).toEqual([3, 1]);
77 });
78
79 test("a private app's likes are counted only for those who can read it", async () => {
80 const seen = await explore(app, bob, parseExploreQuery("sort:likes"));
81 expect(seen.some((r) => r.name === "alice/hidden")).toBe(false);
82 expect(seen.find((r) => r.name === "alice")!.numbers.likes).toBe(4);
83 const own = await explore(app, alice, apps);
84 expect(own.map((r) => r.name)).toEqual(["alice/hidden", "alice/loved", "alice/quiet"]);
85 expect((await explore(app, undefined, apps)).map((r) => r.numbers.likes)).toEqual([3, 1]);
86 });
87
88 test("likes lift the score sort modestly", async () => {
89 const rows = await explore(app, bob, parseExploreQuery("kind:app"));
90 expect(rows[0]!.name).toBe("alice/loved");
91 expect(rows[0]!.score).toBeGreaterThan(rows[1]!.score);
92 expect(rows[0]!.score).toBeLessThan(1.5);
93 });
94
95 test("sort:likes round-trips through the line", () => {
96 const q = parseExploreQuery("kind:app sort:likes garden");
97 expect(q.sort).toBe("likes");
98 expect(exploreLine(q)).toBe("kind:app sort:likes garden");
99 });
test/explore-unknown-words.test.ts +2 −2
@@ -31,7 +31,7 @@ describe("readExploreLine", () => {
3131 });
3232
3333 test("the note names what is taken", () => {
34 expect(exploreNote(["sort:bogus"])).toBe("sort:bogus is not a sort — use score, forks, ships, use, fresh, name");
34 expect(exploreNote(["sort:bogus"])).toBe("sort:bogus is not a sort — use score, forks, ships, likes, use, fresh, name");
3535 expect(exploreNote(["kind:x"])).toBe("kind:x is not a kind — use app, person, forge");
3636 });
3737 });
@@ -55,7 +55,7 @@ describe("the page and the API", () => {
5555 const q = encodeURIComponent("sort:bogus garden");
5656 const out = await (await fetch(`${servers.url}/explore?q=${q}`)).text();
5757 expect(out.split("is not a sort").length).toBe(2);
58 expect(out).toContain("sort:bogus is not a sort — use score, forks, ships, use, fresh, name");
58 expect(out).toContain("sort:bogus is not a sort — use score, forks, ships, likes, use, fresh, name");
5959 expect(out).not.toContain(encodeURIComponent("sort:bogus"));
6060 expect(out).toContain(`href="/explore?q=${encodeURIComponent("kind:app garden")}"`);
6161 });
test/tui-dashboard-explore.test.ts +11
@@ -169,3 +169,14 @@ test("owner:<name> alone lists that owner's apps as the explorer", async () => {
169169 expect(text).toContain("alice/zed");
170170 expect(text).not.toContain("bob/zed-one");
171171 });
172
173 test("sort:likes lists the apps by likes and each row says how many", async () => {
174 const ant = (await explorePage(forge, alice, parseExploreQuery("kind:app"), null))!.items.find((r) => r.name === "alice/ant")!;
175 const id = (await forge.db.query<{ id: string }, [string]>("SELECT id FROM repo WHERE name = ?1").get("ant"))!.id;
176 expect(ant.numbers.likes).toBe(0);
177 for (const who of [alice, bob])
178 forge.db.run("INSERT INTO feed_like (viewer_id, item_id, repo_id) VALUES (?1, ?2, ?3)", [who.id, "post-ant", id]);
179 const text = await typed("kind:app sort:likes");
180 expect(text).toContain("2 likes");
181 expect(text.indexOf("alice/ant")).toBeLessThan(text.indexOf("alice/zed"));
182 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs