rigjs 4.0.13 → 4.0.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.
package/lib/wiki/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import wikiInit from './init';
2
2
  import wikiScan from './scan';
3
3
  import wikiSurvey from './survey';
4
+ import wikiSync from './sync';
4
5
  import wikiFetch from './fetch';
5
6
  import wikiIngest from './ingest';
6
7
  import wikiQuery from './query';
@@ -20,8 +21,8 @@ import { registerDaemonCommands } from './daemon';
20
21
  export function registerWikiCommands(program: any): void {
21
22
  const wiki = program.command('wiki').description('Karpathy-style LLM Wiki ops (macOS only in v1)');
22
23
 
23
- wiki.command('init <scope>')
24
- .description('bootstrap a vault scoped to <scope>/ an existing data subdir of the project. Metadata is auto-created at ./rig-wiki/.')
24
+ wiki.command('init [scope]')
25
+ .description('bootstrap a vault under ./rig-wiki/. With no <scope>, the vault covers the whole CWD; with <scope>, only that subdir is scanned. Hidden/.gitignored/binary files are filtered at walk time regardless.')
25
26
  .action(wikiInit);
26
27
 
27
28
  wiki.command('scan')
@@ -38,6 +39,13 @@ export function registerWikiCommands(program: any): void {
38
39
  .option('--json', 'machine-readable output')
39
40
  .action(wikiSurvey);
40
41
 
42
+ wiki.command('sync')
43
+ .description('one-shot wiki update: scan → ingest NEW + MODIFIED → prune wiki pages for DELETED sources')
44
+ .option('--dry-run', 'print scan diff and what would change without writing anything')
45
+ .option('--no-prune', 'skip the delete step (only ingest NEW + MODIFIED)')
46
+ .option('--json', 'machine-readable output')
47
+ .action(wikiSync);
48
+
41
49
  wiki.command('fetch <url>')
42
50
  .description('verbatim download URL into raw/YYYY-MM-DD-<slug>.md')
43
51
  .option('--slug <slug>', 'override the auto-derived slug')
package/lib/wiki/init.ts CHANGED
@@ -154,28 +154,30 @@ function defaultVaultConfig(scope: string, rootRel: string): VaultConfig {
154
154
  }
155
155
 
156
156
  export default function wikiInit(scope?: string): void {
157
- if (!scope || !scope.trim()) {
158
- print.error('rig wiki init requires a scope.');
159
- print.info('usage: rig wiki init <scope> (e.g. `rig wiki init personal` to ingest from ./personal/)');
160
- print.info(`<scope> is an existing data subdir of the project. Vault metadata is auto-created at ./${VAULT_DIRNAME}/.`);
161
- process.exit(1);
162
- }
163
-
164
157
  const cwd = process.cwd();
165
158
  const vaultDir = path.join(cwd, VAULT_DIRNAME);
166
- const scopeAbs = path.resolve(cwd, scope);
167
159
 
168
- // The scope must already exist pointing the wiki at a missing dir would
169
- // hide what is almost certainly a typo.
170
- if (!fs.existsSync(scopeAbs) || !fs.statSync(scopeAbs).isDirectory()) {
171
- print.error(`scope dir not found: ${scope}`);
172
- print.info(`expected an existing data subdir at ${shortPath(scopeAbs)}`);
173
- process.exit(1);
174
- }
175
- // The scope can't be (or contain) the vault dir itself.
176
- if (scopeAbs === vaultDir || vaultDir.startsWith(scopeAbs + path.sep)) {
177
- print.error(`scope cannot be or contain the vault dir (${VAULT_DIRNAME}/).`);
178
- process.exit(1);
160
+ // No-arg form: scope = the whole project (CWD). Vault scans up one level
161
+ // from `<CWD>/rig-wiki/` to reach CWD. Wiki name defaults to basename(CWD).
162
+ // Hidden segments, binary extensions, and .gitignored files are filtered
163
+ // by scan/survey at walk time — no scope restriction needed.
164
+ const hasScope = !!(scope && scope.trim());
165
+ const scopeAbs = hasScope ? path.resolve(cwd, scope!) : cwd;
166
+ const scopeName = hasScope ? scope!.trim() : path.basename(cwd);
167
+
168
+ if (hasScope) {
169
+ // The explicit scope must already exist pointing the wiki at a missing
170
+ // dir would hide what is almost certainly a typo.
171
+ if (!fs.existsSync(scopeAbs) || !fs.statSync(scopeAbs).isDirectory()) {
172
+ print.error(`scope dir not found: ${scope}`);
173
+ print.info(`expected an existing data subdir at ${shortPath(scopeAbs)}`);
174
+ process.exit(1);
175
+ }
176
+ // The scope can't be (or contain) the vault dir itself.
177
+ if (scopeAbs === vaultDir || vaultDir.startsWith(scopeAbs + path.sep)) {
178
+ print.error(`scope cannot be or contain the vault dir (${VAULT_DIRNAME}/).`);
179
+ process.exit(1);
180
+ }
179
181
  }
180
182
 
181
183
  const guard = guardPath(vaultDir, cwd);
@@ -189,6 +191,7 @@ export default function wikiInit(scope?: string): void {
189
191
  // If the vault already has a config, it must already be scoped to the
190
192
  // same data dir — otherwise the user is trying to re-target an existing
191
193
  // vault, which we won't do silently. Manual config edit only.
194
+ // No-arg init resolves to the CWD itself, which equals `path.dirname(vaultDir)`.
192
195
  const cfgFile = vaultConfigPath(vaultDir);
193
196
  if (fs.existsSync(cfgFile)) {
194
197
  const existing = loadVaultConfig(vaultDir);
@@ -222,12 +225,13 @@ export default function wikiInit(scope?: string): void {
222
225
 
223
226
  if (!fs.existsSync(cfgFile)) {
224
227
  const rootRel = path.relative(vaultDir, scopeAbs);
225
- saveVaultConfig(vaultDir, defaultVaultConfig(scope, rootRel));
228
+ saveVaultConfig(vaultDir, defaultVaultConfig(scopeName, rootRel));
226
229
  }
227
230
 
228
- print.succeed(`vault initialized at ${shortPath(vaultDir)} (scope: ${scope})`);
231
+ const scopeLabel = hasScope ? `scope: ${scope}` : `scope: <project-wide, name "${scopeName}">`;
232
+ print.succeed(`vault initialized at ${shortPath(vaultDir)} (${scopeLabel})`);
229
233
  print.info(`next: edit ${shortPath(path.join(vaultDir, 'purpose.md'))} to describe what this wiki is for.`);
230
- print.info(`then run \`rig wiki scan\` from anywhere inside ${shortPath(cwd)} to see what will be ingested.`);
234
+ print.info(`then run \`rig wiki sync\` from anywhere inside ${shortPath(cwd)} to ingest, update, and prune in one shot.`);
231
235
  }
232
236
 
233
237
  function writeIfMissing(file: string, content: string) {
@@ -0,0 +1,145 @@
1
+ // Prune wiki pages whose underlying source file has been deleted from the
2
+ // scan root. Triggered by `rig wiki sync` (and exposed as a helper for
3
+ // `rig wiki prune` if/when we add a standalone command).
4
+ //
5
+ // Three actions per deleted source path:
6
+ //
7
+ // 1. Delete `sources/<slug>.md` whose `source-path:` frontmatter resolves
8
+ // to the deleted file. The slug = basename of that wiki source page.
9
+ //
10
+ // 2. Scrub the deleted slug from every derived page's `sources: [...]`
11
+ // frontmatter array (entities/concepts/synthesis/queries). Pages that
12
+ // end up with an empty `sources: []` are left in place for `rig wiki
13
+ // lint` to surface as orphans — we don't auto-delete derived pages
14
+ // since they may carry the user's intellectual content distinct from
15
+ // any single source.
16
+ //
17
+ // 3. Drop the row from `state.db.source_sha` so future scans don't keep
18
+ // reporting the file as DELETED.
19
+
20
+ import fs from 'fs';
21
+ import path from 'path';
22
+ import yaml from 'js-yaml';
23
+ import { WikiEntry } from './config';
24
+ import { deleteSourceSha } from './db';
25
+
26
+ const DERIVED_DIRS = ['entities', 'concepts', 'synthesis', 'queries'];
27
+
28
+ export interface PruneReport {
29
+ /** wiki-relative paths of source pages deleted */
30
+ deletedSourcePages: string[];
31
+ /** wiki-relative paths of derived pages whose sources[] lost the dropped slug */
32
+ scrubbedDerivedPages: string[];
33
+ /** root-relative paths of source_sha rows dropped */
34
+ shaRowsDropped: string[];
35
+ }
36
+
37
+ export function pruneDeletedSources(target: WikiEntry, deletedRelPaths: string[]): PruneReport {
38
+ const report: PruneReport = { deletedSourcePages: [], scrubbedDerivedPages: [], shaRowsDropped: [] };
39
+ if (deletedRelPaths.length === 0) return report;
40
+
41
+ const deletedSet = new Set(deletedRelPaths.map(normalizePath));
42
+ const sourcesDir = path.join(target.path, 'sources');
43
+ const droppedSlugs = new Set<string>();
44
+
45
+ // 1. sources/<slug>.md whose `source-path:` points at a deleted file
46
+ if (fs.existsSync(sourcesDir)) {
47
+ for (const file of fs.readdirSync(sourcesDir)) {
48
+ if (!file.endsWith('.md') || file === '.gitkeep') continue;
49
+ const abs = path.join(sourcesDir, file);
50
+ const fm = readFrontmatter(abs);
51
+ if (!fm) continue;
52
+ const rel = extractSourcePathRel(String(fm['source-path'] || ''));
53
+ if (rel && deletedSet.has(rel)) {
54
+ fs.rmSync(abs, { force: true });
55
+ report.deletedSourcePages.push(path.relative(target.path, abs));
56
+ droppedSlugs.add(path.basename(file, '.md'));
57
+ }
58
+ }
59
+ }
60
+
61
+ // 2. derived pages — strip dropped slugs from `sources: [...]`
62
+ if (droppedSlugs.size > 0) {
63
+ for (const sub of DERIVED_DIRS) {
64
+ const dir = path.join(target.path, sub);
65
+ if (!fs.existsSync(dir)) continue;
66
+ for (const file of fs.readdirSync(dir)) {
67
+ if (!file.endsWith('.md') || file === '.gitkeep') continue;
68
+ const abs = path.join(dir, file);
69
+ const content = fs.readFileSync(abs, 'utf8');
70
+ const next = scrubSlugsFromSourcesArray(content, droppedSlugs);
71
+ if (next !== content) {
72
+ fs.writeFileSync(abs, next, 'utf8');
73
+ report.scrubbedDerivedPages.push(path.relative(target.path, abs));
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ // 3. state.db cleanup
80
+ for (const p of deletedRelPaths) {
81
+ deleteSourceSha(target.name, p);
82
+ report.shaRowsDropped.push(p);
83
+ }
84
+
85
+ return report;
86
+ }
87
+
88
+ // ─── helpers ────────────────────────────────────────────────────────────
89
+
90
+ function normalizePath(p: string): string {
91
+ return p.replace(/\\/g, '/');
92
+ }
93
+
94
+ function readFrontmatter(file: string): Record<string, unknown> | null {
95
+ let content: string;
96
+ try { content = fs.readFileSync(file, 'utf8'); } catch { return null; }
97
+ if (!content.startsWith('---\n')) return null;
98
+ const end = content.indexOf('\n---', 4);
99
+ if (end < 0) return null;
100
+ try {
101
+ const parsed = yaml.load(content.slice(4, end));
102
+ return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : null;
103
+ } catch { return null; }
104
+ }
105
+
106
+ /**
107
+ * `source-path` can be:
108
+ * - an obsidian:// URL: `obsidian://open?vault=name&file=<encoded-path>`
109
+ * - a plain root-relative path: `personal/work/foo.md`
110
+ * Returns the underlying root-relative path, or null if we can't extract one.
111
+ */
112
+ function extractSourcePathRel(srcPath: string): string | null {
113
+ if (!srcPath) return null;
114
+ const m = srcPath.match(/[?&]file=([^&]+)/);
115
+ if (m) {
116
+ try { return normalizePath(decodeURIComponent(m[1])); }
117
+ catch { return normalizePath(m[1]); }
118
+ }
119
+ return normalizePath(srcPath);
120
+ }
121
+
122
+ /**
123
+ * Surgically rewrites the `sources: [a, b, c]` frontmatter line, removing
124
+ * any entries that appear in `slugs`. Leaves the rest of the frontmatter
125
+ * and body untouched. If no match, returns the content unchanged.
126
+ */
127
+ function scrubSlugsFromSourcesArray(content: string, slugs: Set<string>): string {
128
+ if (!content.startsWith('---\n')) return content;
129
+ const end = content.indexOf('\n---', 4);
130
+ if (end < 0) return content;
131
+ const fm = content.slice(4, end);
132
+ const after = content.slice(end);
133
+
134
+ const m = fm.match(/^([ \t]*sources[ \t]*:[ \t]*)\[(.*?)\]/m);
135
+ if (!m) return content;
136
+
137
+ const items = m[2]
138
+ .split(',')
139
+ .map(s => s.trim().replace(/^['"]|['"]$/g, ''))
140
+ .filter(s => s && !slugs.has(s));
141
+
142
+ const newSources = `${m[1]}[${items.map(s => `'${s}'`).join(', ')}]`;
143
+ const newFm = fm.replace(m[0], newSources);
144
+ return '---\n' + newFm + after;
145
+ }
package/lib/wiki/scan.ts CHANGED
Binary file
@@ -0,0 +1,111 @@
1
+ // `rig wiki sync` — one-shot wiki update.
2
+ //
3
+ // 1. scan: compute NEW / MODIFIED / DELETED against state.db.source_sha
4
+ // (hidden + .gitignored + binary-extension filters apply at walk time,
5
+ // and the gitignore check is multi-repo-aware so git submodules are
6
+ // respected — see `lib/wiki/gitignore.ts`).
7
+ // 2. ingest each NEW + MODIFIED source via the existing `wikiIngest`
8
+ // code path. Sequential so Claude doesn't double-write the same wiki
9
+ // page from concurrent runs, and so token spend is bounded.
10
+ // 3. prune the wiki pages whose underlying source files have been
11
+ // deleted: removes `sources/<slug>.md`, strips that slug from derived
12
+ // pages' `sources: [...]` frontmatter, drops the source_sha row.
13
+ //
14
+ // RAW DRIFT (a `raw/` file's bytes changed in place) is surfaced as an
15
+ // error — we do NOT try to "fix" it via re-ingest. raw/ is immutable by
16
+ // design; drift means something else (a copy-on-write filesystem,
17
+ // accidental edit, etc.) and the user needs to look.
18
+
19
+ import path from 'path';
20
+ import print from '../print';
21
+ import { requireVault } from './config';
22
+ import { recordLastRun } from './db';
23
+ import { scanOne, ScanReport } from './scan';
24
+ import { pruneDeletedSources, PruneReport } from './prune';
25
+ import { default as wikiIngest } from './ingest';
26
+
27
+ interface SyncOpts {
28
+ json?: boolean;
29
+ dryRun?: boolean;
30
+ noPrune?: boolean;
31
+ }
32
+
33
+ export default async function wikiSync(opts: SyncOpts): Promise<void> {
34
+ const target = requireVault();
35
+
36
+ // Step 1: scan. We don't baseline yet — ingest+prune are the ground truth
37
+ // for what gets recorded, and baselining before would mark unprocessed
38
+ // files as "known" prematurely.
39
+ const scan: ScanReport = scanOne(target, false);
40
+
41
+ if (scan.rawDrift.length > 0) {
42
+ print.error(`RAW DRIFT detected — ${scan.rawDrift.length} file(s) in raw/ changed. raw/ is immutable; resolve manually before syncing.`);
43
+ for (const p of scan.rawDrift) {
44
+ // eslint-disable-next-line no-console
45
+ console.log(` ${p}`);
46
+ }
47
+ recordLastRun(target.name, 'sync', 10);
48
+ process.exit(10);
49
+ }
50
+
51
+ const toIngest = [...scan.new, ...scan.modified];
52
+ print.info(`sync: ${target.name} NEW ${scan.new.length} MODIFIED ${scan.modified.length} DELETED ${scan.deleted.length} UNCHANGED ${scan.unchanged}`);
53
+
54
+ // Step 2: ingest NEW + MODIFIED (one Claude call per file).
55
+ let ingestOk = 0, ingestFail = 0;
56
+ for (const rel of toIngest) {
57
+ const abs = path.resolve(target.root, rel);
58
+ print.start(`ingest ${rel}`);
59
+ try {
60
+ await wikiIngest(abs, { dryRun: !!opts.dryRun });
61
+ ingestOk++;
62
+ } catch (e) {
63
+ ingestFail++;
64
+ print.error(`ingest ${rel} failed: ${(e as Error).message}`);
65
+ }
66
+ }
67
+
68
+ // Step 3: prune DELETED unless --no-prune / --dry-run.
69
+ let prune: PruneReport = { deletedSourcePages: [], scrubbedDerivedPages: [], shaRowsDropped: [] };
70
+ if (scan.deleted.length > 0 && !opts.noPrune && !opts.dryRun) {
71
+ prune = pruneDeletedSources(target, scan.deleted);
72
+ }
73
+
74
+ // Step 4: report.
75
+ if (opts.json) {
76
+ // eslint-disable-next-line no-console
77
+ console.log(JSON.stringify({
78
+ ok: ingestFail === 0,
79
+ code: ingestFail === 0 ? 0 : 1,
80
+ data: {
81
+ wiki: target.name,
82
+ scan,
83
+ ingested: { ok: ingestOk, failed: ingestFail },
84
+ pruned: prune,
85
+ dryRun: !!opts.dryRun,
86
+ },
87
+ }, null, 2));
88
+ } else {
89
+ print.succeed(`sync: ingested ${ingestOk} ok, ${ingestFail} failed.`);
90
+ if (prune.deletedSourcePages.length > 0) {
91
+ print.info(`pruned ${prune.deletedSourcePages.length} source page(s):`);
92
+ for (const p of prune.deletedSourcePages) {
93
+ // eslint-disable-next-line no-console
94
+ console.log(` − ${p}`);
95
+ }
96
+ }
97
+ if (prune.scrubbedDerivedPages.length > 0) {
98
+ print.info(`scrubbed deleted-source refs from ${prune.scrubbedDerivedPages.length} derived page(s):`);
99
+ for (const p of prune.scrubbedDerivedPages) {
100
+ // eslint-disable-next-line no-console
101
+ console.log(` ~ ${p}`);
102
+ }
103
+ }
104
+ if (opts.dryRun && scan.deleted.length > 0) {
105
+ print.info(`dry-run: ${scan.deleted.length} delete(s) NOT applied. Re-run without --dry-run to prune.`);
106
+ }
107
+ }
108
+
109
+ recordLastRun(target.name, 'sync', ingestFail === 0 ? 0 : 1);
110
+ if (ingestFail > 0) process.exit(1);
111
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rigjs",
3
- "version": "4.0.13",
4
- "versionCode": 26052419,
3
+ "version": "4.0.14",
4
+ "versionCode": 26052420,
5
5
  "description": "A multi-repos dev tool based on yarn and git.Rigjs is intended to be the simplest way to develop,share and deliver codes between different developers or different projects.",
6
6
  "keywords": [
7
7
  "modular",