@foldspace_npm/harness 0.1.11 → 0.1.13

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 (38) hide show
  1. package/CLAUDE.md +287 -178
  2. package/README.md +1 -1
  3. package/package.json +2 -1
  4. package/recipes/INDEX.md +15 -0
  5. package/recipes/README.md +46 -0
  6. package/recipes/find-by-name/README.md +47 -0
  7. package/recipes/find-by-name/agent/actions/find_project_id.ts +101 -0
  8. package/recipes/find-by-name/agent/api/projects.ts +36 -0
  9. package/recipes/find-by-name/agent/projects.ts +40 -0
  10. package/recipes/find-by-name/fixtures/projects.all.json +29 -0
  11. package/recipes/find-by-name/fixtures/projects.empty-account.json +4 -0
  12. package/recipes/find-by-name/fixtures/projects.none.json +4 -0
  13. package/recipes/find-by-name/recipe.json +10 -0
  14. package/recipes/pick-from-a-list/README.md +44 -0
  15. package/recipes/pick-from-a-list/agent/actions/choose_project.ts +161 -0
  16. package/recipes/pick-from-a-list/agent/api/projects.ts +36 -0
  17. package/recipes/pick-from-a-list/agent/projects.ts +40 -0
  18. package/recipes/pick-from-a-list/agent/views/brand.ts +14 -0
  19. package/recipes/pick-from-a-list/agent/views/picker.ts +119 -0
  20. package/recipes/pick-from-a-list/fixtures/projects.all.json +29 -0
  21. package/recipes/pick-from-a-list/fixtures/projects.empty-account.json +4 -0
  22. package/recipes/pick-from-a-list/recipe.json +10 -0
  23. package/recipes/swap-the-login-method/README.md +40 -0
  24. package/recipes/swap-the-login-method/agent/utils.ts +73 -0
  25. package/recipes/swap-the-login-method/fixtures/anything.ok.json +8 -0
  26. package/recipes/swap-the-login-method/recipe.json +12 -0
  27. package/recipes/swap-the-login-method/variants/utils.cookies.ts +64 -0
  28. package/recipes/who-is-the-user/README.md +56 -0
  29. package/recipes/who-is-the-user/agent/identify.ts +89 -0
  30. package/recipes/who-is-the-user/fixtures/profile.ok.json +6 -0
  31. package/recipes/who-is-the-user/recipe.json +9 -0
  32. package/src/runtime/config.ts +1 -1
  33. package/src/runtime/http.ts +104 -53
  34. package/src/runtime/index.ts +4 -1
  35. package/src/runtime/match.ts +1 -1
  36. package/src/runtime/render.ts +42 -1
  37. package/templates/agent-starter/agent/actions/_example.ts +9 -2
  38. package/templates/agent-starter/agent/utils.ts +2 -0
@@ -0,0 +1,47 @@
1
+ # Find by name — L2
2
+
3
+ **The user says** "add it to the Maple Street project". **The agent needs** an id.
4
+
5
+ **Proven by 3 production builds**, all the same shape: a name from the user's
6
+ words → the app's own list → a ranked answer **back to the agent as data**. None
7
+ of them draws a card. A resolver answers questions; it never asks one. When the
8
+ user genuinely has to choose, that is a different action —
9
+ [`pick-from-a-list`](../pick-from-a-list/).
10
+
11
+ In Agent Studio: an action with key `find_project_id` and one optional string
12
+ parameter `name` — *the name, or part of the name, the user said*.
13
+
14
+ ## What it returns
15
+
16
+ | The user's words | The agent gets |
17
+ |---|---|
18
+ | no name ("how many projects do I have?") | the account's **count**, plus a small sample — never the whole account |
19
+ | one clear match | that project, its match strength, and up to three alternatives |
20
+ | several equally good matches | **all of them**, marked `ambiguous` — never a silent pick |
21
+ | nothing matches | `success: false`, plus near-misses for a typo |
22
+ | the account is empty | a count of zero — not an error |
23
+ | signed out, no access, throttled, unreachable | `success: false` with the `reason` |
24
+
25
+ ## Adapt it
26
+
27
+ | File | Change |
28
+ |---|---|
29
+ | `agent/api/projects.ts` | Path, parameter names, envelope — from a captured response |
30
+ | `agent/projects.ts` | What a summary carries, and which keys a user might name out loud |
31
+ | `agent/actions/find_project_id.ts` | The entity. The logic rarely needs touching |
32
+
33
+ ## What those builds learned the hard way
34
+
35
+ - **Hand back every tie.** Picking one of two equally good matches is how a
36
+ later action changes the wrong project.
37
+ - **Count on the server.** Fetching every row to count them is the slowest thing
38
+ an agent can do, and it puts the whole account into the model's context.
39
+ - **Return summaries, not rows.** Two of the three builds send ten raw fields
40
+ per row to the agent; the third sends three and is the one that scales.
41
+ - **A missing session is not "no projects".** One build reports a missing token
42
+ as an empty list, and the agent cheerfully tells a signed-out user they have
43
+ none.
44
+ - **Server search is usually a plain substring**, so a typo finds nothing. Try
45
+ the near-miss pass before saying no.
46
+ - **Probe with a name a real record has.** A search that returns zero proves
47
+ nothing unless a matching record exists.
@@ -0,0 +1,101 @@
1
+ // Resolver: a name the user said → the id every other project action needs.
2
+ //
3
+ // It answers questions and never asks one. "How many projects do I have?" gets
4
+ // a count, not a widget demanding a selection — asking is choose_project.
5
+
6
+ import { listProjects } from "../api/projects";
7
+ import { classify, MATCH_RANK, nearMatches, toSummary, type MatchType, type ProjectSummary } from "../projects";
8
+
9
+ /** Enough for the agent to name a few projects without shipping the account. */
10
+ const SAMPLE_SIZE = 25;
11
+
12
+ /** Names are matched on the server, so a shortlist is all this has to rank. */
13
+ const SHORTLIST = 50;
14
+
15
+ const plural = (n: number) => (n === 1 ? "" : "s");
16
+
17
+ export const find_project_id = {
18
+ execute: async (params: { name?: string }) => {
19
+ const query = typeof params?.name === "string" ? params.name.trim() : "";
20
+
21
+ // No name: a count question. Only a sample of rows comes back.
22
+ if (!query) {
23
+ const res = await listProjects({ pageSize: SAMPLE_SIZE });
24
+ if (!res.ok) return { success: false, error: res.error, reason: res.reason };
25
+
26
+ const { projects, total } = res.data;
27
+ if (total === 0) {
28
+ return { success: true, message: "This account has no projects yet.", data: { total: 0, projects: [] } };
29
+ }
30
+ const sample = projects.map(toSummary);
31
+ return {
32
+ success: true,
33
+ message:
34
+ `This account has ${total} project${plural(total)}.` +
35
+ (sample.length < total ? ` The first ${sample.length} are listed.` : ""),
36
+ data: { total, showing: sample.length, projects: sample },
37
+ };
38
+ }
39
+
40
+ // A name: let the server match it, then rank what comes back.
41
+ const res = await listProjects({ search: query, pageSize: SHORTLIST });
42
+ if (!res.ok) {
43
+ console.warn("[find_project_id]", res.status, res.reason, res.detail);
44
+ return { success: false, error: res.error, reason: res.reason };
45
+ }
46
+
47
+ const summaries = res.data.projects.map(toSummary);
48
+
49
+ if (summaries.length === 0) {
50
+ // Server search is a substring of the name, so a typo finds nothing.
51
+ // Near-misses give the agent something true to work with — but they are
52
+ // near-misses, not a resolution.
53
+ const wide = await listProjects({ pageSize: SHORTLIST });
54
+ const near = wide.ok ? nearMatches(wide.data.projects.map(toSummary), query, 5) : [];
55
+ return {
56
+ success: false,
57
+ error: `No project matching "${query}".`,
58
+ reason: "not_found" as const,
59
+ data: { query, total: res.data.total, nearMatches: near },
60
+ };
61
+ }
62
+
63
+ const ranked = summaries
64
+ .map((project) => ({ project, match: classify(project.name, query) }))
65
+ .filter((m): m is { project: ProjectSummary; match: MatchType } => m.match !== null)
66
+ .sort((a, b) => MATCH_RANK[a.match] - MATCH_RANK[b.match]);
67
+
68
+ // The server matched something the classifier does not recognise. Anything
69
+ // it cannot rank is still a real hit.
70
+ const best = ranked[0];
71
+ if (!best) {
72
+ return {
73
+ success: true,
74
+ message: `${summaries.length} project${plural(summaries.length)} match "${query}".`,
75
+ data: { query, ambiguous: summaries.length > 1, matches: summaries },
76
+ };
77
+ }
78
+
79
+ // Same-strength runners-up mean the name really was ambiguous. Hand them
80
+ // all back rather than silently picking one and changing the wrong project.
81
+ const ties = ranked.filter((m) => m.match === best.match);
82
+ if (ties.length > 1) {
83
+ return {
84
+ success: true,
85
+ message: `${ties.length} projects match "${query}".`,
86
+ data: { query, ambiguous: true, matches: ties.map((m) => m.project) },
87
+ };
88
+ }
89
+
90
+ return {
91
+ success: true,
92
+ message: `Matched project "${best.project.name}".`,
93
+ data: {
94
+ ambiguous: false,
95
+ matchType: best.match,
96
+ ...best.project,
97
+ alternatives: ranked.slice(1, 4).map((m) => m.project),
98
+ },
99
+ };
100
+ },
101
+ };
@@ -0,0 +1,36 @@
1
+ // The app's own project list. Replace the path, the parameter names and the
2
+ // envelope with what you OBSERVED — type only the keys you read.
3
+ //
4
+ // Prefer an endpoint that searches and counts on the server. One production
5
+ // build started on an endpoint that returned the whole account in one response;
6
+ // counting a thousand rows to answer "how many projects do I have?" is how a
7
+ // lookup becomes the slowest thing the agent does.
8
+
9
+ import { apiFetch, type ApiResult } from "../utils";
10
+
11
+ export type Project = {
12
+ id: string;
13
+ name: string;
14
+ status?: string | null;
15
+ updatedAt?: string | null;
16
+ };
17
+
18
+ export type ProjectPage = {
19
+ projects: Project[];
20
+ /** Every project in the account, not just this page. */
21
+ total: number;
22
+ };
23
+
24
+ export async function listProjects(
25
+ options: { search?: string; pageSize?: number } = {},
26
+ ): Promise<ApiResult<ProjectPage>> {
27
+ const query = new URLSearchParams();
28
+ if (options.search) query.set("search", options.search);
29
+ query.set("pageSize", String(options.pageSize && options.pageSize > 0 ? options.pageSize : 25));
30
+
31
+ const res = await apiFetch<{ items?: Project[]; total?: number }>(`/__observe_me/projects?${query}`);
32
+ if (!res.ok) return res;
33
+
34
+ const rows = Array.isArray(res.data?.items) ? res.data.items : [];
35
+ return { ok: true, status: res.status, data: { projects: rows, total: res.data?.total ?? rows.length } };
36
+ }
@@ -0,0 +1,40 @@
1
+ // Name matching shared by the action that RESOLVES a project and the one that
2
+ // asks the user to PICK one. They must agree: a name the lookup calls ambiguous
3
+ // has to produce the same shortlist when the user is shown it.
4
+
5
+ import type { Project } from "./api/projects";
6
+ import { rankBy } from "./utils";
7
+
8
+ /** Only what the agent needs to talk about a project. Never the whole row. */
9
+ export type ProjectSummary = {
10
+ projectId: string;
11
+ name: string;
12
+ status: string | null;
13
+ };
14
+
15
+ export function toSummary(project: Project): ProjectSummary {
16
+ return { projectId: project.id, name: project.name, status: project.status ?? null };
17
+ }
18
+
19
+ export type MatchType = "exact" | "starts_with" | "contains";
20
+
21
+ export const MATCH_RANK: Record<MatchType, number> = { exact: 0, starts_with: 1, contains: 2 };
22
+
23
+ export function classify(name: string, query: string): MatchType | null {
24
+ const a = name.trim().toLowerCase();
25
+ const b = query.trim().toLowerCase();
26
+ if (!b) return null;
27
+ if (a === b) return "exact";
28
+ if (a.startsWith(b)) return "starts_with";
29
+ if (a.includes(b)) return "contains";
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * Loose phrasing — a typo, a half-remembered word. Substring hits lead, the
35
+ * SDK's fuzzy match fills in behind; if the agent is not ready the substring
36
+ * pass stands alone. That is `rankBy`, which was lifted from this pattern.
37
+ */
38
+ export function nearMatches(projects: ProjectSummary[], query: string, limit: number): ProjectSummary[] {
39
+ return rankBy(projects, query, { searchKeys: ["name", "status"], idOf: (p) => p.projectId, limit });
40
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "items": [
3
+ {
4
+ "id": "p_101",
5
+ "name": "Maple Street",
6
+ "status": "Active",
7
+ "updatedAt": "2026-08-30T14:05:00Z"
8
+ },
9
+ {
10
+ "id": "p_102",
11
+ "name": "Harbor Street Office",
12
+ "status": "Draft",
13
+ "updatedAt": "2026-07-12T09:00:00Z"
14
+ },
15
+ {
16
+ "id": "p_103",
17
+ "name": "Maple Street Annex",
18
+ "status": "Closed",
19
+ "updatedAt": "2026-03-02T17:40:00Z"
20
+ },
21
+ {
22
+ "id": "p_104",
23
+ "name": "Harbor View",
24
+ "status": "Active",
25
+ "updatedAt": "2026-02-11T10:00:00Z"
26
+ }
27
+ ],
28
+ "total": 4
29
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "items": [],
3
+ "total": 0
4
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "items": [],
3
+ "total": 4
4
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "title": "Find by name",
3
+ "level": "L2",
4
+ "family": "find-and-show",
5
+ "kind": "action",
6
+ "action": "find_project_id",
7
+ "entry": "agent/actions/find_project_id.ts",
8
+ "outcome": "A name the user said becomes the id the next action needs",
9
+ "provenBy": 3
10
+ }
@@ -0,0 +1,44 @@
1
+ # Pick from a list — L2
2
+
3
+ **The agent needs** the user to choose a project. **The user sees** a clickable
4
+ list, picks one, and the choice goes back to the agent.
5
+
6
+ **Proven by 1 production build**, which split this from
7
+ [`find-by-name`](../find-by-name/) on purpose: *asking is a decision the agent
8
+ makes, not a side effect of looking something up.* A lookup that draws a widget
9
+ puts a selection in front of someone who only asked a question.
10
+
11
+ In Agent Studio: an action with key `choose_project`, optional string parameters
12
+ `name` (narrow the list to what the user said) and `purpose` (shown above the
13
+ list: "Which project for *the export*?").
14
+
15
+ ## What it does
16
+
17
+ | Situation | What happens |
18
+ |---|---|
19
+ | Projects to choose from | the card; the agent **waits** (`awaitUserInput: true`) |
20
+ | The name matched nothing | falls back to the whole list — an empty picker cannot be answered |
21
+ | The account has no projects | **no card**; the result goes straight back |
22
+ | The list could not be loaded | **no card**, `success: false` — never "no projects" for someone who has hundreds |
23
+ | The user picks one | `chosenByUser: true` and the project |
24
+ | "None of these" | what was offered, so the agent can ask what they meant |
25
+ | Cancel | `cancelled: true` — and the card says so, so it is not left looking live |
26
+
27
+ ## Adapt it
28
+
29
+ | File | Change |
30
+ |---|---|
31
+ | `agent/api/projects.ts`, `agent/projects.ts` | Same as `find-by-name` — keep the two recipes on one copy so their shortlists agree |
32
+ | `agent/actions/choose_project.ts` | The entity and the wording |
33
+ | `agent/views/picker.ts` | What a row shows. Re-sample `brand.ts` from the host page |
34
+
35
+ ## What that build learned the hard way
36
+
37
+ - **Only what is on screen goes back to the agent.** Returning the whole account
38
+ put every project name into the model's context on every ask.
39
+ - **Say what is actually on screen.** "25 projects to choose from" is false when
40
+ the account holds a thousand.
41
+ - **Styles go in `header`.** `host` lives inside the component's own frame.
42
+ - **Grow into search later.** The production picker adds a search box that ranks
43
+ a locally held pool with the SDK's fuzzy match (up to ~500 rows), and falls
44
+ back to server search past that. Start without it.
@@ -0,0 +1,161 @@
1
+ // Asks the user which project to work with, and waits for the answer.
2
+ //
3
+ // This exists so that asking is a decision the agent makes, not a side effect of
4
+ // looking something up. find_project_id answers questions and must never put a
5
+ // widget in front of someone who only asked one.
6
+
7
+ import { listProjects } from "../api/projects";
8
+ import { toSummary, type ProjectSummary } from "../projects";
9
+ import { mountProjectPicker } from "../views/picker";
10
+
11
+ /** One screen of projects. */
12
+ const PAGE_SIZE = 25;
13
+
14
+ type ChooseProjectData = {
15
+ askable: boolean;
16
+ query?: string | null;
17
+ purpose?: string | null;
18
+ narrowed?: boolean;
19
+ matched?: number;
20
+ total?: number;
21
+ projects?: ProjectSummary[];
22
+ };
23
+
24
+ type ChooseProjectResult = {
25
+ success: boolean;
26
+ message?: string;
27
+ error?: string;
28
+ reason?: string;
29
+ data?: ChooseProjectData;
30
+ };
31
+
32
+ export const choose_project = {
33
+ execute: async (params: { name?: string; purpose?: string }): Promise<ChooseProjectResult> => {
34
+ const query = typeof params?.name === "string" ? params.name.trim() : "";
35
+ const purpose = typeof params?.purpose === "string" ? params.purpose.trim() : "";
36
+
37
+ const res = await listProjects({ search: query, pageSize: PAGE_SIZE });
38
+ if (!res.ok) {
39
+ console.warn("[choose_project]", res.status, res.reason, res.detail);
40
+ return { success: false, error: res.error, reason: res.reason, data: { askable: false } };
41
+ }
42
+
43
+ let { projects, total } = res.data;
44
+ let narrowed = Boolean(query);
45
+
46
+ // A name that matches nothing falls back to the whole list rather than
47
+ // showing an empty picker the user cannot answer.
48
+ if (projects.length === 0 && query) {
49
+ const all = await listProjects({ pageSize: PAGE_SIZE });
50
+ if (all.ok) {
51
+ projects = all.data.projects;
52
+ total = all.data.total;
53
+ narrowed = false;
54
+ }
55
+ }
56
+
57
+ if (total === 0) {
58
+ return {
59
+ success: true,
60
+ message: "This account has no projects yet, so there is none to pick.",
61
+ data: { askable: false, total: 0, projects: [] },
62
+ };
63
+ }
64
+
65
+ // Rows empty while the account is not: the fallback failed. "No projects"
66
+ // here would be a lie repeated to someone who has hundreds.
67
+ if (projects.length === 0) {
68
+ return {
69
+ success: false,
70
+ error: "The project list could not be loaded just now.",
71
+ data: { askable: false, total },
72
+ };
73
+ }
74
+
75
+ // Only what is on screen goes back to the agent — never the whole account.
76
+ const shown = projects.map(toSummary);
77
+ return {
78
+ success: true,
79
+ message: `A list of ${shown.length} project${shown.length === 1 ? "" : "s"} is on screen and the user is choosing one.`,
80
+ data: {
81
+ askable: true,
82
+ query: query || null,
83
+ purpose: purpose || null,
84
+ narrowed,
85
+ matched: shown.length,
86
+ total,
87
+ projects: shown,
88
+ },
89
+ };
90
+ },
91
+
92
+ awaitUserInput: true,
93
+
94
+ // `result` is what execute RETURNED, not the action's params.
95
+ render: (
96
+ result: ChooseProjectResult | undefined,
97
+ host: HTMLElement,
98
+ header: HTMLElement,
99
+ callback: (value: unknown, disableOnSubmit?: boolean) => void,
100
+ ) => {
101
+ const data = result?.data;
102
+ const projects = data?.projects ?? [];
103
+
104
+ // Nothing to ask about — hand the result back rather than showing an empty
105
+ // card the user cannot answer.
106
+ if (!result || result.success === false || data?.askable === false || projects.length === 0) {
107
+ callback(result, true);
108
+ return;
109
+ }
110
+
111
+ const total = data?.total ?? projects.length;
112
+ const showing = projects.length;
113
+ // Say what is actually on screen.
114
+ const subtitle =
115
+ data?.narrowed && data.query
116
+ ? `${showing} project${showing === 1 ? "" : "s"} match "${data.query}".`
117
+ : showing < total
118
+ ? `Showing ${showing} of ${total}.`
119
+ : `${total} project${total === 1 ? "" : "s"} to choose from.`;
120
+
121
+ mountProjectPicker(host, header, {
122
+ title: data?.purpose ? `Which project for ${data.purpose}?` : "Which project?",
123
+ subtitle,
124
+ projects,
125
+ onSubmit: (choice, offered) => {
126
+ if (choice.kind === "project") {
127
+ callback(
128
+ {
129
+ success: true,
130
+ message: `The user chose the project "${choice.project.name}".`,
131
+ data: { ...choice.project, chosenByUser: true },
132
+ },
133
+ true,
134
+ );
135
+ return;
136
+ }
137
+ if (choice.kind === "cancel") {
138
+ callback(
139
+ {
140
+ success: true,
141
+ message: "The user closed the list without choosing a project.",
142
+ data: { chosenByUser: false, cancelled: true },
143
+ },
144
+ true,
145
+ );
146
+ return;
147
+ }
148
+ // The list did not hold what they wanted. Say what was offered, so the
149
+ // next question can be about what they meant.
150
+ callback(
151
+ {
152
+ success: true,
153
+ message: "The user rejected every project on the list.",
154
+ data: { chosenByUser: false, offered: offered.map((p) => p.name) },
155
+ },
156
+ true,
157
+ );
158
+ },
159
+ });
160
+ },
161
+ };
@@ -0,0 +1,36 @@
1
+ // The app's own project list. Replace the path, the parameter names and the
2
+ // envelope with what you OBSERVED — type only the keys you read.
3
+ //
4
+ // Prefer an endpoint that searches and counts on the server. One production
5
+ // build started on an endpoint that returned the whole account in one response;
6
+ // counting a thousand rows to answer "how many projects do I have?" is how a
7
+ // lookup becomes the slowest thing the agent does.
8
+
9
+ import { apiFetch, type ApiResult } from "../utils";
10
+
11
+ export type Project = {
12
+ id: string;
13
+ name: string;
14
+ status?: string | null;
15
+ updatedAt?: string | null;
16
+ };
17
+
18
+ export type ProjectPage = {
19
+ projects: Project[];
20
+ /** Every project in the account, not just this page. */
21
+ total: number;
22
+ };
23
+
24
+ export async function listProjects(
25
+ options: { search?: string; pageSize?: number } = {},
26
+ ): Promise<ApiResult<ProjectPage>> {
27
+ const query = new URLSearchParams();
28
+ if (options.search) query.set("search", options.search);
29
+ query.set("pageSize", String(options.pageSize && options.pageSize > 0 ? options.pageSize : 25));
30
+
31
+ const res = await apiFetch<{ items?: Project[]; total?: number }>(`/__observe_me/projects?${query}`);
32
+ if (!res.ok) return res;
33
+
34
+ const rows = Array.isArray(res.data?.items) ? res.data.items : [];
35
+ return { ok: true, status: res.status, data: { projects: rows, total: res.data?.total ?? rows.length } };
36
+ }
@@ -0,0 +1,40 @@
1
+ // Name matching shared by the action that RESOLVES a project and the one that
2
+ // asks the user to PICK one. They must agree: a name the lookup calls ambiguous
3
+ // has to produce the same shortlist when the user is shown it.
4
+
5
+ import type { Project } from "./api/projects";
6
+ import { rankBy } from "./utils";
7
+
8
+ /** Only what the agent needs to talk about a project. Never the whole row. */
9
+ export type ProjectSummary = {
10
+ projectId: string;
11
+ name: string;
12
+ status: string | null;
13
+ };
14
+
15
+ export function toSummary(project: Project): ProjectSummary {
16
+ return { projectId: project.id, name: project.name, status: project.status ?? null };
17
+ }
18
+
19
+ export type MatchType = "exact" | "starts_with" | "contains";
20
+
21
+ export const MATCH_RANK: Record<MatchType, number> = { exact: 0, starts_with: 1, contains: 2 };
22
+
23
+ export function classify(name: string, query: string): MatchType | null {
24
+ const a = name.trim().toLowerCase();
25
+ const b = query.trim().toLowerCase();
26
+ if (!b) return null;
27
+ if (a === b) return "exact";
28
+ if (a.startsWith(b)) return "starts_with";
29
+ if (a.includes(b)) return "contains";
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * Loose phrasing — a typo, a half-remembered word. Substring hits lead, the
35
+ * SDK's fuzzy match fills in behind; if the agent is not ready the substring
36
+ * pass stands alone. That is `rankBy`, which was lifted from this pattern.
37
+ */
38
+ export function nearMatches(projects: ProjectSummary[], query: string, limit: number): ProjectSummary[] {
39
+ return rankBy(projects, query, { searchKeys: ["name", "status"], idOf: (p) => p.projectId, limit });
40
+ }
@@ -0,0 +1,14 @@
1
+ // Neutral placeholders. SAMPLE the real values from computed styles on the host
2
+ // page and record where each came from in docs/app-profile.md. Never edit by eye.
3
+
4
+ export const brand = {
5
+ text: "rgb(17, 24, 39)",
6
+ muted: "rgb(107, 114, 128)",
7
+ primary: "rgb(37, 99, 235)",
8
+ border: "rgb(229, 231, 235)",
9
+ tint: "rgb(243, 244, 246)",
10
+ surface: "rgb(255, 255, 255)",
11
+ radius: "8px",
12
+ size: { base: "14px", small: "12px" },
13
+ font: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
14
+ } as const;