@awebai/oats 0.27.1 → 0.28.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/bin/oats.mjs +185 -26
- package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +8 -3
- package/capabilities/oats-okf/bin/oats-okf.mjs +33 -28
- package/capabilities/oats-okf/injects/okf.md +29 -21
- package/capabilities/oats-okf/lib/config.mjs +5 -1
- package/capabilities/oats-okf/lib/consult.mjs +500 -0
- package/capabilities/oats-okf/lib/inspection.mjs +11 -3
- package/capabilities/oats-okf/lib/io.mjs +9 -2
- package/capabilities/oats-okf/lib/sources.mjs +15 -53
- package/capabilities/oats-okf/lib/stores.mjs +10 -7
- package/capabilities/oats-okf/lib/worker.mjs +9 -1
- package/capabilities/oats-okf/oats.json +13 -4
- package/capabilities/oats-okf/skills/okf/SKILL.md +14 -6
- package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +142 -0
- package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +86 -0
- package/docs/capabilities.md +3 -1
- package/docs/design/2026-09-24-phase-d-plan.md +11 -0
- package/docs/design/2026-09-26-desktop-design-brief-architecture.md +241 -0
- package/docs/design/2026-09-26-okf-knowledge-operations.md +389 -0
- package/docs/desktop-cli-api.md +99 -7
- package/docs/oats-local.schema.json +2 -1
- package/docs/oats-package.schema.json +39 -0
- package/docs/packages.md +67 -3
- package/docs/release-notes/v0.27.2.md +51 -0
- package/docs/release-notes/v0.28.0.md +144 -0
- package/docs/schedules.md +99 -1
- package/docs/souls-and-instances.md +19 -3
- package/docs/workspaces.md +8 -2
- package/lib/core.mjs +124 -10
- package/lib/instance-inspect.mjs +4 -4
- package/lib/instance-resolution.mjs +62 -18
- package/lib/materialize.mjs +13 -0
- package/lib/packages.mjs +90 -6
- package/lib/resolve.mjs +20 -2
- package/lib/schedule.mjs +24 -11
- package/lib/triggers.mjs +545 -0
- package/lib/workspace.mjs +80 -3
- package/package-catalog.json +1 -1
- package/package.json +1 -1
|
@@ -1,27 +1,36 @@
|
|
|
1
1
|
## Knowledge: OKF
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
when resuming. Follow only links relevant to the task: do not bulk-load bases.
|
|
7
|
-
Each base is one link namespace: `/node/concept.md` resolves from that base's
|
|
8
|
-
root, not the filesystem root. All configured bases are discoverable; owns
|
|
9
|
-
means responsibility and reads means starting context, neither is an ACL.
|
|
3
|
+
You have two kinds of knowledge. Consult both throughout your work: at the
|
|
4
|
+
start of every task, after compaction, and while you work, to make decisions
|
|
5
|
+
and to understand things.
|
|
10
6
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
7
|
+
- **Soul knowledge**: your soul's accepted OKF bases. They are external to the
|
|
8
|
+
soul and read remotely at their accepted state; there is no local copy.
|
|
9
|
+
**Consultation** reads them with the `oats okf` CLI from your instance home.
|
|
10
|
+
Load the **okf-consultation** skill at the start of every task (and whenever
|
|
11
|
+
you look something up): it teaches the commands, navigation, search, citing
|
|
12
|
+
and freshness.
|
|
13
|
+
- **Instance knowledge**: this instance's own STATE.md, log.md and notes/ in
|
|
14
|
+
instance home: what you are doing, what happened, what you have learned.
|
|
15
|
+
|
|
16
|
+
- **At the start of every task:** re-read STATE.md and the relevant notes/,
|
|
17
|
+
then `oats okf index` and `oats okf cat --base ALIAS PATH` for the concepts
|
|
18
|
+
the task needs. Follow links; do not bulk-load.
|
|
19
|
+
- **After compaction, before continuing:** re-read STATE.md, recent log.md
|
|
20
|
+
entries and notes/, then `oats okf index` again.
|
|
21
|
+
- **Throughout the work, not only at the start:** before a design decision,
|
|
22
|
+
before re-deriving something, whenever you need to understand something, and
|
|
23
|
+
whenever a question touches your domain, check your notes/ and
|
|
24
|
+
`oats okf search` / `cat` the relevant concepts first. Consult prior
|
|
25
|
+
decisions before re-deriving them.
|
|
26
|
+
- Cite `alias/node/concept.md@<short-oid>`. A Git PR is not accepted knowledge
|
|
27
|
+
until it is merged. Report missing configuration or blocked reads; do not
|
|
28
|
+
create a substitute.
|
|
19
29
|
|
|
20
30
|
**Never write accepted knowledge or soul knowledge.** This is an instruction
|
|
21
|
-
boundary, not a filesystem sandbox.
|
|
22
|
-
editing the base behind them. Skills remain curated soul artifacts.
|
|
31
|
+
boundary, not a filesystem sandbox. Skills remain curated soul artifacts.
|
|
23
32
|
|
|
24
|
-
Keep
|
|
33
|
+
Keep instance knowledge current, in instance home, not ./work:
|
|
25
34
|
- STATE.md: rewrite the current task and progress; # Next names one next step.
|
|
26
35
|
- log.md: append dated significant events; never rewrite history.
|
|
27
36
|
- notes/: one Markdown concept per non-obvious insight, with type, title,
|
|
@@ -29,6 +38,5 @@ Keep your task-local memory in instance home, not ./work:
|
|
|
29
38
|
Record decisions, rejected alternatives, limitations and conclusions as
|
|
30
39
|
they happen. Never include credentials or third-party messages verbatim.
|
|
31
40
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
code manual: code and repository documentation remain truth about code.
|
|
41
|
+
Update memory before task boundaries. These files are not a second code manual:
|
|
42
|
+
code and repository documentation remain truth about code.
|
|
@@ -7,8 +7,9 @@ function keys(value, allowed, label, code='E_CONFIG') {
|
|
|
7
7
|
}
|
|
8
8
|
export function settings() {
|
|
9
9
|
const s = JSON.parse(process.env.OATS_SETTINGS || '{}');
|
|
10
|
-
keys(s,['bindings-file','state-dir','harvest-runtime','harvest-model','git-timeout'],'OATS_SETTINGS');
|
|
10
|
+
keys(s,['bindings-file','state-dir','harvest-runtime','harvest-model','git-timeout','consult-max-age'],'OATS_SETTINGS');
|
|
11
11
|
if(s['git-timeout']!==undefined && (!Number.isInteger(s['git-timeout']) || s['git-timeout']<1)) fail('E_CONFIG','git-timeout must be a positive integer number of seconds');
|
|
12
|
+
if(s['consult-max-age']!==undefined && (!Number.isInteger(s['consult-max-age']) || s['consult-max-age']<0)) fail('E_CONFIG','consult-max-age must be a non-negative integer number of seconds');
|
|
12
13
|
if(s['state-dir']!==undefined && (typeof s['state-dir']!=='string' || !isAbsolute(s['state-dir']) || resolve(s['state-dir'])!==s['state-dir'])) fail('E_CONFIG','state-dir must be a normalized absolute path');
|
|
13
14
|
if(s['harvest-runtime']!==undefined && !['pi','claude','codex'].includes(s['harvest-runtime'])) fail('E_CONFIG','invalid harvest-runtime');
|
|
14
15
|
if(s['harvest-model']!==undefined && (typeof s['harvest-model']!=='string' || !s['harvest-model'].trim())) fail('E_CONFIG','harvest-model must be a nonempty string');
|
|
@@ -17,6 +18,9 @@ export function settings() {
|
|
|
17
18
|
/** Time budget for Git operations that talk to a remote (clone, fetch, push,
|
|
18
19
|
* ls-remote). Local object reads keep the short exec default. */
|
|
19
20
|
export function gitTimeoutMs() { return (settings()['git-timeout'] ?? 600)*1000; }
|
|
21
|
+
/** How old (seconds) the host cache's accepted commit may be before a consult
|
|
22
|
+
* read refetches the accepted branch; 0 fetches on every read. */
|
|
23
|
+
export function consultMaxAgeMs() { return (settings()['consult-max-age'] ?? 300)*1000; }
|
|
20
24
|
export function noGit(path) {
|
|
21
25
|
for (let p = safePath(path); ; p = dirname(p)) {
|
|
22
26
|
if (fs.existsSync(join(p, '.git')) || (fs.existsSync(join(p, 'HEAD')) && fs.existsSync(join(p, 'objects')) && fs.existsSync(join(p, 'refs')))) fail('E_DIRECTORY_GIT', `directory store is in Git custody: ${p}; use kind git`);
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
// okf 3.0.0 consult: souls read their knowledge REMOTELY, at the accepted
|
|
2
|
+
// state, with no per-instance copy. The only local bytes are one host-wide bare
|
|
3
|
+
// partial clone per Git base (blobs arrive on first read) under the bindings'
|
|
4
|
+
// stateDir; directory bases are read in place under their cooperative lock.
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { hostname } from 'node:os';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { fs, join, dirname, safePath, readJSON, save, digest, tree, withLock, fail, syncDir, identifier } from './io.mjs';
|
|
9
|
+
import { noGit, gitTimeoutMs, consultMaxAgeMs, splitRef, resolveNodes } from './config.mjs';
|
|
10
|
+
import { git, gitEnv, validateBase, baseLock, journalPath, preflightLocalRepository, requireNotShallow, verifyRemote, unavailable } from './stores.mjs';
|
|
11
|
+
|
|
12
|
+
const HIT_LIMIT = 50, SNIPPET = 200, FRONTMATTER_BYTES = 8 * 1024, TEXT_BYTES = 64 * 1024 * 1024;
|
|
13
|
+
const WALK_FILES = 20000, WALK_BYTES = 256 * 1024 * 1024, DIRECTORY_WAIT_MS = 30000, LOCAL_GIT_MS = 30000;
|
|
14
|
+
const obj = v => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------- paths
|
|
17
|
+
/** Resolve a user path or OKF link to a canonical path relative to the base
|
|
18
|
+
* root ('' is the root). `/node/x.md` is base-root absolute, a relative path
|
|
19
|
+
* resolves against the directory of `from` (itself a base path), and a bare
|
|
20
|
+
* path without `from` resolves from the root. Nothing may leave the root. */
|
|
21
|
+
export function resolveBasePath(input, { from = null, fsRoots = [] } = {}) {
|
|
22
|
+
if (typeof input !== 'string' || !input.trim()) fail('E_USAGE', 'a base path is required, for example /node/index.md');
|
|
23
|
+
const raw = input.trim();
|
|
24
|
+
if (/[\0\\]/.test(raw)) fail('E_PATH', `not a base path: ${JSON.stringify(input)}`);
|
|
25
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(raw) || raw.startsWith('//')) fail('E_PATH', `${input} is a URL, not a path in the knowledge base`);
|
|
26
|
+
let decoded;
|
|
27
|
+
try { decoded = decodeURIComponent(raw.replace(/[?#].*$/, '')); } catch { fail('E_PATH', `malformed percent-encoding in ${input}`); }
|
|
28
|
+
if (!decoded || /[\0\\]/.test(decoded)) fail('E_PATH', `not a base path: ${JSON.stringify(input)}`);
|
|
29
|
+
for (const root of fsRoots.filter(Boolean)) if (decoded === root || decoded.startsWith(root + '/')) fail('E_PATH', `${input} is a filesystem path; base paths start at the base root, like /node/concept.md`);
|
|
30
|
+
const segments = decoded.startsWith('/') ? decoded.slice(1).split('/')
|
|
31
|
+
: from !== null ? [...from.split('/').slice(0, -1), ...decoded.split('/')] : decoded.split('/');
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const segment of segments) {
|
|
34
|
+
if (segment === '' || segment === '.') continue;
|
|
35
|
+
if (segment === '..') { if (!out.length) fail('E_PATH', `${input} escapes the base root`); out.pop(); continue; }
|
|
36
|
+
if (segment.startsWith('.')) fail('E_PATH', `${input}: hidden paths are not knowledge`);
|
|
37
|
+
out.push(segment);
|
|
38
|
+
}
|
|
39
|
+
return out.join('/');
|
|
40
|
+
}
|
|
41
|
+
const parent = p => p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '';
|
|
42
|
+
const shown = p => '/' + p;
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------- git cache
|
|
45
|
+
const cacheDir = (bindings, base) => join(bindings.stateDir, 'cache', `${identifier(base.id)}.git`);
|
|
46
|
+
const cacheLock = (bindings, base) => join(bindings.stateDir, 'cache', `${base.id}.lock`);
|
|
47
|
+
const stateFile = cache => join(cache, 'okf-consult.json');
|
|
48
|
+
function gitRun(cwd, args, { input, timeout = LOCAL_GIT_MS, maxBuffer = TEXT_BYTES, encoding = 'utf8' } = {}) {
|
|
49
|
+
return spawnSync('git', ['--no-replace-objects', '--literal-pathspecs', '-c', 'core.hooksPath=/dev/null', '-c', 'protocol.ext.allow=never', '-C', cwd, ...args],
|
|
50
|
+
{ cwd, env: gitEnv(), input, timeout, maxBuffer, encoding });
|
|
51
|
+
}
|
|
52
|
+
const gitError = r => Object.assign(new Error(`git failed: ${r.error?.message || r.stderr || `exit ${r.status}`}`), { code: r.error?.code === 'ETIMEDOUT' ? 'ETIMEDOUT' : 'E_COMMAND' });
|
|
53
|
+
function acceptedCommit(cache, base) {
|
|
54
|
+
const r = gitRun(cache, ['rev-parse', '--verify', '--quiet', `refs/heads/${base.acceptedBranch}^{commit}`]);
|
|
55
|
+
const oid = r.status === 0 ? r.stdout.trim() : '';
|
|
56
|
+
return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(oid) ? oid : null;
|
|
57
|
+
}
|
|
58
|
+
/** The cache's bookkeeping, or null when the cache is absent or belongs to a
|
|
59
|
+
* different repository/branch (then it is rebuilt, never reinterpreted). */
|
|
60
|
+
function usableCache(cache, base) {
|
|
61
|
+
if (!fs.existsSync(cache)) return null;
|
|
62
|
+
safePath(cache);
|
|
63
|
+
let state;
|
|
64
|
+
try { state = readJSON(stateFile(cache)); } catch { return null; }
|
|
65
|
+
if (!obj(state) || state.version !== 1 || state.repository !== base.repository || state.acceptedBranch !== base.acceptedBranch) return null;
|
|
66
|
+
try { verifyRemote(base, cache); } catch { return null; }
|
|
67
|
+
return acceptedCommit(cache, base) ? state : null;
|
|
68
|
+
}
|
|
69
|
+
function cloneCache(base, alias, dest) {
|
|
70
|
+
preflightLocalRepository(base, alias);
|
|
71
|
+
// --no-local: a local path gets the same filtered transport as a remote
|
|
72
|
+
// (git ignores --filter for local clones and would copy every object).
|
|
73
|
+
const args = ['clone', '--bare', ...(base.repository.startsWith('/') ? ['--no-local'] : []), '--single-branch', '--branch', base.acceptedBranch];
|
|
74
|
+
try { git(dirname(dest), [...args, '--filter=blob:none', '--', base.repository, dest], { timeout: gitTimeoutMs() }); }
|
|
75
|
+
catch (e) {
|
|
76
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
77
|
+
if (!(e.code === 'E_COMMAND' && /filter/i.test(e.message))) unavailable(base, alias, 'clone', e);
|
|
78
|
+
try { git(dirname(dest), [...args, '--', base.repository, dest], { timeout: gitTimeoutMs() }); }
|
|
79
|
+
catch (error) { unavailable(base, alias, 'clone', error); }
|
|
80
|
+
}
|
|
81
|
+
try { requireNotShallow(base, alias, dest); } catch (e) { if (e.code === 'E_BASE_SHALLOW') throw e; unavailable(base, alias, 'clone', e); }
|
|
82
|
+
verifyRemote(base, dest);
|
|
83
|
+
if (!acceptedCommit(dest, base)) unavailable(base, alias, 'clone', new Error(`accepted branch ${base.acceptedBranch} not found`));
|
|
84
|
+
}
|
|
85
|
+
function fetchAccepted(base, alias, cache) {
|
|
86
|
+
// Only the accepted branch, forced to what the remote accepts now.
|
|
87
|
+
try { git(cache, ['fetch', '--no-tags', '--update-head-ok', 'origin', `+refs/heads/${base.acceptedBranch}:refs/heads/${base.acceptedBranch}`], { timeout: gitTimeoutMs() }); }
|
|
88
|
+
catch (e) { unavailable(base, alias, 'fetch', e); }
|
|
89
|
+
try { requireNotShallow(base, alias, cache); } catch (e) { if (e.code === 'E_BASE_SHALLOW') throw e; unavailable(base, alias, 'fetch', e); }
|
|
90
|
+
verifyRemote(base, cache);
|
|
91
|
+
}
|
|
92
|
+
function gitContext(bindings, alias, base, cache, state) {
|
|
93
|
+
const commit = acceptedCommit(cache, base);
|
|
94
|
+
if (!commit) unavailable(base, alias, 'fetch', new Error('cached accepted branch is missing'));
|
|
95
|
+
const receipt = { base: alias, id: base.id, kind: 'git', commit, fetchedAt: state.fetchedAt, stale: !!state.error };
|
|
96
|
+
if (state.error) receipt.reason = state.error;
|
|
97
|
+
return { alias, base, bindings, kind: 'git', cache, commit, receipt };
|
|
98
|
+
}
|
|
99
|
+
function resolveGit(bindings, alias, base, { fresh = false } = {}) {
|
|
100
|
+
const cache = cacheDir(bindings, base), lock = cacheLock(bindings, base);
|
|
101
|
+
fs.mkdirSync(dirname(cache), { recursive: true, mode: 0o700 });
|
|
102
|
+
const due = s => fresh || Date.now() - Math.max(Date.parse(s.fetchedAt) || 0, Date.parse(s.attemptedAt) || 0) >= consultMaxAgeMs();
|
|
103
|
+
let state = usableCache(cache, base);
|
|
104
|
+
if (state && !due(state)) return gitContext(bindings, alias, base, cache, state);
|
|
105
|
+
clearDeadCacheLock(lock);
|
|
106
|
+
try {
|
|
107
|
+
return withLock(lock, () => {
|
|
108
|
+
state = usableCache(cache, base);
|
|
109
|
+
if (state && !due(state)) return gitContext(bindings, alias, base, cache, state); // a peer just fetched
|
|
110
|
+
const now = new Date().toISOString();
|
|
111
|
+
if (!state) {
|
|
112
|
+
const temp = join(dirname(cache), `.${base.id}.git.tmp-${randomUUID()}`);
|
|
113
|
+
try {
|
|
114
|
+
cloneCache(base, alias, temp);
|
|
115
|
+
const created = { version: 1, repository: base.repository, acceptedBranch: base.acceptedBranch, fetchedAt: now, attemptedAt: now, error: null };
|
|
116
|
+
save(stateFile(temp), created);
|
|
117
|
+
// Readers only ever see a complete cache: build aside, then rename.
|
|
118
|
+
const old = fs.existsSync(cache) ? `${cache}.old-${randomUUID()}` : null;
|
|
119
|
+
if (old) fs.renameSync(cache, old);
|
|
120
|
+
fs.renameSync(temp, cache); syncDir(dirname(cache));
|
|
121
|
+
if (old) fs.rmSync(old, { recursive: true, force: true });
|
|
122
|
+
return gitContext(bindings, alias, base, cache, created);
|
|
123
|
+
} finally { fs.rmSync(temp, { recursive: true, force: true }); }
|
|
124
|
+
}
|
|
125
|
+
try { fetchAccepted(base, alias, cache); state = { ...state, fetchedAt: now, attemptedAt: now, error: null }; }
|
|
126
|
+
catch (e) {
|
|
127
|
+
if (e.code !== 'E_BASE_UNAVAILABLE') throw e;
|
|
128
|
+
// Serve the last fetched accepted commit, loudly; retry after max-age or --fresh.
|
|
129
|
+
state = { ...state, attemptedAt: now, error: `fetch of the accepted branch failed at ${now} (reason: ${e.reason || 'unknown'}); serving the last fetched accepted commit` };
|
|
130
|
+
}
|
|
131
|
+
save(stateFile(cache), state);
|
|
132
|
+
return gitContext(bindings, alias, base, cache, state);
|
|
133
|
+
}, { waitMs: state ? Math.min(gitTimeoutMs(), 60000) : gitTimeoutMs() }); // a cached commit can be served sooner
|
|
134
|
+
} catch (e) {
|
|
135
|
+
if (e.code === 'E_LOCKED' && state) return gitContext(bindings, alias, base, cache, { ...state, error: `host cache lock is busy (${lock}); serving the last fetched accepted commit` });
|
|
136
|
+
throw e;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** The cache is disposable host state, not accepted custody: a lock whose
|
|
140
|
+
* owner process on this host is gone is released (an interrupted clone is
|
|
141
|
+
* built aside and never renamed in; an interrupted fetch leaves refs whole).
|
|
142
|
+
* A live, foreign-host or unreadable owner is always waited for. */
|
|
143
|
+
function clearDeadCacheLock(lock) {
|
|
144
|
+
let owner; try { owner = readJSON(join(lock, 'owner.json')); } catch { return; }
|
|
145
|
+
if (owner?.host !== hostname() || !Number.isInteger(owner.pid)) return;
|
|
146
|
+
try { process.kill(owner.pid, 0); } catch (e) {
|
|
147
|
+
if (e.code !== 'ESRCH') return;
|
|
148
|
+
let current; try { current = readJSON(join(lock, 'owner.json')); } catch { return; }
|
|
149
|
+
if (current?.token === owner.token) fs.rmSync(lock, { recursive: true, force: true }); // still the dead owner's lock
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const repoPath = (ctx, p) => ctx.base.root === '.' ? p : (p ? `${ctx.base.root}/${p}` : ctx.base.root);
|
|
153
|
+
const basePathOf = (ctx, p) => ctx.base.root === '.' ? p : p.slice(ctx.base.root.length + 1);
|
|
154
|
+
function lsTree(ctx, p, { recursive = false } = {}) {
|
|
155
|
+
const r = gitRun(ctx.cache, ['ls-tree', '-z', ...(recursive ? ['-r'] : []), `${ctx.commit}:${repoPath(ctx, p)}`]);
|
|
156
|
+
if (r.status !== 0) throw gitError(r);
|
|
157
|
+
return r.stdout.split('\0').filter(Boolean).map(line => {
|
|
158
|
+
const tab = line.indexOf('\t'), [mode, type, oid] = line.slice(0, tab).split(' ');
|
|
159
|
+
return { name: line.slice(tab + 1), mode, type, oid };
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
function gitEntry(ctx, p) {
|
|
163
|
+
if (!p) return { type: 'dir' };
|
|
164
|
+
const r = gitRun(ctx.cache, ['ls-tree', '-z', ctx.commit, '--', repoPath(ctx, p)]);
|
|
165
|
+
if (r.status !== 0) throw gitError(r);
|
|
166
|
+
const line = r.stdout.split('\0').filter(Boolean)[0];
|
|
167
|
+
if (!line) return null;
|
|
168
|
+
const tab = line.indexOf('\t'), [mode, type, oid] = line.slice(0, tab).split(' ');
|
|
169
|
+
if (line.slice(tab + 1) !== repoPath(ctx, p)) return null;
|
|
170
|
+
if (mode === '040000' && type === 'tree') return { type: 'dir', oid };
|
|
171
|
+
if (['100644', '100755'].includes(mode) && type === 'blob') return { type: 'file', oid };
|
|
172
|
+
fail('E_PATH', `${ctx.alias}${shown(p)}: symlink or submodule in knowledge base is not allowed`);
|
|
173
|
+
}
|
|
174
|
+
/** One batched fetch for the blobs a command is about to read, instead of one
|
|
175
|
+
* lazy round trip per blob. Best effort: a failure surfaces on the read. */
|
|
176
|
+
function prefetch(ctx, p, only = null) {
|
|
177
|
+
const r = gitRun(ctx.cache, ['rev-list', '--objects', '--missing=print', `${ctx.commit}:${repoPath(ctx, p)}`]);
|
|
178
|
+
if (r.status !== 0) return;
|
|
179
|
+
const missing = r.stdout.split('\n').filter(l => l.startsWith('?')).map(l => l.slice(1).trim()).filter(oid => !only || only.has(oid));
|
|
180
|
+
if (!missing.length) return;
|
|
181
|
+
try {
|
|
182
|
+
withLock(cacheLock(ctx.bindings, ctx.base), () => gitRun(ctx.cache, ['-c', 'fetch.negotiationAlgorithm=noop', 'fetch', '--no-tags', '--no-write-fetch-head', '--recurse-submodules=no', '--filter=blob:none', 'origin', '--stdin'],
|
|
183
|
+
{ input: missing.join('\n') + '\n', timeout: gitTimeoutMs() }), { waitMs: gitTimeoutMs() });
|
|
184
|
+
} catch { /* lazy reads remain authoritative */ }
|
|
185
|
+
}
|
|
186
|
+
function gitBlob(ctx, oid, p, { limit } = {}) {
|
|
187
|
+
const r = gitRun(ctx.cache, ['cat-file', 'blob', oid], { timeout: gitTimeoutMs(), maxBuffer: limit ?? TEXT_BYTES });
|
|
188
|
+
if (r.error?.code === 'ENOBUFS') { if (limit) return r.stdout; fail('E_TOO_LARGE', `${ctx.alias}${shown(p)} exceeds ${TEXT_BYTES} bytes`); }
|
|
189
|
+
if (r.error || r.status !== 0) unavailable(ctx.base, ctx.alias, 'read', gitError(r));
|
|
190
|
+
return r.stdout;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------- stores
|
|
194
|
+
function dirEntry(ctx, p) {
|
|
195
|
+
const file = safePath(join(ctx.base.path, p));
|
|
196
|
+
let stat; try { stat = fs.lstatSync(file); } catch (e) { if (['ENOENT', 'ENOTDIR'].includes(e.code)) return null; throw e; }
|
|
197
|
+
if (stat.isDirectory()) return { type: 'dir', file };
|
|
198
|
+
if (stat.isFile()) { if (stat.nlink !== 1) fail('E_PATH', `${ctx.alias}${shown(p)}: hardlink not allowed`); return { type: 'file', file }; }
|
|
199
|
+
fail('E_PATH', `${ctx.alias}${shown(p)}: unsupported entry in knowledge base`);
|
|
200
|
+
}
|
|
201
|
+
function readBounded(file, limit) {
|
|
202
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
203
|
+
try {
|
|
204
|
+
const size = fs.fstatSync(fd).size;
|
|
205
|
+
if (!limit && size > TEXT_BYTES) fail('E_TOO_LARGE', `${file} exceeds ${TEXT_BYTES} bytes`);
|
|
206
|
+
const bytes = Buffer.alloc(Math.min(size, limit ?? size)); let length = 0;
|
|
207
|
+
while (length < bytes.length) { const n = fs.readSync(fd, bytes, length, bytes.length - length, null); if (!n) break; length += n; }
|
|
208
|
+
return bytes.subarray(0, length).toString('utf8');
|
|
209
|
+
} finally { fs.closeSync(fd); }
|
|
210
|
+
}
|
|
211
|
+
const entry = (ctx, p) => ctx.kind === 'git' ? gitEntry(ctx, p) : dirEntry(ctx, p);
|
|
212
|
+
function read(ctx, p, e = entry(ctx, p), opts) { return ctx.kind === 'git' ? gitBlob(ctx, e.oid, p, opts) : readBounded(e.file, opts?.limit); }
|
|
213
|
+
function children(ctx, p) {
|
|
214
|
+
const rows = ctx.kind === 'git'
|
|
215
|
+
? lsTree(ctx, p).map(r => ({ name: r.name, oid: r.oid, type: r.mode === '040000' ? 'dir' : ['100644', '100755'].includes(r.mode) ? 'file' : 'unsupported' }))
|
|
216
|
+
: fs.readdirSync(safePath(join(ctx.base.path, p)), { withFileTypes: true }).map(d => ({ name: d.name, file: join(ctx.base.path, p, d.name), type: d.isDirectory() ? 'dir' : d.isFile() ? 'file' : 'unsupported' }));
|
|
217
|
+
return rows.filter(r => !r.name.startsWith('.')).sort((a, b) => (a.type === 'dir') !== (b.type === 'dir') ? (a.type === 'dir' ? -1 : 1) : a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
218
|
+
}
|
|
219
|
+
function notFound(ctx, p) {
|
|
220
|
+
let dir = parent(p);
|
|
221
|
+
while (dir && entry(ctx, dir)?.type !== 'dir') dir = parent(dir);
|
|
222
|
+
const rows = children(ctx, dir), names = rows.slice(0, 20).map(r => r.type === 'dir' ? `${r.name}/` : r.name);
|
|
223
|
+
fail('E_NOT_FOUND', `${ctx.alias}${shown(p)} does not exist at ${short(ctx.receipt)}; \`oats okf ls --base ${ctx.alias} ${shown(dir)}\` lists: ${names.join(', ') || '(empty)'}${rows.length > 20 ? `, and ${rows.length - 20} more` : ''}`);
|
|
224
|
+
}
|
|
225
|
+
function requireFile(ctx, p) {
|
|
226
|
+
if (!p.endsWith('.md')) fail('E_NOT_MARKDOWN', `${ctx.alias}${shown(p)} is not a Markdown file; cat reads only .md concepts (use ls for directories)`);
|
|
227
|
+
const e = entry(ctx, p);
|
|
228
|
+
if (!e) notFound(ctx, p);
|
|
229
|
+
if (e.type !== 'file') fail('E_NOT_MARKDOWN', `${ctx.alias}${shown(p)} is a directory; use ls`);
|
|
230
|
+
return e;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Run fn against one base at its accepted state. Git: the host cache (fetched
|
|
234
|
+
* per freshness rules). Directory: in place, under the base lock, refusing a
|
|
235
|
+
* pending publication exactly as staging does. */
|
|
236
|
+
export function withBase(bindings, alias, { fresh = false } = {}, fn) {
|
|
237
|
+
if (typeof alias !== 'string' || !Object.hasOwn(bindings.bases, alias)) fail('E_BASE_UNKNOWN', `unknown base ${JSON.stringify(alias ?? null)}; bound bases: ${Object.keys(bindings.bases).join(', ')}`);
|
|
238
|
+
const base = bindings.bases[alias];
|
|
239
|
+
if (base.kind === 'git') return fn(resolveGit(bindings, alias, base, { fresh }));
|
|
240
|
+
noGit(base.path);
|
|
241
|
+
return withLock(baseLock(base), () => {
|
|
242
|
+
if (fs.existsSync(journalPath(base))) fail('E_RECOVERY', `publication pending: ${journalPath(base)}; retry its recorded run before reading`);
|
|
243
|
+
if (!fs.existsSync(safePath(base.path))) fail('E_BASE_UNAVAILABLE', `directory base "${alias}" is missing at ${base.path}`);
|
|
244
|
+
const ctx = { alias, base, bindings, kind: 'directory' };
|
|
245
|
+
ctx.receipt = { base: alias, id: base.id, kind: 'directory', digest: digest(tree(base.path)), fetchedAt: new Date().toISOString(), stale: false };
|
|
246
|
+
return fn(ctx);
|
|
247
|
+
}, { waitMs: DIRECTORY_WAIT_MS });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------- validation
|
|
251
|
+
const VALIDATION_CODES = new Set(['E_VALIDATION', 'E_BASE', 'E_PATH', 'E_ID', 'E_CONFIG']);
|
|
252
|
+
/** Whether the accepted state is a valid OKF base. Git verdicts are cached per
|
|
253
|
+
* commit inside the host cache; computing one materializes the base root into
|
|
254
|
+
* a transient host scratch that is removed before returning. */
|
|
255
|
+
export function verdict(ctx) {
|
|
256
|
+
const judge = run => {
|
|
257
|
+
try { const v = run(); return { version: 1, ok: true, digest: v.digest, nodes: v.meta.nodes }; }
|
|
258
|
+
catch (e) { if (!VALIDATION_CODES.has(e.code)) throw e; return { version: 1, ok: false, error: { code: e.code, message: e.message } }; }
|
|
259
|
+
};
|
|
260
|
+
if (ctx.kind === 'directory') return judge(() => validateBase(ctx.base.path, ctx.base));
|
|
261
|
+
const file = join(ctx.cache, 'okf-validation', `${ctx.commit}.json`);
|
|
262
|
+
if (fs.existsSync(file)) { const cached = readJSON(file); if (cached?.version === 1) return cached; }
|
|
263
|
+
const scratch = fs.mkdtempSync(join(dirname(ctx.cache), '.validate-'));
|
|
264
|
+
try {
|
|
265
|
+
const result = judge(() => {
|
|
266
|
+
const rows = lsTree(ctx, '', { recursive: true });
|
|
267
|
+
for (const r of rows) if (!['100644', '100755'].includes(r.mode) || r.type !== 'blob') fail('E_PATH', 'symlink or submodule in knowledge base is not allowed');
|
|
268
|
+
prefetch(ctx, '');
|
|
269
|
+
for (const r of rows) {
|
|
270
|
+
const target = safePath(join(scratch, r.name)); fs.mkdirSync(dirname(target), { recursive: true });
|
|
271
|
+
const fd = fs.openSync(target, 'wx', 0o600);
|
|
272
|
+
let w; try { w = spawnSync('git', ['--no-replace-objects', '-c', 'core.hooksPath=/dev/null', '-C', ctx.cache, 'cat-file', 'blob', r.oid], { env: gitEnv(), timeout: gitTimeoutMs(), stdio: ['ignore', fd, 'pipe'] }); } finally { fs.closeSync(fd); }
|
|
273
|
+
if (w.error || w.status !== 0) unavailable(ctx.base, ctx.alias, 'read', gitError(w));
|
|
274
|
+
}
|
|
275
|
+
return validateBase(scratch, ctx.base);
|
|
276
|
+
});
|
|
277
|
+
save(file, { ...result, commit: ctx.commit, at: new Date().toISOString() });
|
|
278
|
+
return result;
|
|
279
|
+
} finally { fs.rmSync(scratch, { recursive: true, force: true }); }
|
|
280
|
+
}
|
|
281
|
+
/** Registration's accepted resolution: per base, the accepted commit/digest and
|
|
282
|
+
* its nodes, validated, and the soul's declaration resolved against them. No
|
|
283
|
+
* files are materialized for the instance. */
|
|
284
|
+
export function acceptedResolution(bindings, decl) {
|
|
285
|
+
const view = {}, accepted = {};
|
|
286
|
+
for (const alias of Object.keys(bindings.bases)) withBase(bindings, alias, {}, ctx => {
|
|
287
|
+
const v = verdict(ctx);
|
|
288
|
+
if (!v.ok) fail(v.error.code, `base "${alias}" at ${short(ctx.receipt)}: ${v.error.message}`);
|
|
289
|
+
accepted[alias] = { nodes: v.nodes };
|
|
290
|
+
view[alias] = { ...ctx.receipt, digest: v.digest, nodes: v.nodes };
|
|
291
|
+
});
|
|
292
|
+
resolveNodes(decl, bindings, accepted);
|
|
293
|
+
return view;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------- helpers
|
|
297
|
+
function nodesOf(ctx) {
|
|
298
|
+
let m;
|
|
299
|
+
const e = entry(ctx, 'okf-base.json');
|
|
300
|
+
try { m = JSON.parse(e ? read(ctx, 'okf-base.json', e) : ''); } catch { fail('E_BASE', `base "${ctx.alias}" has no valid okf-base.json at ${short(ctx.receipt)}`); }
|
|
301
|
+
if (!obj(m?.nodes)) fail('E_BASE', `base "${ctx.alias}" okf-base.json has no nodes`);
|
|
302
|
+
return m.nodes;
|
|
303
|
+
}
|
|
304
|
+
function nodePath(ctx, node) {
|
|
305
|
+
const nodes = nodesOf(ctx);
|
|
306
|
+
if (!Object.hasOwn(nodes, node) || typeof nodes[node]?.path !== 'string') fail('E_NOT_FOUND', `base "${ctx.alias}" has no node "${node}"; nodes: ${Object.keys(nodes).join(', ')}`);
|
|
307
|
+
return resolveBasePath(nodes[node].path);
|
|
308
|
+
}
|
|
309
|
+
export function frontmatter(text) {
|
|
310
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?(?:\n|$)/.exec(text);
|
|
311
|
+
if (!m) return null;
|
|
312
|
+
const out = {};
|
|
313
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
314
|
+
const kv = /^(type|title|description):\s*(.*)$/.exec(line);
|
|
315
|
+
if (kv) out[kv[1]] = kv[2].trim().replace(/^(['"])(.*)\1$/, '$2').slice(0, 300);
|
|
316
|
+
}
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
export function markdownLinks(text) {
|
|
320
|
+
const clean = text.replace(/^(```|~~~)[^\n]*\n[\s\S]*?^\1[^\n]*$/gm, '').replace(/`[^`\n]*`/g, '');
|
|
321
|
+
const links = [], seen = new Set();
|
|
322
|
+
const add = (label, target) => { if (!target || seen.has(target)) return; seen.add(target); links.push({ text: label, target }); };
|
|
323
|
+
for (const m of clean.matchAll(/!?\[([^\]]*)\]\(\s*<?([^)\s>]+)>?(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/g)) add(m[1], m[2]);
|
|
324
|
+
for (const m of clean.matchAll(/^\s{0,3}\[([^\]]+)\]:\s*<?(\S+?)>?(?:\s+.*)?$/gm)) add(m[1], m[2]);
|
|
325
|
+
return links;
|
|
326
|
+
}
|
|
327
|
+
function age(iso) {
|
|
328
|
+
const s = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000));
|
|
329
|
+
return s < 5 ? 'just now' : s < 90 ? `${s}s ago` : s < 5400 ? `${Math.round(s / 60)}m ago` : s < 172800 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago`;
|
|
330
|
+
}
|
|
331
|
+
export const short = r => `${r.base}@${r.kind === 'git' ? r.commit.slice(0, 12) : `dir:${r.digest.slice(0, 12)}`}`;
|
|
332
|
+
export const footer = receipts => receipts.map(r => `— ${short(r)} (fetched ${age(r.fetchedAt)}${r.stale ? `; STALE: ${r.reason}` : ''})`).join('\n');
|
|
333
|
+
const fsRoots = (source, ctx) => [source.home, source.work, source.bindings.stateDir, dirname(source.bindings.file), ctx.kind === 'directory' ? ctx.base.path : null];
|
|
334
|
+
const refsOf = decl => [...decl.owns.map(ref => [ref, 'owns']), ...decl.reads.filter(ref => !decl.owns.includes(ref)).map(ref => [ref, 'reads'])];
|
|
335
|
+
function needBase(source, flags) {
|
|
336
|
+
if (!flags.base) fail('E_USAGE', `--base ALIAS is required; bound bases: ${Object.keys(source.bindings.bases).join(', ')}`);
|
|
337
|
+
return flags.base;
|
|
338
|
+
}
|
|
339
|
+
function onePath(positionals, label) {
|
|
340
|
+
if (positionals.length !== 1) fail('E_USAGE', `expected one ${label}`);
|
|
341
|
+
return positionals[0];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------- commands
|
|
345
|
+
export function bases(source, flags) {
|
|
346
|
+
const rows = [], receipts = [];
|
|
347
|
+
for (const [alias, base] of Object.entries(source.bindings.bases)) withBase(source.bindings, alias, { fresh: !!flags.fresh }, ctx => {
|
|
348
|
+
const v = verdict(ctx), mine = relation => source.decl[relation].filter(ref => splitRef(ref)[0] === alias).map(ref => splitRef(ref)[1]);
|
|
349
|
+
rows.push({ alias, id: base.id, kind: base.kind, ...(base.kind === 'git' ? { repository: base.repository, acceptedBranch: base.acceptedBranch, root: base.root, commit: ctx.commit } : { path: base.path, digest: ctx.receipt.digest }),
|
|
350
|
+
fetchedAt: ctx.receipt.fetchedAt, stale: ctx.receipt.stale, ...(ctx.receipt.reason ? { reason: ctx.receipt.reason } : {}),
|
|
351
|
+
validated: v.ok ? { ok: true } : { ok: false, error: v.error }, nodes: v.ok ? Object.keys(v.nodes) : null, owns: mine('owns'), reads: mine('reads'), receipt: ctx.receipt });
|
|
352
|
+
receipts.push(ctx.receipt);
|
|
353
|
+
});
|
|
354
|
+
const text = rows.map(r => `${r.alias} (${r.kind}) ${r.kind === 'git' ? `${r.repository} ${r.acceptedBranch} root=${r.root}` : r.path}\n accepted ${short(r.receipt)} fetched ${age(r.fetchedAt)}${r.stale ? ` STALE: ${r.reason}` : ''}; ${r.validated.ok ? 'valid OKF base' : `INVALID: ${r.validated.error.message}`}\n owns: ${r.owns.join(', ') || '-'}; reads: ${r.reads.join(', ') || '-'}${r.nodes ? `; nodes: ${r.nodes.join(', ')}` : ''}`).join('\n');
|
|
355
|
+
return { result: { bases: rows }, text: `${text}\n${footer(receipts)}` };
|
|
356
|
+
}
|
|
357
|
+
export function index(source, flags, positionals) {
|
|
358
|
+
if (positionals.length > 1) fail('E_USAGE', 'index takes at most one node');
|
|
359
|
+
let targets;
|
|
360
|
+
const relation = (alias, node) => refsOf(source.decl).find(([ref]) => ref === `${alias}/${node}`)?.[1] ?? null;
|
|
361
|
+
if (positionals.length) {
|
|
362
|
+
let alias = flags.base, node = positionals[0];
|
|
363
|
+
if (node.includes('/')) { const [a, n] = splitRef(node); if (alias && alias !== a) fail('E_USAGE', `--base ${alias} disagrees with ${node}`); alias = a; node = n; }
|
|
364
|
+
else if (!alias) {
|
|
365
|
+
const matches = refsOf(source.decl).filter(([ref]) => splitRef(ref)[1] === node);
|
|
366
|
+
if (matches.length !== 1) fail('E_USAGE', matches.length ? `node "${node}" is in several bases (${matches.map(([r]) => r).join(', ')}); pass --base ALIAS` : `node "${node}" is not one of your nodes (${refsOf(source.decl).map(([r]) => r).join(', ') || 'none'}); pass --base ALIAS`);
|
|
367
|
+
alias = splitRef(matches[0][0])[0];
|
|
368
|
+
}
|
|
369
|
+
targets = [{ alias, node }];
|
|
370
|
+
} else if (flags.base) targets = [{ alias: flags.base, node: null }];
|
|
371
|
+
else targets = refsOf(source.decl).map(([ref]) => { const [alias, node] = splitRef(ref); return { alias, node }; });
|
|
372
|
+
if (!targets.length) fail('E_USAGE', 'this soul owns and reads no nodes; pass --base ALIAS [node]');
|
|
373
|
+
const indexes = [], receipts = [];
|
|
374
|
+
for (const alias of [...new Set(targets.map(t => t.alias))]) withBase(source.bindings, alias, { fresh: !!flags.fresh }, ctx => {
|
|
375
|
+
receipts.push(ctx.receipt);
|
|
376
|
+
for (const t of targets.filter(t => t.alias === alias)) {
|
|
377
|
+
const path = t.node ? `${nodePath(ctx, t.node)}/index.md` : 'index.md', e = requireFile(ctx, path);
|
|
378
|
+
indexes.push({ base: alias, node: t.node, relation: t.node ? relation(alias, t.node) : null, path, text: read(ctx, path, e), receipt: ctx.receipt });
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
const order = new Map(targets.map((t, i) => [`${t.alias}/${t.node}`, i]));
|
|
382
|
+
indexes.sort((a, b) => order.get(`${a.base}/${a.node}`) - order.get(`${b.base}/${b.node}`));
|
|
383
|
+
const text = indexes.map(x => `## ${x.node ? `${x.base}/${x.node}` : x.base}${x.relation ? ` (${x.relation})` : ''}\n\n${x.text.trimEnd()}\n`).join('\n');
|
|
384
|
+
return { result: { indexes }, text: `${text}\n${footer(receipts)}` };
|
|
385
|
+
}
|
|
386
|
+
export function cat(source, flags, positionals) {
|
|
387
|
+
const input = onePath(positionals, 'path');
|
|
388
|
+
return withBase(source.bindings, needBase(source, flags), { fresh: !!flags.fresh }, ctx => {
|
|
389
|
+
const roots = fsRoots(source, ctx);
|
|
390
|
+
const from = flags.from === undefined ? null : resolveBasePath(flags.from, { fsRoots: roots });
|
|
391
|
+
const path = resolveBasePath(input, { from, fsRoots: roots }), e = requireFile(ctx, path);
|
|
392
|
+
const text = read(ctx, path, e);
|
|
393
|
+
return { result: { path, text, receipt: ctx.receipt }, text: `${text.trimEnd()}\n${footer([ctx.receipt])}` };
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
export function ls(source, flags, positionals) {
|
|
397
|
+
if (positionals.length > 1) fail('E_USAGE', 'ls takes at most one directory');
|
|
398
|
+
return withBase(source.bindings, needBase(source, flags), { fresh: !!flags.fresh }, ctx => {
|
|
399
|
+
const path = positionals.length ? resolveBasePath(positionals[0], { fsRoots: fsRoots(source, ctx) }) : '';
|
|
400
|
+
const e = entry(ctx, path);
|
|
401
|
+
if (!e) notFound(ctx, path);
|
|
402
|
+
if (e.type !== 'dir') fail('E_USAGE', `${ctx.alias}${shown(path)} is a file; use cat`);
|
|
403
|
+
const rows = children(ctx, path);
|
|
404
|
+
if (ctx.kind === 'git') prefetch(ctx, path, new Set(rows.filter(r => r.type === 'file' && r.name.endsWith('.md')).map(r => r.oid)));
|
|
405
|
+
const entries = rows.map(r => {
|
|
406
|
+
const p = path ? `${path}/${r.name}` : r.name, out = { name: r.name, path: p, kind: r.type };
|
|
407
|
+
if (r.type === 'file' && r.name.endsWith('.md')) {
|
|
408
|
+
const fm = frontmatter(ctx.kind === 'git' ? gitBlob(ctx, r.oid, p, { limit: FRONTMATTER_BYTES }) : readBounded(safePath(r.file), FRONTMATTER_BYTES));
|
|
409
|
+
if (fm) Object.assign(out, fm);
|
|
410
|
+
}
|
|
411
|
+
return out;
|
|
412
|
+
});
|
|
413
|
+
const text = entries.map(x => {
|
|
414
|
+
if (x.kind === 'dir') return `${x.name}/`;
|
|
415
|
+
if (x.kind === 'unsupported') return `${x.name} (unsupported entry, refused)`;
|
|
416
|
+
const meta = [x.type ? `[${x.type}]` : '', x.title, x.description ? `— ${x.description}` : ''].filter(Boolean).join(' ');
|
|
417
|
+
return meta ? `${x.name} ${meta}` : x.name;
|
|
418
|
+
}).join('\n');
|
|
419
|
+
return { result: { path, entries, receipt: ctx.receipt }, text: `${shown(path)}\n${text || '(empty)'}\n${footer([ctx.receipt])}` };
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
export function links(source, flags, positionals) {
|
|
423
|
+
const input = onePath(positionals, 'path');
|
|
424
|
+
return withBase(source.bindings, needBase(source, flags), { fresh: !!flags.fresh }, ctx => {
|
|
425
|
+
const roots = fsRoots(source, ctx), path = resolveBasePath(input, { fsRoots: roots }), e = requireFile(ctx, path);
|
|
426
|
+
const rows = markdownLinks(read(ctx, path, e)).filter(l => !l.target.startsWith('#')).map(l => {
|
|
427
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(l.target) || l.target.startsWith('//')) return { ...l, external: true };
|
|
428
|
+
try {
|
|
429
|
+
const p = resolveBasePath(l.target, { from: path, fsRoots: roots }), found = p ? entry(ctx, p) : { type: 'dir' };
|
|
430
|
+
return { ...l, path: p, exists: !!found, ...(found?.type === 'dir' ? { directory: true } : {}) };
|
|
431
|
+
} catch (err) { if (!['E_PATH', 'E_USAGE'].includes(err.code)) throw err; return { ...l, exists: false, refused: err.message }; }
|
|
432
|
+
});
|
|
433
|
+
const text = rows.map(r => r.external ? `external ${r.target}` : r.refused ? `REFUSED ${r.target} (${r.refused})` : `${r.exists ? 'ok ' : 'MISSING '} ${shown(r.path)}${r.target !== shown(r.path) ? ` (${r.target})` : ''}`).join('\n');
|
|
434
|
+
return { result: { path, links: rows, receipt: ctx.receipt }, text: `${shown(path)} links:\n${text || '(none)'}\n${footer([ctx.receipt])}` };
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
function matcher(query, { regex, caseSensitive }) {
|
|
438
|
+
if (regex) { let re; try { re = new RegExp(query, caseSensitive ? '' : 'i'); } catch (e) { fail('E_USAGE', `invalid --regex: ${e.message}`); } return line => { const m = re.exec(line); return m ? m.index : -1; }; }
|
|
439
|
+
const needle = caseSensitive ? query : query.toLowerCase();
|
|
440
|
+
return line => (caseSensitive ? line : line.toLowerCase()).indexOf(needle);
|
|
441
|
+
}
|
|
442
|
+
function snippet(line, at) {
|
|
443
|
+
const text = line.trim(), offset = Math.max(0, (at ?? 0) - (line.length - line.trimStart().length) - 60);
|
|
444
|
+
const cut = text.length <= SNIPPET ? text : text.slice(offset, offset + SNIPPET);
|
|
445
|
+
return `${offset > 0 && text.length > SNIPPET ? '…' : ''}${cut}${offset + SNIPPET < text.length ? '…' : ''}`;
|
|
446
|
+
}
|
|
447
|
+
function searchBase(ctx, prefix, query, opts, hits) {
|
|
448
|
+
const find = matcher(query, opts);
|
|
449
|
+
if (ctx.kind === 'git') {
|
|
450
|
+
prefetch(ctx, prefix);
|
|
451
|
+
const r = gitRun(ctx.cache, ['grep', '-z', '-n', '-I', '--no-color', ...(opts.regex ? ['-E'] : ['-F']), ...(opts.caseSensitive ? [] : ['-i']), '-e', query, ctx.commit, '--', repoPath(ctx, prefix) || '.'], { timeout: gitTimeoutMs() });
|
|
452
|
+
if (r.error?.code === 'ENOBUFS') fail('E_TOO_LARGE', 'search output is too large; narrow it with --node or a longer query');
|
|
453
|
+
if (r.status === 1 && !r.stderr.trim()) return;
|
|
454
|
+
if (r.error || r.status !== 0) unavailable(ctx.base, ctx.alias, 'search', gitError(r));
|
|
455
|
+
for (const line of r.stdout.split('\n').filter(Boolean)) {
|
|
456
|
+
const [where, number, ...rest] = line.split('\0'), repo = where.slice(ctx.commit.length + 1), text = rest.join('\0');
|
|
457
|
+
if (!repo.endsWith('.md') || (ctx.base.root !== '.' && !repo.startsWith(ctx.base.root + '/'))) continue;
|
|
458
|
+
const p = basePathOf(ctx, repo);
|
|
459
|
+
if (p.split('/').some(s => s.startsWith('.'))) continue;
|
|
460
|
+
hits.push({ base: ctx.alias, path: p, line: Number(number), snippet: snippet(text, find(text)) });
|
|
461
|
+
}
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
let files = 0, bytes = 0;
|
|
465
|
+
const walk = dir => {
|
|
466
|
+
for (const r of children(ctx, dir)) {
|
|
467
|
+
const p = dir ? `${dir}/${r.name}` : r.name;
|
|
468
|
+
if (r.type === 'dir') { walk(p); continue; }
|
|
469
|
+
if (r.type !== 'file' || !r.name.endsWith('.md')) continue;
|
|
470
|
+
const e = dirEntry(ctx, p);
|
|
471
|
+
if (++files > WALK_FILES || (bytes += fs.statSync(e.file).size) > WALK_BYTES) fail('E_TOO_LARGE', `directory search is bounded to ${WALK_FILES} files / ${WALK_BYTES} bytes; narrow it with --node`);
|
|
472
|
+
readBounded(e.file).split(/\r?\n/).forEach((text, i) => { const at = find(text); if (at >= 0) hits.push({ base: ctx.alias, path: p, line: i + 1, snippet: snippet(text, at) }); });
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
walk(prefix);
|
|
476
|
+
}
|
|
477
|
+
export function search(source, flags, positionals) {
|
|
478
|
+
if (!positionals.length) fail('E_USAGE', 'search needs the text to look for');
|
|
479
|
+
const query = positionals.join(' ');
|
|
480
|
+
if (flags.all && flags.base) fail('E_USAGE', 'choose --base ALIAS or --all');
|
|
481
|
+
let node = flags.node ?? null, only = flags.base ?? null;
|
|
482
|
+
if (node && node.includes('/')) { const [a, n] = splitRef(node); if (only && only !== a) fail('E_USAGE', `--base ${only} disagrees with --node ${node}`); only = a; node = n; }
|
|
483
|
+
const mine = [...new Set(refsOf(source.decl).map(([ref]) => splitRef(ref)[0]))];
|
|
484
|
+
const scope = only ? [only] : flags.all || !mine.length ? Object.keys(source.bindings.bases) : mine;
|
|
485
|
+
if (node && scope.length !== 1) fail('E_USAGE', '--node needs one base: pass --base ALIAS or --node ALIAS/NODE');
|
|
486
|
+
const hits = [], receipts = [];
|
|
487
|
+
for (const alias of scope) withBase(source.bindings, alias, { fresh: !!flags.fresh }, ctx => {
|
|
488
|
+
receipts.push(ctx.receipt);
|
|
489
|
+
searchBase(ctx, node ? nodePath(ctx, node) : '', query, { regex: !!flags.regex, caseSensitive: !!flags['case-sensitive'] }, hits);
|
|
490
|
+
});
|
|
491
|
+
const shownHits = hits.slice(0, HIT_LIMIT), more = hits.length - shownHits.length;
|
|
492
|
+
const text = shownHits.map(h => `${h.base}${shown(h.path)}:${h.line}: ${h.snippet}`).join('\n');
|
|
493
|
+
return { result: { query, regex: !!flags.regex, bases: scope, node, hits: shownHits, total: hits.length, more, receipts: Object.fromEntries(receipts.map(r => [r.base, r])) },
|
|
494
|
+
text: `${text || `no matches for ${JSON.stringify(query)}`}${more ? `\n… and ${more} more (narrow with --node or a longer query)` : ''}\n${footer(receipts)}` };
|
|
495
|
+
}
|
|
496
|
+
/** okf 2.x compatibility: read --base A [--path P] is cat of P (default the base index). */
|
|
497
|
+
export function readCompat(source, flags) {
|
|
498
|
+
return cat(source, { base: flags.base, fresh: flags.fresh }, [flags.path ?? 'index.md']);
|
|
499
|
+
}
|
|
500
|
+
export const CONSULT = { bases, index, cat, ls, links, search, read: readCompat };
|
|
@@ -99,13 +99,21 @@ export function capturedAuthority(source) {
|
|
|
99
99
|
return {...base,registration:'captured',capture:'recorded',migrationRequired:false,sourceIdentity:JSON.parse(JSON.stringify(identity)),executionBinding:JSON.parse(JSON.stringify(binding)),
|
|
100
100
|
responsibleHuman:{status:source.responsibleHuman===null?'disabled':'specified'}};
|
|
101
101
|
}
|
|
102
|
+
/** A ./knowledge/ snapshot written by okf 2.x. okf 3.0.0 never reads it
|
|
103
|
+
* (knowledge is consulted remotely) and never deletes it. */
|
|
104
|
+
export function legacyLocalView(source,status=loadStatus(source)) {
|
|
105
|
+
if(!liveHome(source,status).available) return null;
|
|
106
|
+
const path=join(safePath(source.home),'knowledge');let stat;
|
|
107
|
+
try {stat=fs.lstatSync(path);} catch(e) {if(missing(e)) return null;throw e;} // never follow it
|
|
108
|
+
return {status:'legacy-local-view',path,ignored:true,...(stat.isSymbolicLink()?{symlink:true}:{}),note:'okf 3.0.0 ignores this okf 2.x snapshot and reads knowledge remotely; it is safe to delete by hand'};
|
|
109
|
+
}
|
|
102
110
|
export function inspect(source) {
|
|
103
|
-
const status=loadStatus(source),working=workingDocuments(source,status);
|
|
111
|
+
const status=loadStatus(source),working=workingDocuments(source,status),legacy=legacyLocalView(source,status);
|
|
104
112
|
let health;try {health=oats(['schedule','list','--dir',source.context,'--json'],source.context).scheduler;} catch(e) {health={active:false,error:e.message};}
|
|
105
113
|
const documents=[...working.documents,{label:'Durable processing receipts',kind:'text',path:join(dirname(source.file),'status.json'),text:JSON.stringify(status,null,2)}];
|
|
106
114
|
return {
|
|
107
|
-
summary:`OKF ${source.id}: ${status.captured.inputs.length-status.processed.length} unprocessed inputs; ${status.retired?'source retired':'source not retired'}; ${working.liveMemory.available?`${working.documents.length} working-memory documents`:`live memory unavailable (${working.liveMemory.reason})`}`,
|
|
115
|
+
summary:`OKF ${source.id}: ${status.captured.inputs.length-status.processed.length} unprocessed inputs; ${status.retired?'source retired':'source not retired'}; ${working.liveMemory.available?`${working.documents.length} working-memory documents`:`live memory unavailable (${working.liveMemory.reason})`}${legacy?'; legacy-local-view ./knowledge/ (ignored, safe to delete)':''}`,
|
|
108
116
|
source:source.file,owns:source.decl.owns,reads:source.decl.reads,bases:source.bindings.bases,authority:capturedAuthority(source),
|
|
109
|
-
acceptedView:source.acceptedView,status,scheduler:health,liveMemory:working.liveMemory,documents
|
|
117
|
+
acceptedView:source.acceptedView,legacyLocalView:legacy,status,scheduler:health,liveMemory:working.liveMemory,documents
|
|
110
118
|
};
|
|
111
119
|
}
|