@ferrule-io/ok-fine 0.3.12 → 0.3.14

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.
@@ -97,14 +97,7 @@ export class JwtTokenVerifier {
97
97
  }
98
98
  }
99
99
  if (requiredGroups.length > 0) {
100
- const groupsVal = payload[groupsClaim];
101
- let tokenGroups = [];
102
- if (typeof groupsVal === "string") {
103
- tokenGroups = [groupsVal];
104
- }
105
- else if (Array.isArray(groupsVal)) {
106
- tokenGroups = groupsVal.filter((g) => typeof g === "string");
107
- }
100
+ const tokenGroups = readGroups(payload[groupsClaim]);
108
101
  const hasRequiredGroup = tokenGroups.some((g) => requiredGroups.includes(g));
109
102
  if (!hasRequiredGroup) {
110
103
  throw new OAuthError(OAuthErrorCode.InsufficientScope, "token not permitted by this server's access policy");
@@ -133,6 +126,7 @@ export class JwtTokenVerifier {
133
126
  break;
134
127
  }
135
128
  }
129
+ const groups = readGroups(payload[this.access?.groupsClaim ?? "groups"]);
136
130
  return {
137
131
  token,
138
132
  clientId,
@@ -141,10 +135,19 @@ export class JwtTokenVerifier {
141
135
  extra: {
142
136
  sub: typeof payload.sub === "string" ? payload.sub : "unknown",
143
137
  identity,
138
+ groups,
144
139
  },
145
140
  };
146
141
  }
147
142
  }
143
+ /** Normalizes a groups claim, accepted as a string or an array of strings. */
144
+ function readGroups(value) {
145
+ if (typeof value === "string")
146
+ return [value];
147
+ if (Array.isArray(value))
148
+ return value.filter((g) => typeof g === "string");
149
+ return [];
150
+ }
148
151
  /**
149
152
  * Computes a Principal from an AuthInfo and configured scope names.
150
153
  * Hierarchy:
@@ -161,12 +164,15 @@ export function principalFromAuthInfo(info, scopeNames) {
161
164
  const canRead = hasRead || hasWrite || hasAdmin;
162
165
  const extraSub = info.extra?.sub;
163
166
  const extraIdentity = info.extra?.identity;
167
+ const extraGroups = info.extra?.groups;
164
168
  const sub = typeof extraSub === "string" ? extraSub : "unknown";
165
169
  const identity = typeof extraIdentity === "string" ? extraIdentity : null;
170
+ const groups = Array.isArray(extraGroups) ? extraGroups.filter((g) => typeof g === "string") : [];
166
171
  return {
167
172
  subject: sub,
168
173
  clientId: info.clientId,
169
174
  identity,
175
+ groups,
170
176
  scopes: info.scopes,
171
177
  canRead,
172
178
  canWrite,
package/dist/config.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { isIPv4 } from "node:net";
2
3
  import { z } from "zod";
4
+ import { PROJECT_RE } from "./okf/paths.js";
3
5
  /** A whole-string decimal integer env var (rejects `8080junk`, `1x`, `1.5`, and blanks). */
4
6
  function intEnv(name, fallback, min, max = Number.MAX_SAFE_INTEGER) {
5
7
  return z
@@ -73,6 +75,10 @@ const storageEnvShape = {
73
75
  .default("/data")
74
76
  .refine((v) => v.startsWith("/"), "DATA_DIR must be an absolute path"),
75
77
  GIT_BRANCH: z.string().default("main"),
78
+ HUB_PROJECT: z
79
+ .string()
80
+ .default("org")
81
+ .transform((v) => (v.trim() === "" ? "org" : v.trim())),
76
82
  GIT_REMOTE_URL: z.string().optional(),
77
83
  GIT_SYNC_INTERVAL_SECONDS: intEnv("GIT_SYNC_INTERVAL_SECONDS", "60", 0),
78
84
  GIT_SSH_KEY_PATH: z.string().optional(),
@@ -82,7 +88,99 @@ const storageEnvShape = {
82
88
  MAX_FILE_BYTES: intEnv("MAX_FILE_BYTES", "1048576", 1),
83
89
  MAX_ARCHIVE_BYTES: intEnv("MAX_ARCHIVE_BYTES", "52428800", 1),
84
90
  DEFAULT_STALE_AFTER_DAYS: optionalIntEnv("DEFAULT_STALE_AFTER_DAYS", 1, 36500),
91
+ METRICS_RETENTION_DAYS: intEnv("METRICS_RETENTION_DAYS", "30", 1, 3650),
92
+ PROJECT_ACCESS: z.string().optional(),
93
+ PROJECT_ACCESS_FILE: z.string().optional(),
85
94
  };
95
+ const projectAccessRuleSchema = z.object({
96
+ readGroups: z.array(z.string()).default([]),
97
+ writeGroups: z.array(z.string()).default([]),
98
+ });
99
+ const projectAccessMapSchema = z.record(z.string(), projectAccessRuleSchema);
100
+ function refineProjectAccess(data, ctx) {
101
+ if (data.PROJECT_ACCESS && data.PROJECT_ACCESS_FILE) {
102
+ ctx.addIssue({
103
+ code: "custom",
104
+ path: ["PROJECT_ACCESS"],
105
+ message: "PROJECT_ACCESS and PROJECT_ACCESS_FILE cannot both be set",
106
+ });
107
+ return;
108
+ }
109
+ let jsonStr;
110
+ let targetPath = "PROJECT_ACCESS";
111
+ if (data.PROJECT_ACCESS !== undefined && data.PROJECT_ACCESS.trim() !== "") {
112
+ jsonStr = data.PROJECT_ACCESS;
113
+ targetPath = "PROJECT_ACCESS";
114
+ }
115
+ else if (data.PROJECT_ACCESS_FILE !== undefined && data.PROJECT_ACCESS_FILE.trim() !== "") {
116
+ targetPath = "PROJECT_ACCESS_FILE";
117
+ try {
118
+ jsonStr = readFileSync(data.PROJECT_ACCESS_FILE, "utf-8");
119
+ }
120
+ catch (err) {
121
+ ctx.addIssue({
122
+ code: "custom",
123
+ path: ["PROJECT_ACCESS_FILE"],
124
+ message: `could not read PROJECT_ACCESS_FILE: ${err.message}`,
125
+ });
126
+ return;
127
+ }
128
+ }
129
+ if (jsonStr === undefined)
130
+ return;
131
+ let parsed;
132
+ try {
133
+ parsed = JSON.parse(jsonStr);
134
+ }
135
+ catch {
136
+ ctx.addIssue({
137
+ code: "custom",
138
+ path: [targetPath],
139
+ message: `${targetPath} must be valid JSON`,
140
+ });
141
+ return;
142
+ }
143
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
144
+ ctx.addIssue({
145
+ code: "custom",
146
+ path: [targetPath],
147
+ message: `${targetPath} must be a JSON object`,
148
+ });
149
+ return;
150
+ }
151
+ for (const key of Object.keys(parsed)) {
152
+ if (!PROJECT_RE.test(key)) {
153
+ ctx.addIssue({
154
+ code: "custom",
155
+ path: [targetPath, key],
156
+ message: `invalid project name "${key}": must match ${PROJECT_RE}`,
157
+ });
158
+ }
159
+ }
160
+ const result = projectAccessMapSchema.safeParse(parsed);
161
+ if (!result.success) {
162
+ for (const issue of result.error.issues) {
163
+ ctx.addIssue({
164
+ code: "custom",
165
+ path: [targetPath, ...issue.path],
166
+ message: issue.message,
167
+ });
168
+ }
169
+ }
170
+ }
171
+ function parseProjectAccess(projectAccess, projectAccessFile) {
172
+ let jsonStr;
173
+ if (projectAccess !== undefined && projectAccess.trim() !== "") {
174
+ jsonStr = projectAccess;
175
+ }
176
+ else if (projectAccessFile !== undefined && projectAccessFile.trim() !== "") {
177
+ jsonStr = readFileSync(projectAccessFile, "utf-8");
178
+ }
179
+ if (!jsonStr)
180
+ return {};
181
+ const parsed = JSON.parse(jsonStr);
182
+ return projectAccessMapSchema.parse(parsed);
183
+ }
86
184
  const httpEnvShape = {
87
185
  AUTH_MODE: z.enum(["oidc", "none"], "AUTH_MODE must be oidc or none").default("oidc"),
88
186
  PORT: intEnv("PORT", "8080", 0, 65535),
@@ -150,6 +248,7 @@ const rawEnvSchema = z.object({ ...httpEnvShape, ...storageEnvShape }).superRefi
150
248
  if (!data || typeof data !== "object")
151
249
  return;
152
250
  refineGitCredentials(data, ctx);
251
+ refineProjectAccess(data, ctx);
153
252
  if (data.AUTH_MODE === "none") {
154
253
  const isLoopback = isLoopbackHost(data.HOST ?? "0.0.0.0");
155
254
  const allowUnauthenticated = data.ALLOW_UNAUTHENTICATED_NETWORK === "true";
@@ -220,7 +319,10 @@ const rawEnvSchema = z.object({ ...httpEnvShape, ...storageEnvShape }).superRefi
220
319
  }
221
320
  }
222
321
  }, { when: () => true });
223
- const storageEnvSchema = z.object(storageEnvShape).superRefine(refineGitCredentials, { when: () => true });
322
+ const storageEnvSchema = z
323
+ .object(storageEnvShape)
324
+ .superRefine(refineGitCredentials, { when: () => true })
325
+ .superRefine(refineProjectAccess, { when: () => true });
224
326
  function formatConfigError(error) {
225
327
  const errorMessages = error.issues.map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`);
226
328
  return new Error(`Configuration errors:\n ${errorMessages.join("\n ")}`);
@@ -230,6 +332,7 @@ function storageFromRaw(raw, env, options) {
230
332
  logLevel: raw.LOG_LEVEL,
231
333
  dataDir: raw.DATA_DIR,
232
334
  gitBranch: raw.GIT_BRANCH,
335
+ hubProject: raw.HUB_PROJECT,
233
336
  gitRemoteUrl: raw.GIT_REMOTE_URL,
234
337
  gitSyncIntervalSeconds: raw.GIT_SYNC_INTERVAL_SECONDS,
235
338
  gitSshKeyPath: raw.GIT_SSH_KEY_PATH,
@@ -244,6 +347,8 @@ function storageFromRaw(raw, env, options) {
244
347
  maxFileBytes: raw.MAX_FILE_BYTES,
245
348
  maxArchiveBytes: raw.MAX_ARCHIVE_BYTES,
246
349
  defaultStaleAfterDays: raw.DEFAULT_STALE_AFTER_DAYS,
350
+ metricsRetentionDays: raw.METRICS_RETENTION_DAYS,
351
+ projectAccess: parseProjectAccess(raw.PROJECT_ACCESS, raw.PROJECT_ACCESS_FILE),
247
352
  };
248
353
  }
249
354
  /** Storage-only settings for the stdio transport: needs neither PUBLIC_BASE_URL nor OAUTH_*. */
@@ -0,0 +1,11 @@
1
+ import { z } from "zod";
2
+ import { METRICS_WINDOWS } from "../metrics/types.js";
3
+ const metricsQuery = z.object({ window: z.enum(METRICS_WINDOWS).default("24h") });
4
+ /** Reads MetricsStore directly: metrics are not knowledge, and the service stays free of database access. */
5
+ export function registerMetricsRoutes(app, metrics) {
6
+ app.get("/api/v1/metrics", { config: { permission: "read" } }, async (req) => {
7
+ const { window } = metricsQuery.parse(req.query);
8
+ return metrics.report(window);
9
+ });
10
+ }
11
+ //# sourceMappingURL=metrics.js.map
package/dist/http/rest.js CHANGED
@@ -105,8 +105,24 @@ export function registerRestRoutes(app, service) {
105
105
  const write = { permission: "write" };
106
106
  const admin = { permission: "admin" };
107
107
  app.get("/api/v1/projects", { config: read }, async (req) => {
108
- const { repository } = z.object({ repository: z.string().optional() }).parse(req.query);
109
- return service.listProjects({ repository });
108
+ const { repository, team, query } = z
109
+ .object({
110
+ repository: z.string().optional(),
111
+ team: z.string().optional(),
112
+ query: z.string().optional(),
113
+ })
114
+ .parse(req.query);
115
+ return service.listProjects(principalOf(req), { repository, team, query });
116
+ });
117
+ app.get("/api/v1/orient", { config: read }, async (req) => {
118
+ const { question, project, limit } = z
119
+ .object({
120
+ question: z.string().min(1).max(512),
121
+ project: z.string().optional(),
122
+ limit: z.coerce.number().int().min(1).max(50).optional(),
123
+ })
124
+ .parse(req.query);
125
+ return service.orient(principalOf(req), { question, project, limit });
110
126
  });
111
127
  app.post("/api/v1/projects", { config: write }, async (req, reply) => {
112
128
  const body = z
@@ -117,7 +133,7 @@ export function registerRestRoutes(app, service) {
117
133
  });
118
134
  app.get("/api/v1/projects/:project", { config: read }, async (req) => {
119
135
  const { project } = projectParams.parse(req.params);
120
- return service.getProject(project);
136
+ return service.getProject(principalOf(req), project);
121
137
  });
122
138
  app.delete("/api/v1/projects/:project", { config: admin }, async (req) => {
123
139
  const { project } = projectParams.parse(req.params);
@@ -126,11 +142,11 @@ export function registerRestRoutes(app, service) {
126
142
  app.get("/api/v1/projects/:project/index", { config: read }, async (req) => {
127
143
  const { project } = projectParams.parse(req.params);
128
144
  const { path } = z.object({ path: z.string().optional() }).parse(req.query);
129
- return service.getIndex(project, path ?? "");
145
+ return service.getIndex(principalOf(req), project, path ?? "");
130
146
  });
131
147
  app.get("/api/v1/projects/:project/concepts/*", { config: read }, async (req, reply) => {
132
148
  const { project, "*": id } = wildcardParams.parse(req.params);
133
- const concept = withRevision(reply, await service.readConcept(project, id));
149
+ const concept = withRevision(reply, await service.readConcept(principalOf(req), project, id));
134
150
  if ((req.headers.accept ?? "").includes("text/markdown")) {
135
151
  return reply.type("text/markdown; charset=utf-8").send(concept.markdown);
136
152
  }
@@ -173,7 +189,7 @@ export function registerRestRoutes(app, service) {
173
189
  });
174
190
  app.get("/api/v1/projects/:project/files/*", { config: read }, async (req, reply) => {
175
191
  const { project, "*": path } = wildcardParams.parse(req.params);
176
- const file = withRevision(reply, await service.readFile(project, path));
192
+ const file = withRevision(reply, await service.readFile(principalOf(req), project, path));
177
193
  const extension = /\.[^./]+$/.exec(path)?.[0].toLowerCase() ?? "";
178
194
  return reply.type(CONTENT_TYPE_BY_EXTENSION[extension] ?? "text/plain; charset=utf-8").send(file.content);
179
195
  });
@@ -204,19 +220,19 @@ export function registerRestRoutes(app, service) {
204
220
  const { id, limit } = z
205
221
  .object({ id: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).optional() })
206
222
  .parse(req.query);
207
- return service.history(project, id, limit);
223
+ return service.history(principalOf(req), project, id, limit);
208
224
  });
209
225
  app.get("/api/v1/projects/:project/lint", { config: read }, async (req) => {
210
226
  const { project } = projectParams.parse(req.params);
211
- return service.lint(project);
227
+ return service.lint(principalOf(req), project);
212
228
  });
213
229
  app.get("/api/v1/projects/:project/conflicts", { config: read }, async (req) => {
214
230
  const { project } = projectParams.parse(req.params);
215
- return service.listConflicts(project);
231
+ return service.listConflicts(principalOf(req), project);
216
232
  });
217
233
  app.get("/api/v1/projects/:project/conflicts/:id/files/*", { config: read }, async (req) => {
218
234
  const { project, id, "*": path, } = z.object({ project: z.string(), id: z.string(), "*": z.string() }).parse(req.params);
219
- return service.readConflict(project, id, path);
235
+ return service.readConflict(principalOf(req), project, id, path);
220
236
  });
221
237
  app.post("/api/v1/projects/:project/conflicts/:id/resolution", { config: write }, async (req, reply) => {
222
238
  const { project, id } = z.object({ project: z.string(), id: z.string() }).parse(req.params);
@@ -226,7 +242,7 @@ export function registerRestRoutes(app, service) {
226
242
  });
227
243
  app.get("/api/v1/projects/:project/archive", { config: read }, async (req, reply) => {
228
244
  const { project } = projectParams.parse(req.params);
229
- const stream = await service.exportArchive(project);
245
+ const stream = service.exportArchive(principalOf(req), project);
230
246
  return reply
231
247
  .type("application/gzip")
232
248
  .header("content-disposition", `attachment; filename="${project}.tar.gz"`)
@@ -241,7 +257,7 @@ export function registerRestRoutes(app, service) {
241
257
  });
242
258
  app.get("/api/v1/search", { config: read }, async (req) => {
243
259
  const q = searchQuery.parse(req.query);
244
- return service.search({
260
+ return service.search(principalOf(req), {
245
261
  ...(q.q === undefined ? {} : { query: q.q }),
246
262
  ...(q.project === undefined ? {} : { project: q.project }),
247
263
  ...(q.type === undefined ? {} : { type: q.type }),
@@ -85,6 +85,7 @@ export async function createLocalHost(options) {
85
85
  subject: "local",
86
86
  clientId: "stdio",
87
87
  identity,
88
+ groups: [],
88
89
  scopes: [],
89
90
  canRead: true,
90
91
  canWrite: true,
@@ -4,11 +4,11 @@ import { OkfError } from "../errors.js";
4
4
  import { TRUST_TIERS } from "../okf/semantics.js";
5
5
  import { FEEDBACK_TYPES, feedbackLink } from "../service/feedback.js";
6
6
  import { VERSION } from "../version.js";
7
- export const INSTRUCTIONS = `ok-fine holds shared project knowledge outside the codebase, as OKF v0.2 markdown concepts grouped into projects. In a git repository, first run \`git remote get-url origin\` and call list_projects with that URL as \`repository\`; use the returned project(s) for every read and write. If none match, say the repository is not onboarded and offer to onboard it (ok-fine-onboard skill). Search before planning or editing; record durable decisions, conventions, and runbooks afterwards.
8
- 1. Discover: get_index (progressive disclosure) or search_concepts with \`project\`.
9
- 2. Read: read_concept returns frontmatter, body, trust tier (proposed | unverified | machine-confirmed | human-reviewed), staleness, and links. Freshness comes first: a concept that is stale, when its code sources changed since \`sources[].commit\`, or when that commit is not an ancestor of HEAD (unmerged or rebased away), is a lead to re-check whatever its tier; among fresh concepts prefer higher trust tiers. Knowledge about unmerged work is written at its usual id (e.g. \`decisions/<slug>\`, never over an existing concept) with a \`proposal: { ref: <URI> }\` frontmatter key; such concepts have trust tier \`proposed\` and are not current truth. When one you read has landed in the mainline, rewrite it as current truth without the \`proposal\` key and verify it; when its work was abandoned, set \`status: deprecated\`. Deprecated concepts are history; when code contradicts a concept, trust the code and update the concept.
10
- 3. Write: write_concept with frontmatter containing \`type\` (e.g. Decision, Convention, Architecture, Component, Playbook, Interface, Reference) plus \`title\`, \`description\`, \`tags\`, and \`stale_after\` (ISO 8601, e.g. 180 days ahead). Record provenance in \`sources\` (each with \`resource\` and a stable \`id\`; code sources carry \`commit\`) and cite claims with footnotes [^id]. Link concepts with bundle-absolute links such as [orders](/tables/orders.md). After re-checking a concept against the code, refresh \`sources[].commit\` and \`stale_after\` with write_concept, then call verify_concept.
11
- 4. Pass \`actor\` as <harness>/<model> (e.g. claude-code/claude-opus-4-5, codex/gpt-5-codex, gemini-cli/gemini-2.5-pro). Use human:<email>, with the email from \`git config user.email\`, only when the user personally reviewed the concept; on forbidden_actor, report both identities instead of retrying as another. The server stamps \`generated\`; \`verified\` changes only through verify_concept.
7
+ export const INSTRUCTIONS = `ok-fine holds shared project knowledge outside the codebase, as OKF v0.2 markdown concepts grouped into projects. Project resolution order: 1) A project named explicitly by the user or by harness project/workspace instructions (e.g. Claude Project instructions, a custom GPT's instructions): use it. 2) In a git repository with a remote: call list_projects with \`repository\` set to the remote URL (\`git remote get-url origin\`, falling back to the first remote); use the returned project(s) for every read and write. If none match, say the repository is not onboarded and offer to onboard it (ok-fine-onboard skill). 3) Otherwise (no repository, no shell, or no remote): call orient with your question to rank relevant projects and concepts across the organization, or call list_projects with \`team\` and/or \`query\` (or no arguments). Never run git commands and never say "not onboarded" or offer onboarding on this path. If several fit, read from all; ask before writing only when the write target is ambiguous. Search before planning or editing; record durable decisions, conventions, and runbooks afterwards.
8
+ 1. Discover: orient (cross-org ranked discovery with working rules), get_index (progressive disclosure) or search_concepts with \`project\` (omitting \`project\` searches all projects).
9
+ 2. Read: read_concept returns frontmatter, body, trust tier (proposed | unverified | machine-confirmed | human-reviewed), staleness, and links. Freshness comes first: a concept that is stale is a lead to re-check whatever its tier. For concepts with code sources, that means when code changed since \`sources[].commit\` or that commit is not an ancestor of HEAD (unmerged or rebased away); for concepts without code sources, fresh means \`stale_after\` is set and not past (additionally, when a tool available in the session can fetch a source's \`resource\` URL and report its last-modified time, a source modified after \`generated.at\` counts as drifted). Among fresh concepts prefer higher trust tiers. Knowledge about unmerged work is written at its usual id (e.g. \`decisions/<slug>\`, never over an existing concept) with a \`proposal: { ref: <URI> }\` frontmatter key; such concepts have trust tier \`proposed\` and are not current truth. When one you read has landed in the mainline, rewrite it as current truth without the \`proposal\` key and verify it; when its work was abandoned, set \`status: deprecated\`. Deprecated concepts are history; when code contradicts a concept, trust the code and update the concept.
10
+ 3. Write: write_concept with frontmatter containing \`type\` (e.g. Decision, Convention, Architecture, Component, Playbook, Interface, Reference) plus \`title\`, \`description\`, \`tags\`, and \`stale_after\` (ISO 8601, e.g. 180 days ahead). Record provenance in \`sources\` (each with \`resource\` and a stable \`id\`; code sources carry \`commit\`; non-code sources use a URL or stable URI \`resource\` without \`commit\`) and cite claims with footnotes [^id]. Link concepts with bundle-absolute links such as [orders](/tables/orders.md). After re-checking a concept against the code or sources, refresh \`sources[].commit\` and \`stale_after\` with write_concept, then call verify_concept.
11
+ 4. Pass \`actor\` as <harness>/<model> (e.g. claude-code/claude-opus-4-5, claude-desktop/<model>, claude-ai/<model>, chatgpt/<model>, gemini-cli/gemini-2.5-pro). Use human:<email>, with the email from \`git config user.email\` in a repository or the email confirmed by the user outside one, only when the user personally reviewed the concept; on forbidden_actor, report both identities instead of retrying as another. The server stamps \`generated\`; \`verified\` changes only through verify_concept.
12
12
  5. When updating, pass expectedRevision from read_concept (null to create only).
13
13
  6. An \`unresolved_conflict\` issue (lint_project, read_concept) is a write ok-fine accepted but could not merge with a concurrent edit from another ok-fine instance. Before editing the affected file: read_conflict, merge \`preserved\` into \`current\` (use \`base\` to see what each side changed), write the result with expectedRevision set to \`current.revision\`, then call resolve_conflict with every file path of the conflict. Resolve only conflicts of the project you are working in.
14
14
  7. Prefer \`status: deprecated\` over delete_concept. index.md and log.md are maintained by the server; do not write them. A project is bound to repositories through the \`repositories\` list in its overview frontmatter.
@@ -62,30 +62,49 @@ export function createMcpServer(service, principal, log) {
62
62
  const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
63
63
  tool("list_projects", "read", {
64
64
  title: "List projects",
65
- description: "List projects (OKF bundles) with concept and staleness counts and the git repositories bound to each; pass repository to find the projects that hold a codebase's knowledge.",
65
+ description: "Call first to pick the project. In a git repository pass `repository` (the remote URL); outside a git repository (e.g. desktop chat), call with `team` and/or `query` (or no arguments) and choose by title, description, teams, domains, keywords; a project named by the user or project instructions wins. Lists projects (OKF bundles) with concept and staleness counts, bound git repositories, and routing metadata (teams, domains, audience, keywords, owners).",
66
66
  inputSchema: z.object({
67
67
  repository: z
68
68
  .string()
69
69
  .optional()
70
- .describe("Git remote URL of the current repository, e.g. the output of `git remote get-url origin`; returns only the projects bound to it"),
70
+ .describe("Git remote URL of the current repository, e.g. the output of `git remote get-url origin`; returns only the projects bound to it. Omit outside a git repository."),
71
+ team: z.string().optional().describe("Case-insensitive match on the overview's `teams`"),
72
+ query: z
73
+ .string()
74
+ .optional()
75
+ .describe("Keywords matched against title, description, `domains`, and `keywords`; any term matches by word prefix"),
76
+ }),
77
+ annotations: readOnly,
78
+ }, (a) => service.listProjects(principal, { repository: a.repository, team: a.team, query: a.query }));
79
+ tool("orient", "read", {
80
+ title: "Orient across organization knowledge",
81
+ description: "Call before answering any question about how the organization works: processes, policies, customers, products, campaigns. Returns ranked projects, each with its top concepts and scores, plus working rules (freshness first, trust tier, citing sources, and rephrasing queries).",
82
+ inputSchema: z.object({
83
+ question: z
84
+ .string()
85
+ .min(1)
86
+ .max(512)
87
+ .describe("The user's question, task, or search terms to orient against (max 512 chars)"),
88
+ project: z.string().optional().describe("Restrict orientation to a single project"),
89
+ limit: z.number().int().min(1).max(50).optional().describe("Maximum projects to return (default: 5)"),
71
90
  }),
72
91
  annotations: readOnly,
73
- }, (a) => service.listProjects({ repository: a.repository }));
92
+ }, (a) => service.orient(principal, { question: a.question, project: a.project, limit: a.limit }));
74
93
  tool("get_index", "read", {
75
94
  title: "Get directory index",
76
95
  description: "Progressive-disclosure listing of one bundle directory: its concepts grouped by type, files, and subdirectories.",
77
96
  inputSchema: z.object({ project, path: z.string().optional().describe("directory, default bundle root") }),
78
97
  annotations: readOnly,
79
- }, (a) => service.getIndex(a.project, a.path ?? ""));
98
+ }, (a) => service.getIndex(principal, a.project, a.path ?? ""));
80
99
  tool("read_concept", "read", {
81
100
  title: "Read concept",
82
101
  description: "Read one concept with frontmatter, body, derived trust/staleness, links, and lint issues; returns `revision` to pass back as expectedRevision when updating.",
83
102
  inputSchema: z.object({ project, id }),
84
103
  annotations: readOnly,
85
- }, (a) => service.readConcept(a.project, a.id));
104
+ }, (a) => service.readConcept(principal, a.project, a.id));
86
105
  tool("search_concepts", "read", {
87
106
  title: "Search concepts",
88
- description: "Keyword full-text search over concepts, optionally filtered by project, type, tags, status, trust tier, and staleness.",
107
+ description: "Keyword full-text search over concepts, optionally filtered by project, type, tags, status, trust tier, and staleness. Omitting project searches every project (use when no project is resolved yet or outside a repository); results carry project to use for follow-up read_concept calls. Cite concepts read.",
89
108
  inputSchema: z.object({
90
109
  query: z.string().max(512).optional(),
91
110
  project: z.string().optional(),
@@ -97,32 +116,32 @@ export function createMcpServer(service, principal, log) {
97
116
  limit,
98
117
  }),
99
118
  annotations: readOnly,
100
- }, (a) => service.search(a));
119
+ }, (a) => service.search(principal, a));
101
120
  tool("get_history", "read", {
102
121
  title: "Get history",
103
122
  description: "List git commits that changed a concept, or the whole project when id is omitted.",
104
123
  inputSchema: z.object({ project, id: id.optional(), limit }),
105
124
  annotations: readOnly,
106
- }, (a) => service.history(a.project, a.id, a.limit));
125
+ }, (a) => service.history(principal, a.project, a.id, a.limit));
107
126
  tool("read_file", "read", {
108
127
  title: "Read file",
109
128
  description: "Read any text file in a bundle verbatim (including index.md, log.md, and non-markdown assets).",
110
129
  inputSchema: z.object({ project, path: z.string().describe("bundle-relative file path") }),
111
130
  annotations: readOnly,
112
- }, (a) => service.readFile(a.project, a.path));
131
+ }, (a) => service.readFile(principal, a.project, a.path));
113
132
  tool("lint_project", "read", {
114
133
  title: "Lint project",
115
134
  description: "Check a bundle for OKF v0.2 conformance and return errors, warnings, and info issues.",
116
135
  inputSchema: z.object({ project }),
117
136
  annotations: readOnly,
118
- }, (a) => service.lint(a.project));
137
+ }, (a) => service.lint(principal, a.project));
119
138
  const conflictId = z.string().describe("Conflict id from list_conflicts or an unresolved_conflict lint issue");
120
139
  tool("list_conflicts", "read", {
121
140
  title: "List conflicts",
122
141
  description: "List the project's unresolved conflicts: writes the server accepted but could not reconcile with a concurrent edit from another ok-fine instance. Each lists changed files (divergent = the current version also changed) and the commits that made them.",
123
142
  inputSchema: z.object({ project }),
124
143
  annotations: readOnly,
125
- }, (a) => service.listConflicts(a.project));
144
+ }, (a) => service.listConflicts(principal, a.project));
126
145
  tool("read_conflict", "read", {
127
146
  title: "Read conflict file",
128
147
  description: "Read one file of a conflict: `preserved` (the unapplied write), `base` (common ancestor), and `current` (with the revision to pass as expectedRevision when writing the merge). null = absent on that side.",
@@ -132,7 +151,7 @@ export function createMcpServer(service, principal, log) {
132
151
  path: z.string().describe("bundle-relative file path, e.g. tables/orders.md"),
133
152
  }),
134
153
  annotations: readOnly,
135
- }, (a) => service.readConflict(a.project, a.id, a.path));
154
+ }, (a) => service.readConflict(principal, a.project, a.id, a.path));
136
155
  tool("resolve_conflict", "write", {
137
156
  title: "Resolve conflict",
138
157
  description: "Discard a conflict's preserved copy after merging what should survive with write_concept/write_file. `paths` must list every file of the conflict, confirming each was reconciled; only this project's part of the conflict is resolved.",
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Inclusive upper bounds (ms) of the latency histogram buckets; one overflow bucket follows the last.
3
+ * Changing them changes the database columns: bump SCHEMA_VERSION in store.ts.
4
+ */
5
+ export const LATENCY_BUCKETS_MS = [
6
+ 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000,
7
+ ];
8
+ export const BUCKET_COUNT = LATENCY_BUCKETS_MS.length + 1;
9
+ export function bucketIndex(ms) {
10
+ const i = LATENCY_BUCKETS_MS.findIndex((upper) => ms <= upper);
11
+ return i === -1 ? BUCKET_COUNT - 1 : i;
12
+ }
13
+ /** Estimates quantile q (0..1) by linear interpolation inside the bucket holding the rank, clamped to [minMs, maxMs]. */
14
+ export function quantile(buckets, q, minMs, maxMs) {
15
+ let total = 0;
16
+ for (const c of buckets)
17
+ total += c;
18
+ if (total === 0)
19
+ return 0;
20
+ const rank = q * total;
21
+ let cum = 0;
22
+ for (let i = 0; i < buckets.length; i++) {
23
+ const c = buckets[i] ?? 0;
24
+ if (c === 0)
25
+ continue;
26
+ if (cum + c >= rank) {
27
+ const lower = i === 0 ? 0 : (LATENCY_BUCKETS_MS[i - 1] ?? 0);
28
+ const upper = i < LATENCY_BUCKETS_MS.length ? (LATENCY_BUCKETS_MS[i] ?? maxMs) : maxMs;
29
+ const value = lower + (upper - lower) * ((rank - cum) / c);
30
+ return Math.min(Math.max(value, minMs), maxMs);
31
+ }
32
+ cum += c;
33
+ }
34
+ return maxMs;
35
+ }
36
+ //# sourceMappingURL=histogram.js.map
@@ -0,0 +1,146 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { finished, Readable } from "node:stream";
3
+ import { OkfError } from "../errors.js";
4
+ /** The service reports client errors only as OkfError with a 4xx status; anything else is a server error. */
5
+ export function outcomeOf(err) {
6
+ return err instanceof OkfError && err.status < 500 ? "client_error" : "server_error";
7
+ }
8
+ async function time(sink, layer, op, fn) {
9
+ const startedAt = performance.now();
10
+ try {
11
+ const value = await fn();
12
+ sink.record(layer, op, startedAt, "ok");
13
+ return value;
14
+ }
15
+ catch (err) {
16
+ sink.record(layer, op, startedAt, outcomeOf(err));
17
+ throw err;
18
+ }
19
+ }
20
+ /** Records once the stream ends, errors, or is destroyed. */
21
+ function timeStream(sink, layer, op, startedAt, stream) {
22
+ finished(stream, (err) => sink.record(layer, op, startedAt, err ? outcomeOf(err) : "ok"));
23
+ return stream;
24
+ }
25
+ class InstrumentedTx {
26
+ inner;
27
+ sink;
28
+ constructor(inner, sink) {
29
+ this.inner = inner;
30
+ this.sink = sink;
31
+ }
32
+ writeFile(project, path, content) {
33
+ return time(this.sink, "storage", "tx.writeFile", () => this.inner.writeFile(project, path, content));
34
+ }
35
+ deleteFile(project, path) {
36
+ return time(this.sink, "storage", "tx.deleteFile", () => this.inner.deleteFile(project, path));
37
+ }
38
+ deleteProject(project) {
39
+ return time(this.sink, "storage", "tx.deleteProject", () => this.inner.deleteProject(project));
40
+ }
41
+ replaceProject(project, source) {
42
+ return time(this.sink, "storage", "tx.replaceProject", () => this.inner.replaceProject(project, source));
43
+ }
44
+ resolveConflict(project, id) {
45
+ return time(this.sink, "storage", "tx.resolveConflict", () => this.inner.resolveConflict(project, id));
46
+ }
47
+ }
48
+ /** Times every StorageBackend and StorageTx call under layer `storage`, op = method name. */
49
+ export class InstrumentedStorage {
50
+ inner;
51
+ sink;
52
+ constructor(inner, sink) {
53
+ this.inner = inner;
54
+ this.sink = sink;
55
+ }
56
+ projects() {
57
+ return time(this.sink, "storage", "projects", () => this.inner.projects());
58
+ }
59
+ tree(project) {
60
+ return time(this.sink, "storage", "tree", () => this.inner.tree(project));
61
+ }
62
+ readFile(project, path) {
63
+ return time(this.sink, "storage", "readFile", () => this.inner.readFile(project, path));
64
+ }
65
+ /** Duration includes lock wait, the work, commit, and push. */
66
+ transaction(spec, work) {
67
+ return time(this.sink, "storage", "transaction", () => this.inner.transaction(spec, (tx) => work(new InstrumentedTx(tx, this.sink))));
68
+ }
69
+ setResyncHandler(handler) {
70
+ this.inner.setResyncHandler((project, tx) => handler(project, new InstrumentedTx(tx, this.sink)));
71
+ }
72
+ history(project, path, limit) {
73
+ return time(this.sink, "storage", "history", () => this.inner.history(project, path, limit));
74
+ }
75
+ archive(project) {
76
+ const startedAt = performance.now();
77
+ let stream;
78
+ try {
79
+ stream = this.inner.archive(project);
80
+ }
81
+ catch (err) {
82
+ this.sink.record("storage", "archive", startedAt, outcomeOf(err));
83
+ throw err;
84
+ }
85
+ return timeStream(this.sink, "storage", "archive", startedAt, stream);
86
+ }
87
+ sync() {
88
+ return time(this.sink, "storage", "sync", () => this.inner.sync());
89
+ }
90
+ syncStatus() {
91
+ return time(this.sink, "storage", "syncStatus", () => this.inner.syncStatus());
92
+ }
93
+ conflicts(project) {
94
+ return time(this.sink, "storage", "conflicts", () => this.inner.conflicts(project));
95
+ }
96
+ readConflictFile(project, id, side, path) {
97
+ return time(this.sink, "storage", "readConflictFile", () => this.inner.readConflictFile(project, id, side, path));
98
+ }
99
+ close() {
100
+ return this.inner.close();
101
+ }
102
+ }
103
+ /**
104
+ * Times every KnowledgeService method call under layer `service`, op = method name. A Proxy covers methods added
105
+ * later; methods run bound to the raw service, so its internal `this.*` calls are not counted twice.
106
+ */
107
+ export function instrumentService(service, sink) {
108
+ const wrappers = new Map();
109
+ return new Proxy(service, {
110
+ get(target, prop) {
111
+ const value = Reflect.get(target, prop);
112
+ if (typeof prop !== "string" || prop === "constructor" || typeof value !== "function")
113
+ return value;
114
+ let wrapper = wrappers.get(prop);
115
+ if (!wrapper) {
116
+ wrapper = (...args) => {
117
+ const startedAt = performance.now();
118
+ let result;
119
+ try {
120
+ result = Reflect.apply(value, target, args);
121
+ }
122
+ catch (err) {
123
+ sink.record("service", prop, startedAt, outcomeOf(err));
124
+ throw err;
125
+ }
126
+ if (result instanceof Promise) {
127
+ return result.then((v) => {
128
+ sink.record("service", prop, startedAt, "ok");
129
+ return v;
130
+ }, (err) => {
131
+ sink.record("service", prop, startedAt, outcomeOf(err));
132
+ throw err;
133
+ });
134
+ }
135
+ if (result instanceof Readable)
136
+ return timeStream(sink, "service", prop, startedAt, result);
137
+ sink.record("service", prop, startedAt, "ok");
138
+ return result;
139
+ };
140
+ wrappers.set(prop, wrapper);
141
+ }
142
+ return wrapper;
143
+ },
144
+ });
145
+ }
146
+ //# sourceMappingURL=instrument.js.map