syndai 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to the `syndai` npm package. Versions follow semver.
4
4
 
5
+ ## 0.7.0
6
+
7
+ Land your own branch, and keep a folder in a project's Knowledge.
8
+
9
+ - `land [branch] [--app A] [--no-wait]` lands your pushed `people/<you>/<name>` branch on a project
10
+ Syndai hosts: the same checks, review and landing slot as Syndai's own changes, then it watches
11
+ the landing. It prints any live work on the same files first. A `people/<you>/t12` branch
12
+ delivers task t12 when it goes live.
13
+ - `sync <dir> [--app A] [--watch]` keeps a folder (an Obsidian vault, any folder of notes) in
14
+ the project's Knowledge, where its runs and chat search it. Each sync sends every file's
15
+ sha256 and pushes only the new or changed ones: text files (`.md`, `.txt`, `.csv`, `.json`,
16
+ `.yaml` and the like) as their text, any other file by name only. A file deleted here is
17
+ removed there. Hidden files and folders (`.obsidian/`, `.trash/`, `.git/`), `node_modules/`,
18
+ symlinks, files over 10 MB and the patterns in `<dir>/.syndaiignore` stay out.
19
+ - `--watch` syncs again a second after the last change, one sync at a time, until Ctrl-C; an
20
+ edit is searchable within seconds. Only changed files are read again.
21
+ - A paused folder, or a folder of the same name someone else syncs, is refused (exit 1) with the
22
+ server's sentence. It needs `coding:knowledge:sync` (owners and admins); on a terminal the
23
+ first sync asks for it in the browser.
24
+ - Picking the project from the git remote now matches `github.com/<owner>/<repo>` built from the
25
+ project's name. The API no longer sends repo URLs. Versions up to 0.6.0 read those URLs, so
26
+ they skip this rule and fall through to the only project or the picker.
27
+
5
28
  ## 0.6.0
6
29
 
7
30
  The steps that stay in the browser are one command away, and an agent can sign up.
package/README.md CHANGED
@@ -44,9 +44,11 @@ README is for whoever works on the package.
44
44
  | `answer <run> [--input FILE\|-]` | answer an open question (`--input`: `{"entries":[...]}` from a file or stdin) |
45
45
  | `review <run> [--retry \| --close]` | see why a change is held, then approve it in the browser |
46
46
  | `go-live`, `revert`, `cancel <run>` | land actions |
47
+ | `land [branch] [--app A] [--no-wait]` | on a project Syndai hosts, check, review and land your pushed `people/<you>/<name>` branch (default: the current one) |
47
48
  | `publish [--app A]` | publish (or redeploy) the project as a hosted app and print its live URL (`coding:apps:publish`) |
48
49
  | `connect <provider>` | print, and open on a terminal, the workspace page that connects it: `github` -> `settings#repositories`, `sentry` -> `sentry`, the plan logins, OpenRouter, Slack and social accounts -> `connections?connect=<provider>`. Finish in the browser; no key reads connection status |
49
50
  | `billing [--open]`, `packs`, `buy <pack>`, `plan <tier> [--interval month\|year] [--country CC]`, `card`, `cancel` | plan, balance (packs + included left - holds, as the portal shows it) and the billing page; `buy`/`plan` print and open a checkout a person pays (nothing is charged until then; `--country` else the locale's region else US prices it); `card`/`cancel` and an existing plan go to the billing page |
51
+ | `sync <dir> [--app A] [--watch]` | keep a folder (an Obsidian vault) in the project's Knowledge (`coding:knowledge:sync`): see [Folder sync](#folder-sync) |
50
52
  | `status [--app A]` | what is running, and everything that needs you |
51
53
  | `settings [--app A] [get \| set k=v...]` | `autonomy`, `feature_requests`, `bug_scan`, `protected_paths` (`+=` adds) |
52
54
  | `mcp install claude\|cursor\|codex` | add the Syndai MCP server to that tool's user config, with its own key |
@@ -79,6 +81,30 @@ Exit codes: 0 done, 1 failed/reverted/cancelled, 2 usage or no project, 3 needs
79
81
  4 live but unverified, 5 auth/scope/plan, 6 `--timeout` reached, 7 service
80
82
  unavailable after retries, 130 interrupted (Ctrl-C detaches; it never cancels).
81
83
 
84
+ ## Folder sync
85
+
86
+ `syndai sync <dir>` (`src/sync.js`, SPEC-knowledge-sources K6) walks `<dir>`, hashes every
87
+ file and sends the manifest (`compareKnowledgeFolder`, at most 20,000 `{path, sha256}`). The
88
+ server deletes the files missing from it and answers `need`, the paths new or changed; those go
89
+ up through `pushKnowledgeFolderFiles` in batches of at most 500 files and 8 MB, each read again
90
+ so the hash sent matches the bytes. Text suffixes (the server's `TEXT_SUFFIXES`) carry their
91
+ UTF-8 `content`; any other file goes with `content: null` (recorded by name). The folder's name
92
+ is the basename of `<dir>`; paths are `/`-separated and relative to it. Both calls are
93
+ idempotent (a manifest is a compare, a push upserts by path), so they retry like a GET.
94
+
95
+ Skipped: names starting with `.` (so `.obsidian/`, `.trash/`, `.git/`, `.syndaiignore` itself),
96
+ `node_modules/`, symlinks (never followed), files over 10 MB (left out of the manifest, listed
97
+ as skipped) and `<dir>/.syndaiignore` patterns: one a line, `#` comments, `dir/` for folders,
98
+ a pattern with a `/` anchored at the root, `*`, `?` and `**`; no `!`. A 409 (paused, or another
99
+ person's folder of that name) prints the detail and exits 1; per-file `failed` rows are printed
100
+ and do not change the exit code.
101
+
102
+ `--watch` keeps `{size, mtimeMs, sha256}` per file, so a re-sync re-reads only what moved (a
103
+ hash taken within 2 s of the mtime is redone, for coarse clocks). `io.watchDir` (`fs.watch`,
104
+ recursive) is the trigger, debounced 1 s, one sync at a time; a stat-only check every 10 s
105
+ catches missed events, and a watcher error falls back to it. An unreachable API is retried
106
+ after 30 s; any other error ends the watch. Ctrl-C exits 130.
107
+
82
108
  ## Layout
83
109
 
84
110
  - `bin/syndai.js`: the entrypoint; wires SIGINT to an AbortSignal.
@@ -95,6 +121,7 @@ unavailable after retries, 130 interrupted (Ctrl-C detaches; it never cancels).
95
121
  - `src/secrets.js`: `env`, `secrets` and `run` (environment choice, the production step-up retry).
96
122
  - `src/dotenv.js`: the `.env` reader (`import`) and writer (`download`); errors name lines, never values.
97
123
  - `src/portal.js`: `connect` and `billing`, the hand-offs to a workspace page.
124
+ - `src/sync.js`: `sync` (the walk, `.syndaiignore`, manifest and batched push, `--watch`).
98
125
  - `src/changes.js`, `src/settings.js`, `src/mcp.js`: the other commands.
99
126
 
100
127
  ## Development
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "syndai",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "The Syndai owner CLI: say what to change, and Syndai builds it, checks it and puts it live. Also installs the Syndai MCP server into Claude Code, Cursor and Codex.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/changes.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // edit-text. Each command is a thin call to one route plus, where there is an outcome to
3
3
  // wait for, `watchChange`.
4
4
 
5
+ import { execFileSync } from "node:child_process";
5
6
  import { randomUUID } from "node:crypto";
6
7
  import { readFileSync } from "node:fs";
7
8
  import path from "node:path";
@@ -175,6 +176,37 @@ async function uploadImage(ctx, file) {
175
176
  return { object_key: stored.object_key, content_type: contentType };
176
177
  }
177
178
 
179
+ /** `syndai land [branch]`: validate, review and land your pushed people/<you>/<name> branch. */
180
+ export async function land(ctx, [branch]) {
181
+ const { flags, ui } = ctx;
182
+ const name = branch ?? currentBranch();
183
+ if (!name) throw new CliError("Which branch? syndai land people/<you>/<name>", EXIT.USAGE);
184
+ const project = await resolveProject(ctx, { app: flags.app });
185
+ const landing = await ctx.client.call("landBranch", {
186
+ params: { coding_repository_id: project.id },
187
+ body: { branch: name },
188
+ retry: true, // one landing per pushed head: a retry answers the same run
189
+ });
190
+ ui.progress(ui.dim(`Landing ${name} at ${landing.head_sha.slice(0, 12)}: change ${landing.run_id}`));
191
+ for (const warning of landing.overlap ?? []) ui.progress(`Heads up: ${warning}`);
192
+ if (flags["no-wait"]) {
193
+ return finish(ctx, await getChange(ctx, landing.run_id), {
194
+ exitCode: EXIT.OK,
195
+ next: `syndai watch ${landing.run_id}`,
196
+ });
197
+ }
198
+ return watchChange(ctx, landing.run_id, { timeoutMs: timeoutOf(flags) });
199
+ }
200
+
201
+ function currentBranch() {
202
+ try {
203
+ const out = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" }).trim();
204
+ return out && out !== "HEAD" ? out : null;
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+
178
210
  export async function watch(ctx, runId) {
179
211
  return watchChange(ctx, needRun(runId, "watch"), {
180
212
  timeoutMs: timeoutOf(ctx.flags),
package/src/main.js CHANGED
@@ -2,10 +2,11 @@
2
2
  // process I/O as `io`, so tests drive it in-process with a fake server and fake clocks.
3
3
 
4
4
  import { spawn } from "node:child_process";
5
+ import { watch as watchFs } from "node:fs";
5
6
  import { setTimeout as sleep } from "node:timers/promises";
6
7
  import { parseArgs } from "node:util";
7
8
  import { createContext, login, logout, signup, whoami } from "./auth.js";
8
- import { answer, cancel, change, editText, goLive, newProject, review, revert, status, watch } from "./changes.js";
9
+ import { answer, cancel, change, editText, goLive, land, newProject, review, revert, status, watch } from "./changes.js";
9
10
  import { ApiError, CliError, EXIT, explain, PORTAL_PAGES } from "./errors.js";
10
11
  import { VERSION } from "./http.js";
11
12
  import { mcp } from "./mcp.js";
@@ -15,6 +16,7 @@ import { api } from "./api.js";
15
16
  import { repeat, schedule } from "./schedules.js";
16
17
  import { env, run, secrets } from "./secrets.js";
17
18
  import { settings } from "./settings.js";
19
+ import { sync } from "./sync.js";
18
20
  import { closeInput, openBrowser, openPage, page } from "./ui.js";
19
21
 
20
22
  const str = { type: "string" };
@@ -49,6 +51,15 @@ const COMMANDS = {
49
51
  run: (ctx, args) => newProject(ctx, args),
50
52
  stepUp: false,
51
53
  },
54
+ land: {
55
+ usage: "land [branch] [--app A] [--no-wait] [--timeout D]",
56
+ summary:
57
+ "On a project Syndai hosts: check, review and land your pushed branch (default: the current one, people/<you>/<name>).\n" +
58
+ " A branch named for a task (people/<you>/t12) delivers it when the landing goes live.",
59
+ options: { app: str, "no-wait": bool, timeout: str },
60
+ run: (ctx, args) => land(ctx, args),
61
+ stepUp: false,
62
+ },
52
63
  watch: {
53
64
  usage: "watch <run> [--timeout D] [--logs]",
54
65
  summary: "Follow a change to its outcome (Ctrl-C detaches; the change keeps going).",
@@ -182,6 +193,14 @@ const COMMANDS = {
182
193
  options: { app: str },
183
194
  run: (ctx, args) => settings(ctx, args),
184
195
  },
196
+ sync: {
197
+ usage: "sync <dir> [--app A] [--watch]",
198
+ summary:
199
+ "Sync a folder (an Obsidian vault) into a project's Knowledge: new and changed files go up, deleted ones are removed;\n" +
200
+ " hidden files, node_modules/ and <dir>/.syndaiignore patterns stay out. --watch: keep syncing as files change (Ctrl-C stops).",
201
+ options: { app: str, watch: bool },
202
+ run: (ctx, args) => sync(ctx, args),
203
+ },
185
204
  mcp: {
186
205
  usage: "mcp install claude|cursor|codex",
187
206
  summary: "Add Syndai to an AI coding tool, with its own key.",
@@ -249,6 +268,12 @@ export function processIo(signal) {
249
268
  sleep: (ms, abortSignal) => sleep(ms, undefined, { signal: abortSignal }),
250
269
  openUrl: openBrowser,
251
270
  page: (text) => page(process, text),
271
+ /** `syndai sync --watch`: `onChange(file)` for a change anywhere under `dir` (`file` relative, or null); returns `{close}`. */
272
+ watchDir: (dir, onChange, onError) => {
273
+ const watcher = watchFs(dir, { recursive: true }, (_event, file) => onChange(file ? String(file) : null));
274
+ watcher.on("error", onError);
275
+ return watcher;
276
+ },
252
277
  /**
253
278
  * `syndai run`: the child shares the terminal; resolves `{code, signal}` (ENOENT rejects).
254
279
  * While it runs the parent never exits first (that would orphan a child holding the
package/src/projects.js CHANGED
@@ -28,7 +28,8 @@ export async function listProjects(ctx) {
28
28
  full_name: repo.full_name,
29
29
  live_url: app?.url ?? null,
30
30
  slug: app?.publication?.slug ?? null,
31
- repo_urls: [repo.repo_url, repo.clone_url, repo.html_url].filter(Boolean),
31
+ // A local clone can only be of a GitHub repo (Syndai-hosted ones are never cloned).
32
+ remote: repo.provider === "github" ? normalizeRemote(`github.com/${repo.full_name}`) : null,
32
33
  protected_paths: repo.protected_paths ?? [],
33
34
  customer_id: customerId,
34
35
  };
@@ -84,7 +85,7 @@ export async function resolveProject(ctx, { app, required = true, picker = true,
84
85
  }
85
86
  }
86
87
  const remotes = gitRemotes(io.cwd);
87
- const byRemote = projects.filter((p) => p.repo_urls.some((u) => remotes.has(normalizeRemote(u))));
88
+ const byRemote = projects.filter((p) => p.remote && remotes.has(p.remote));
88
89
  if (byRemote.length === 1) return byRemote[0];
89
90
  if (projects.length === 1) return projects[0];
90
91
  if (picker && ui.interactive && projects.length > 1) return pickInteractively(ctx, projects);
package/src/routes.js CHANGED
@@ -28,8 +28,9 @@ export const ROUTES = Object.freeze({
28
28
  packs: ["GET", `${EXT}/purchases/packs`],
29
29
  packCheckout: ["POST", `${EXT}/purchases/pack-checkout`],
30
30
  planCheckout: ["POST", `${EXT}/purchases/subscription-checkout`],
31
- getBugScan: ["GET", `${EXT}/repositories/{repository_id}/bug-scan`],
32
- setBugScan: ["PUT", `${EXT}/repositories/{repository_id}/bug-scan`],
31
+ listScans: ["GET", `${EXT}/repositories/{repository_id}/scans`],
32
+ setScanRoutine: ["PUT", `${EXT}/repositories/{repository_id}/scans`],
33
+ runScanNow: ["POST", `${EXT}/repositories/{repository_id}/scans/{kind}/run`],
33
34
  getPolicy: ["GET", `${EXT}/policy/defaults`],
34
35
  putPolicy: ["PUT", `${EXT}/policy/defaults`],
35
36
  imageIngest: ["POST", `${EXT}/runs/image-ingest`],
@@ -44,7 +45,10 @@ export const ROUTES = Object.freeze({
44
45
  landRetry: ["POST", `${EXT}/runs/{run_id}/land/retry`],
45
46
  landClose: ["POST", `${EXT}/runs/{run_id}/land/close`],
46
47
  goLive: ["POST", `${EXT}/runs/{run_id}/land/go-live`],
48
+ landBranch: ["POST", `${EXT}/repos/{coding_repository_id}/land`],
47
49
  heldDiff: ["GET", `${EXT}/runs/{run_id}/land/held-diff`],
50
+ compareKnowledgeFolder: ["POST", `${EXT}/repos/{coding_repository_id}/knowledge/folders/{name}/manifest`],
51
+ pushKnowledgeFolderFiles: ["POST", `${EXT}/repos/{coding_repository_id}/knowledge/folders/{name}/files`],
48
52
  listSchedules: ["GET", `${EXT}/schedules`],
49
53
  createSchedule: ["POST", `${EXT}/schedules`],
50
54
  getSchedule: ["GET", `${EXT}/schedules/{schedule_id}`],
package/src/settings.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // Settings live in four places server-side:
4
4
  // autonomy repo-scoped policy default GET/PUT policy/defaults
5
5
  // feature_requests the app's publication GET/PATCH .../publication
6
- // bug_scan the repo's Bug Finder schedule GET/PUT repositories/{id}/bug-scan
6
+ // bug_scan the repo's weekly Code bugs check GET/PUT repositories/{id}/scans
7
7
  // protected_paths the repo row PATCH repos/{id} (a key may only tighten)
8
8
 
9
9
  import { ApiError, CliError, EXIT, explain } from "./errors.js";
@@ -66,9 +66,9 @@ async function apply(ctx, project, { key, op, value }) {
66
66
  body: { feature_requests: value },
67
67
  });
68
68
  } else if (key === "bug_scan") {
69
- await client.call("setBugScan", {
69
+ await client.call("setScanRoutine", {
70
70
  params: { repository_id: project.id },
71
- body: { enabled: value === "on" },
71
+ body: { kind: "code", cadence: value === "on" ? "weekly" : "off" },
72
72
  });
73
73
  } else {
74
74
  const added = value
@@ -91,7 +91,7 @@ async function read(ctx, project) {
91
91
  const [policy, publication, bugScan] = await Promise.all([
92
92
  settle(client.call("getPolicy", { query: { coding_repository_id: project.id } })),
93
93
  settle(client.call("getPublication", { params: ids })),
94
- settle(client.call("getBugScan", { params: { repository_id: project.id } })),
94
+ settle(client.call("listScans", { params: { repository_id: project.id } })),
95
95
  ]);
96
96
  const unavailable = {};
97
97
  for (const [key, got] of [
@@ -104,7 +104,7 @@ async function read(ctx, project) {
104
104
  return {
105
105
  autonomy: policy.value?.config?.autonomy ?? null,
106
106
  feature_requests: publication.value?.feature_requests ?? null,
107
- bug_scan: bugScan.value ? (bugScan.value.enabled ? "on" : "off") : null,
107
+ bug_scan: bugScan.value ? codeScan(bugScan.value) : null,
108
108
  protected_paths: project.protected_paths,
109
109
  unavailable,
110
110
  };
@@ -128,3 +128,9 @@ async function orNull(promise) {
128
128
  throw err;
129
129
  }
130
130
  }
131
+
132
+ // The Code bugs routine is "on" while it runs on a cadence with its spend agreed.
133
+ function codeScan(scans) {
134
+ const code = (scans.kinds ?? []).find((k) => k.kind === "code");
135
+ return code && code.cadence !== "off" && !code.needs_consent ? "on" : "off";
136
+ }
package/src/sync.js ADDED
@@ -0,0 +1,389 @@
1
+ // `syndai sync <dir> [--app A] [--watch]`: keep a local folder (an Obsidian vault, any folder of
2
+ // notes) in a project's Knowledge (SPEC-knowledge-sources K6; docs: syndai.ai/docs/cli, "Sync a
3
+ // folder into Knowledge").
4
+ //
5
+ // One sync: walk the folder, hash every file, send the manifest. The server deletes the files no
6
+ // longer here and answers the paths that are new or changed; those are pushed in batches of at
7
+ // most 500 files and 8 MB. A text file goes as its UTF-8 text, any other file by name only. The
8
+ // folder's name in Knowledge is the directory's basename.
9
+ //
10
+ // Skipped: hidden files and folders (a name starting with `.`, so `.obsidian/`, `.trash/` and
11
+ // `.git/`), `node_modules/`, symlinks (never followed: one may point out of the folder, or
12
+ // loop), files over 10 MB, and the patterns in `<dir>/.syndaiignore`.
13
+ //
14
+ // `--watch` re-runs the whole sync once the folder has been quiet for a second, one sync at a
15
+ // time; Ctrl-C stops it. A file is hashed again only when its size or mtime moved (or it was
16
+ // hashed within 2 s of its mtime, which a coarse clock could hide), so a re-sync costs the
17
+ // changed files, not the vault. File events (`fs.watch`) are the trigger; a stat-only
18
+ // check every 10 s catches what they miss (a network drive, a dropped event), so an edit is in
19
+ // Knowledge within seconds either way.
20
+ //
21
+ // File I/O is synchronous: one file at a time through promises is ~20x slower on a big vault,
22
+ // and nothing else runs meanwhile.
23
+
24
+ import { createHash } from "node:crypto";
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ import { CliError, EXIT, UnavailableError, explain } from "./errors.js";
28
+ import { interrupted } from "./http.js";
29
+ import { projectLabel, resolveProject } from "./projects.js";
30
+
31
+ /** Sent as text; the server's `TEXT_SUFFIXES` (backend/src/features/project_sources/ingest.py). */
32
+ export const TEXT_SUFFIXES = new Set([
33
+ ".md",
34
+ ".markdown",
35
+ ".mdx",
36
+ ".txt",
37
+ ".text",
38
+ ".csv",
39
+ ".tsv",
40
+ ".json",
41
+ ".yaml",
42
+ ".yml",
43
+ ".org",
44
+ ".rst",
45
+ ".log",
46
+ ]);
47
+ export const FILE_MAX_BYTES = 10 * 1024 * 1024;
48
+ export const MANIFEST_MAX = 20_000;
49
+ export const BATCH_FILES = 500;
50
+ export const BATCH_BYTES = 8 * 1024 * 1024;
51
+ export const DEBOUNCE_MS = 1000;
52
+ const POLL_MS = 10_000;
53
+ const RETRY_MS = 30_000;
54
+ const RACY_MS = 2000; // the coarsest common mtime clock (FAT, exFAT)
55
+ const TOO_BIG = "over 10 MB";
56
+ const IGNORE_FILE = ".syndaiignore";
57
+ const DEFAULT_IGNORES = "node_modules/\n";
58
+
59
+ export async function sync(ctx, args) {
60
+ const [dir, ...extra] = args;
61
+ if (!dir || extra.length) throw new CliError(EXIT.USAGE, "Usage: syndai sync <dir> [--app A] [--watch]");
62
+ const root = path.resolve(ctx.io.cwd, dir);
63
+ if (!fs.statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
64
+ throw new CliError(EXIT.USAGE, `${dir} is not a folder.`);
65
+ }
66
+ const name = path.basename(root);
67
+ if (!name) throw new CliError(EXIT.USAGE, "Sync a folder with a name, not the filesystem root.");
68
+ const project = await resolveProject(ctx, { app: ctx.flags.app });
69
+ // `known`: path -> {size, mtimeMs, hashedAt, sha256 (null: skipped)} as of the last scan.
70
+ const target = { root, name, project, rules: [], known: new Map() };
71
+ show(ctx, target, await syncOnce(ctx, target));
72
+ return ctx.flags.watch ? watchFolder(ctx, target) : EXIT.OK;
73
+ }
74
+
75
+ // --- one sync ----------------------------------------------------------------------------
76
+
77
+ async function syncOnce(ctx, target) {
78
+ const { client } = ctx;
79
+ const params = { coding_repository_id: target.project.id, name: target.name };
80
+ scan(target);
81
+ const files = [...target.known].filter(([, f]) => f.sha256).map(([rel, f]) => ({ path: rel, sha256: f.sha256 }));
82
+ if (files.length > MANIFEST_MAX) {
83
+ throw new CliError(
84
+ EXIT.USAGE,
85
+ `${target.name} has ${files.length} files; a synced folder holds up to ${MANIFEST_MAX}.`,
86
+ { next: `list what to leave out in ${path.join(target.root, IGNORE_FILE)}` },
87
+ );
88
+ }
89
+ const manifest = await client.call("compareKnowledgeFolder", {
90
+ params,
91
+ body: { files },
92
+ retry: true, // the same manifest twice is the same answer
93
+ timeoutMs: 120_000,
94
+ });
95
+ const need = manifest.need.filter((rel) => target.known.get(rel)?.sha256);
96
+ const totals = { indexed: 0, linked: 0, failed: [] };
97
+ const progress = ctx.io.stderr.isTTY && need.length > BATCH_FILES;
98
+ let sent = 0;
99
+ let batch = [];
100
+ let size = 0;
101
+ const flush = async () => {
102
+ const pushed = await client.call("pushKnowledgeFolderFiles", {
103
+ params,
104
+ body: { files: batch },
105
+ retry: true, // a push upserts by path
106
+ timeoutMs: 120_000,
107
+ });
108
+ totals.indexed += pushed.indexed;
109
+ totals.linked += pushed.linked;
110
+ totals.failed.push(...pushed.failed);
111
+ sent += batch.length;
112
+ if (progress) ctx.ui.progress(ctx.ui.dim(`indexed ${sent} of ${need.length}`));
113
+ batch = [];
114
+ size = 0;
115
+ };
116
+ for (const rel of need) {
117
+ // Read again: a file edited since the scan goes with its new hash, so the next manifest
118
+ // matches it. One gone since is left to the next manifest, which removes it.
119
+ const file = readFile(target, rel);
120
+ if (!file?.sha256) continue;
121
+ const entry = {
122
+ path: rel,
123
+ sha256: file.sha256,
124
+ bytes: file.bytes.length,
125
+ modified_at: new Date(file.mtimeMs).toISOString(),
126
+ content: TEXT_SUFFIXES.has(path.extname(rel).toLowerCase()) ? file.bytes.toString("utf8") : null,
127
+ };
128
+ const bytes = Buffer.byteLength(JSON.stringify(entry)) + 1;
129
+ if (batch.length && (batch.length >= BATCH_FILES || size + bytes > BATCH_BYTES)) await flush();
130
+ batch.push(entry);
131
+ size += bytes;
132
+ }
133
+ if (batch.length) await flush();
134
+ return {
135
+ source_id: manifest.source_id,
136
+ files: files.length,
137
+ unchanged: files.length - need.length,
138
+ removed: manifest.removed,
139
+ ...totals,
140
+ skipped: [...target.known].filter(([, f]) => !f.sha256).map(([rel]) => ({ path: rel, reason: TOO_BIG })),
141
+ };
142
+ }
143
+
144
+ /** Walk the folder into `target.known`, hashing only a file whose size or mtime moved. */
145
+ function scan(target) {
146
+ target.rules = loadRules(target.root);
147
+ const before = target.known;
148
+ target.known = new Map();
149
+ for (const rel of walk(target.root, target.rules)) {
150
+ const stat = statFile(target, rel);
151
+ const last = before.get(rel);
152
+ if (stat && same(last, stat) && trusted(last)) target.known.set(rel, last);
153
+ else if (stat) readFile(target, rel);
154
+ }
155
+ }
156
+
157
+ const same = (last, stat) => Boolean(last) && last.size === stat.size && last.mtimeMs === stat.mtimeMs;
158
+
159
+ /** A hash taken soon after the mtime may predate a same-size edit in that clock tick: redo it. */
160
+ const trusted = (last) => !last.sha256 || last.mtimeMs < last.hashedAt - RACY_MS;
161
+
162
+ /** Whether a stat-only walk differs from the last scan (a file added, removed or touched). */
163
+ function changedOnDisk(target) {
164
+ const rels = walk(target.root, loadRules(target.root));
165
+ if (rels.length !== target.known.size) return true;
166
+ return rels.some((rel) => {
167
+ const stat = statFile(target, rel);
168
+ const last = target.known.get(rel);
169
+ if (!stat || !same(last, stat)) return true;
170
+ return !trusted(last) && readFile(target, rel)?.sha256 !== last.sha256;
171
+ });
172
+ }
173
+
174
+ function statFile(target, rel) {
175
+ try {
176
+ const stat = fs.lstatSync(path.join(target.root, ...rel.split("/")));
177
+ return stat.isFile() ? stat : null;
178
+ } catch (err) {
179
+ if (["ENOENT", "EACCES", "EPERM", "ENOTDIR"].includes(err.code)) return null;
180
+ throw err;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Read and hash one file into `target.known` and return it with its `bytes`; one over 10 MB is
186
+ * recorded with `sha256: null` (skipped) and never read. Null when it is gone or unreadable.
187
+ */
188
+ function readFile(target, rel) {
189
+ const full = path.join(target.root, ...rel.split("/"));
190
+ let stat;
191
+ let bytes;
192
+ try {
193
+ stat = fs.lstatSync(full);
194
+ if (!stat.isFile()) return null;
195
+ if (stat.size <= FILE_MAX_BYTES) bytes = fs.readFileSync(full);
196
+ } catch (err) {
197
+ if (["ENOENT", "EACCES", "EPERM", "EISDIR", "ENOTDIR"].includes(err.code)) {
198
+ target.known.delete(rel);
199
+ return null;
200
+ }
201
+ throw err;
202
+ }
203
+ const small = bytes && bytes.length <= FILE_MAX_BYTES;
204
+ const file = {
205
+ size: small ? bytes.length : stat.size,
206
+ mtimeMs: stat.mtimeMs,
207
+ hashedAt: Date.now(),
208
+ sha256: small ? createHash("sha256").update(bytes).digest("hex") : null,
209
+ };
210
+ target.known.set(rel, file);
211
+ return { ...file, bytes };
212
+ }
213
+
214
+ function show(ctx, target, s) {
215
+ const lines = [
216
+ `Synced ${target.name} to ${projectLabel(target.project)}: ` +
217
+ `${s.indexed} indexed, ${s.linked} linked, ${s.failed.length} failed`,
218
+ ];
219
+ if (s.removed) lines.push(` removed ${s.removed} deleted here`);
220
+ for (const f of s.failed) lines.push(` failed ${f.path}: ${f.error}`);
221
+ for (const f of s.skipped) lines.push(` skipped ${f.path}: ${f.reason}`);
222
+ ctx.ui.result({ folder: target.name, dir: target.root, project_id: target.project.id, ...s }, lines.join("\n"));
223
+ }
224
+
225
+ // --- what is skipped ---------------------------------------------------------------------
226
+
227
+ function loadRules(root) {
228
+ let own = "";
229
+ try {
230
+ own = fs.readFileSync(path.join(root, IGNORE_FILE), "utf8");
231
+ } catch {
232
+ // No .syndaiignore: the defaults alone.
233
+ }
234
+ return parseIgnore(DEFAULT_IGNORES + own);
235
+ }
236
+
237
+ /**
238
+ * `.syndaiignore`, gitignore-like and kept simple: one pattern a line, `#` comments, `dir/` for
239
+ * folders only, a pattern with a `/` (or a leading one) anchored at the folder's root and any
240
+ * other matched against a name at any depth, `*` and `?` within a name, `**` across folders.
241
+ * No `!` negation.
242
+ */
243
+ export function parseIgnore(text) {
244
+ const rules = [];
245
+ for (const raw of text.split(/\r?\n/)) {
246
+ let line = raw.trim();
247
+ if (!line || line.startsWith("#") || line.startsWith("!")) continue;
248
+ const dirOnly = line.endsWith("/");
249
+ line = line.replace(/\/+$/, "");
250
+ const anchored = line.includes("/");
251
+ line = line.replace(/^\/+/, "");
252
+ if (line) rules.push({ dirOnly, anchored, regex: globRegex(line) });
253
+ }
254
+ return rules;
255
+ }
256
+
257
+ function globRegex(glob) {
258
+ let source = "";
259
+ for (let i = 0; i < glob.length; i++) {
260
+ const ch = glob[i];
261
+ if (ch === "*" && glob[i + 1] === "*") {
262
+ const slash = glob[i + 2] === "/";
263
+ source += slash ? "(?:.*/)?" : ".*";
264
+ i += slash ? 2 : 1;
265
+ } else if (ch === "*") source += "[^/]*";
266
+ else if (ch === "?") source += "[^/]";
267
+ else source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
268
+ }
269
+ return new RegExp(`^${source}$`);
270
+ }
271
+
272
+ /** Whether the entry at `rel` (`/`-separated; `isDir` for a folder) is skipped. */
273
+ export function ignored(rules, rel, isDir) {
274
+ const name = rel.slice(rel.lastIndexOf("/") + 1);
275
+ if (name.startsWith(".")) return true;
276
+ return rules.some((r) => (!r.dirOnly || isDir) && r.regex.test(r.anchored ? rel : name));
277
+ }
278
+
279
+ /** The files to sync, sorted; a skipped folder is never entered. */
280
+ function walk(root, rules) {
281
+ const files = [];
282
+ const visit = (dir, prefix) => {
283
+ let entries;
284
+ try {
285
+ entries = fs.readdirSync(dir, { withFileTypes: true });
286
+ } catch (err) {
287
+ if (prefix && ["ENOENT", "EACCES", "EPERM", "ENOTDIR"].includes(err.code)) return;
288
+ throw err;
289
+ }
290
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
291
+ for (const entry of entries) {
292
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
293
+ // A Dirent describes the link itself: a symlink is neither, so it is never followed.
294
+ if (entry.isDirectory()) {
295
+ if (!ignored(rules, rel, true)) visit(path.join(dir, entry.name), rel);
296
+ } else if (entry.isFile() && !ignored(rules, rel, false)) {
297
+ files.push(rel);
298
+ }
299
+ }
300
+ };
301
+ visit(root, "");
302
+ return files;
303
+ }
304
+
305
+ /** A change event for `rel` matters unless it is in a skipped folder or is a skipped file. */
306
+ function relevant(rules, rel) {
307
+ if (rel === IGNORE_FILE) return true;
308
+ const parts = rel.split("/");
309
+ for (let i = 1; i < parts.length; i++) {
310
+ if (ignored(rules, parts.slice(0, i).join("/"), true)) return false;
311
+ }
312
+ return !ignored(rules, rel, false);
313
+ }
314
+
315
+ // --- --watch -----------------------------------------------------------------------------
316
+
317
+ async function watchFolder(ctx, target) {
318
+ const { io, ui } = ctx;
319
+ const state = { changed: false, wake: null };
320
+ let watcher = null;
321
+ const eventsLost = (err) => {
322
+ watcher?.close();
323
+ watcher = null;
324
+ ui.progress(`${ui.yellow("warning:")} file events stopped (${err.code ?? err.message}); checking every 10 s instead.`);
325
+ };
326
+ try {
327
+ watcher = io.watchDir(
328
+ target.root,
329
+ (file) => {
330
+ if (file && !relevant(target.rules, String(file).split(path.sep).join("/"))) return;
331
+ state.changed = true;
332
+ state.wake?.();
333
+ },
334
+ eventsLost,
335
+ );
336
+ } catch (err) {
337
+ eventsLost(err);
338
+ }
339
+ ui.progress(ui.dim(`Watching ${target.root} for changes (Ctrl-C stops).`));
340
+ try {
341
+ for (;;) {
342
+ if (!state.changed && (await nextEvent(io, state)) === "poll" && !changedOnDisk(target)) continue;
343
+ // Wait until a second passes with no change, so a burst of saves is one sync.
344
+ do {
345
+ state.changed = false;
346
+ await pause(io, DEBOUNCE_MS);
347
+ } while (state.changed);
348
+ try {
349
+ const s = await syncOnce(ctx, target);
350
+ if (s.indexed || s.linked || s.failed.length || s.removed) show(ctx, target, s);
351
+ } catch (err) {
352
+ if (!(err instanceof UnavailableError)) throw err;
353
+ ui.progress(`${ui.yellow("warning:")} ${explain(err).message} Trying again in 30 s.`);
354
+ await pause(io, RETRY_MS);
355
+ state.changed = true;
356
+ }
357
+ }
358
+ } finally {
359
+ watcher?.close();
360
+ }
361
+ }
362
+
363
+ /** "event" on a relevant file event, "poll" after 10 s without one; Ctrl-C interrupts. */
364
+ function nextEvent(io, state) {
365
+ return new Promise((resolve, reject) => {
366
+ if (io.signal.aborted) return reject(interrupted());
367
+ const done = (outcome) => {
368
+ clearTimeout(timer);
369
+ io.signal.removeEventListener("abort", onAbort);
370
+ state.wake = null;
371
+ if (outcome instanceof Error) reject(outcome);
372
+ else resolve(outcome);
373
+ };
374
+ const onAbort = () => done(interrupted());
375
+ const timer = setTimeout(() => done("poll"), POLL_MS);
376
+ io.signal.addEventListener("abort", onAbort, { once: true });
377
+ state.wake = () => done("event");
378
+ });
379
+ }
380
+
381
+ async function pause(io, ms) {
382
+ if (io.signal.aborted) throw interrupted();
383
+ try {
384
+ await io.sleep(ms, io.signal);
385
+ } catch (err) {
386
+ if (io.signal.aborted) throw interrupted();
387
+ throw err;
388
+ }
389
+ }