sdocs-dev 1.14.1 → 1.18.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.
@@ -1,40 +1,39 @@
1
- // Per-file refresh of the SDocs agent block.
1
+ // SmallDocs skill install + legacy-block migration.
2
2
  //
3
- // Operates on AGENT_TARGETS in $HOME. Atomic writes via tmp + rename,
4
- // short-lived exclusive locks to avoid two `sdoc` runs stomping on each
5
- // other, and a backup file beside each agent file before we modify it.
6
- // Symlinks are skipped unless explicitly followed.
3
+ // One canonical SKILL.md lives at ~/.agents/skills/smalldocs/SKILL.md.
4
+ // Every other supported agent gets a relative symlink from its own skills
5
+ // directory into the canonical dir (single source of truth; one write
6
+ // updates every agent). Universal agents already discover ~/.agents/skills,
7
+ // so they are skipped. Windows falls back to a junction (absolute target),
8
+ // then a copy if symlinks are unavailable.
9
+ //
10
+ // On setup/refresh we also strip any recognised always-on block from the
11
+ // historical agent config files so the reference is not loaded twice.
7
12
 
8
13
  const fs = require('fs');
9
14
  const os = require('os');
10
15
  const path = require('path');
11
16
 
12
17
  const {
13
- AGENT_BLOCK_VERSION,
14
- AGENT_BLOCK_BODY,
15
- AGENT_BLOCK_LEGACY_OPEN,
16
- AGENT_TARGETS,
17
- formatAgentBlock,
18
+ SKILL_VERSION,
19
+ SKILL_NAME,
20
+ formatSkill,
21
+ readSkillVersion,
22
+ readSkillEdition,
23
+ canonicalSkillDir,
24
+ canonicalSkillFile,
25
+ resolveSkillAgents,
26
+ legacyBlockTargets,
18
27
  findBookendedBlock,
19
- refreshContent,
28
+ findLegacyBlock,
29
+ removeBlockContent,
20
30
  } = require('./agent-block');
21
31
 
22
32
  const { AGENT_CHANGES_URL } = require('./constants');
23
33
 
24
- function detectAgents() {
25
- const home = os.homedir();
26
- return AGENT_TARGETS
27
- .map(t => ({ ...t, dirPath: path.join(home, t.detectDir || t.dir), filePath: path.join(home, t.dir, t.file) }))
28
- .filter(t => fs.existsSync(t.dirPath));
29
- }
34
+ const IS_WIN = process.platform === 'win32';
30
35
 
31
- function fileHasBlock(filePath) {
32
- try {
33
- const content = fs.readFileSync(filePath, 'utf-8');
34
- return findBookendedBlock(content) !== null
35
- || content.includes(AGENT_BLOCK_LEGACY_OPEN);
36
- } catch (_) { return false; }
37
- }
36
+ // ── generic file helpers ───────────────────────────────────
38
37
 
39
38
  function isSymlink(filePath) {
40
39
  try { return fs.lstatSync(filePath).isSymbolicLink(); }
@@ -84,79 +83,314 @@ function acquireLock(filePath) {
84
83
  }
85
84
  }
86
85
 
87
- function writeBookendedBlock(filePath) {
88
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
89
- const block = formatAgentBlock(AGENT_BLOCK_VERSION, AGENT_BLOCK_BODY);
90
- if (!fs.existsSync(filePath)) {
91
- atomicWrite(filePath, block);
92
- return;
86
+ function copyDirRecursive(src, dest) {
87
+ fs.mkdirSync(dest, { recursive: true });
88
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
89
+ const s = path.join(src, entry.name);
90
+ const d = path.join(dest, entry.name);
91
+ if (entry.isDirectory()) copyDirRecursive(s, d);
92
+ else fs.copyFileSync(s, d);
93
93
  }
94
- const existing = fs.readFileSync(filePath, 'utf-8');
95
- const prefix = existing.endsWith('\n') ? '\n' : '\n\n';
96
- atomicWrite(filePath, existing + prefix + block);
97
94
  }
98
95
 
99
- // Refresh a single agent file.
100
- // Returns { path, name?, changed, fromVersion?, toVersion?, reason?, error? }.
101
- function refreshAgentFile(filePath, opts = {}) {
102
- if (!fs.existsSync(filePath)) return { path: filePath, changed: false, reason: 'absent' };
103
- if (isSymlink(filePath) && !opts.followSymlinks) return { path: filePath, changed: false, reason: 'symlink' };
96
+ // Compare filesystem identity, not just path spelling. Agent skill parents
97
+ // are sometimes symlinked to ~/.agents/skills; in that case two different
98
+ // path strings can name the exact same skill directory.
99
+ function pathsResolveSame(a, b) {
100
+ try { return fs.realpathSync(a) === fs.realpathSync(b); }
101
+ catch (_) {}
102
+
103
+ function withRealParent(p) {
104
+ const resolved = path.resolve(p);
105
+ try { return path.join(fs.realpathSync(path.dirname(resolved)), path.basename(resolved)); }
106
+ catch (_) { return resolved; }
107
+ }
108
+ return withRealParent(a) === withRealParent(b);
109
+ }
104
110
 
105
- const release = acquireLock(filePath);
106
- if (!release) return { path: filePath, changed: false, reason: 'locked' };
111
+ // ── canonical skill ────────────────────────────────────────
107
112
 
113
+ function refreshCanonicalSkill(home) {
114
+ home = home || os.homedir();
115
+ const file = canonicalSkillFile(home);
116
+ let existing = null;
108
117
  try {
109
- const content = fs.readFileSync(filePath, 'utf-8');
110
- const result = refreshContent(content);
111
- if (!result.changed) return { path: filePath, changed: false, reason: result.reason };
112
- backupFile(filePath);
113
- atomicWrite(filePath, result.content);
118
+ const st = fs.lstatSync(file);
119
+ if (!st.isFile()) {
120
+ return {
121
+ changed: false, reason: 'conflict', path: file,
122
+ error: 'existing canonical SKILL.md is not a regular SmallDocs-managed file; left untouched',
123
+ };
124
+ }
125
+ existing = fs.readFileSync(file, 'utf-8');
126
+ } catch (e) {
127
+ if (e.code !== 'ENOENT') {
128
+ return { changed: false, reason: 'error', path: file, error: e.message };
129
+ }
130
+ }
131
+ const currentVersion = existing ? readSkillVersion(existing) : null;
132
+ const currentEdition = existing ? readSkillEdition(existing) : 'standard';
133
+ if (existing !== null && currentVersion === null) {
114
134
  return {
115
- path: filePath, changed: true,
116
- fromVersion: result.fromVersion, toVersion: result.toVersion,
135
+ changed: false, reason: 'conflict', path: file,
136
+ error: 'existing canonical SKILL.md is not managed by SmallDocs; left untouched',
117
137
  };
138
+ }
139
+ if (currentVersion === SKILL_VERSION) {
140
+ return { changed: false, reason: 'current', path: file };
141
+ }
142
+ if (currentVersion !== null && currentVersion > SKILL_VERSION) {
143
+ return { changed: false, reason: 'newer', path: file };
144
+ }
145
+ fs.mkdirSync(path.dirname(file), { recursive: true });
146
+ atomicWrite(file, formatSkill(SKILL_VERSION, { cloud: currentEdition === 'cloud' }));
147
+ return {
148
+ changed: true, path: file,
149
+ fromVersion: currentVersion || 0, toVersion: SKILL_VERSION,
150
+ };
151
+ }
152
+
153
+ // ── symlink install ────────────────────────────────────────
154
+
155
+ // A real directory at an agent skills path is only safe to replace if it looks
156
+ // like one of our own copy-fallback installs (a prior Windows run, or a system
157
+ // where symlinks are unavailable): it must contain a SKILL.md carrying our
158
+ // sdocs-skill marker. Anything else is user content we must not destroy.
159
+ function looksLikeOurSkillDir(dir) {
160
+ try {
161
+ const content = fs.readFileSync(path.join(dir, 'SKILL.md'), 'utf-8');
162
+ return readSkillVersion(content) !== null;
163
+ } catch (_) { return false; }
164
+ }
165
+
166
+ // Ensure <linkDir> resolves to <canonicalDir>. Idempotent: a link already
167
+ // pointing at canonical is a no-op; a wrong/stale symlink is replaced. A real
168
+ // directory is replaced only if it is recognisably one of our copy-fallback
169
+ // installs. Other directories and files are left untouched (returns an error)
170
+ // so the user's own content is never clobbered. Returns { method } where
171
+ // method is symlink | copy | noop | error.
172
+ function ensureSkillLink(canonicalDir, linkDir) {
173
+ if (pathsResolveSame(canonicalDir, linkDir)) return { method: 'noop' };
174
+
175
+ try {
176
+ const st = fs.lstatSync(linkDir);
177
+ if (st.isSymbolicLink()) {
178
+ const tgt = fs.readlinkSync(linkDir);
179
+ const resolved = path.resolve(path.dirname(linkDir), tgt);
180
+ if (resolved === canonicalDir) return { method: 'noop' };
181
+ // Stale/wrong symlink: safe to remove (a link holds no user data).
182
+ fs.rmSync(linkDir, { recursive: true, force: true });
183
+ } else if (st.isDirectory()) {
184
+ if (!looksLikeOurSkillDir(linkDir)) {
185
+ return { method: 'error', error: 'exists and is not a SmallDocs skill directory; left untouched' };
186
+ }
187
+ fs.rmSync(linkDir, { recursive: true, force: true });
188
+ } else {
189
+ return { method: 'error', error: 'exists and is not a SmallDocs skill directory; left untouched' };
190
+ }
118
191
  } catch (e) {
119
- return { path: filePath, changed: false, error: e.message };
120
- } finally {
121
- release();
192
+ if (e.code !== 'ENOENT') return { method: 'error', error: e.message };
193
+ }
194
+
195
+ try { fs.mkdirSync(path.dirname(linkDir), { recursive: true }); }
196
+ catch (e) { return { method: 'error', error: e.message }; }
197
+
198
+ // Relative symlink on POSIX so it survives a home-dir move; junction on
199
+ // Windows requires an absolute target.
200
+ try {
201
+ let target = path.resolve(canonicalDir);
202
+ let linkParent = path.resolve(path.dirname(linkDir));
203
+ try { target = fs.realpathSync(canonicalDir); } catch (_) {}
204
+ try { linkParent = fs.realpathSync(path.dirname(linkDir)); } catch (_) {}
205
+ if (IS_WIN) {
206
+ fs.symlinkSync(target, linkDir, 'junction');
207
+ } else {
208
+ const rel = path.relative(linkParent, target);
209
+ fs.symlinkSync(rel, linkDir, 'dir');
210
+ }
211
+ return { method: 'symlink' };
212
+ } catch (_) {
213
+ // Permission / non-admin Windows: fall back to a real copy.
214
+ try {
215
+ copyDirRecursive(canonicalDir, linkDir);
216
+ return { method: 'copy' };
217
+ } catch (e) {
218
+ return { method: 'error', error: e.message };
219
+ }
220
+ }
221
+ }
222
+
223
+ function detectSkillAgents(home, env) {
224
+ home = home || os.homedir();
225
+ return resolveSkillAgents(home, env || process.env)
226
+ .filter(a => a.detect.some(p => { try { return fs.existsSync(p); } catch (_) { return false; } }));
227
+ }
228
+
229
+ // Evidence that the user previously opted into SmallDocs setup: a skill file
230
+ // already on disk (prior skill install), or a recognised always-on block in one
231
+ // of the historical config files (pre-skill install). maybeAutoRefresh uses
232
+ // this so a brand-new user is NOT auto-installed without consent - they get the
233
+ // interactive first-run prompt (or run `sdoc setup --yes`) instead.
234
+ function hasSetupEvidence(home, env) {
235
+ home = home || os.homedir();
236
+ env = env || process.env;
237
+ try {
238
+ const content = fs.readFileSync(canonicalSkillFile(home), 'utf-8');
239
+ if (readSkillVersion(content) !== null) return true;
240
+ } catch (_) {}
241
+ for (const t of legacyBlockTargets(home, env)) {
242
+ let content;
243
+ try { content = fs.readFileSync(t.file, 'utf-8'); } catch (_) { continue; }
244
+ if (findBookendedBlock(content) || findLegacyBlock(content)) return true;
245
+ }
246
+ return false;
247
+ }
248
+
249
+ // ── legacy block stripping ─────────────────────────────────
250
+
251
+ function stripLegacyBlocks(home, env) {
252
+ home = home || os.homedir();
253
+ const out = [];
254
+
255
+ function skippedTarget(t, reason) {
256
+ try {
257
+ const content = fs.readFileSync(t.file, 'utf-8');
258
+ const hasBlock = !!(findBookendedBlock(content) || findLegacyBlock(content));
259
+ return {
260
+ name: t.name, file: t.file, changed: false, reason,
261
+ error: hasBlock ? `${reason}; recognised legacy SmallDocs block left untouched` : undefined,
262
+ };
263
+ } catch (e) {
264
+ return { name: t.name, file: t.file, changed: false, reason, error: e.message };
265
+ }
266
+ }
267
+
268
+ for (const t of legacyBlockTargets(home, env || process.env)) {
269
+ if (!fs.existsSync(t.file)) { out.push({ name: t.name, file: t.file, changed: false, reason: 'absent' }); continue; }
270
+ if (isSymlink(t.file)) { out.push(skippedTarget(t, 'symlink')); continue; }
271
+
272
+ const release = acquireLock(t.file);
273
+ if (!release) { out.push(skippedTarget(t, 'locked')); continue; }
274
+ try {
275
+ const content = fs.readFileSync(t.file, 'utf-8');
276
+ const r = removeBlockContent(content);
277
+ if (!r.changed) { out.push({ name: t.name, file: t.file, changed: false, reason: r.reason }); continue; }
278
+ backupFile(t.file);
279
+ atomicWrite(t.file, r.content);
280
+ out.push({ name: t.name, file: t.file, changed: true, fromVersion: r.version });
281
+ } catch (e) {
282
+ out.push({ name: t.name, file: t.file, changed: false, error: e.message });
283
+ } finally {
284
+ release();
285
+ }
122
286
  }
287
+ return out;
123
288
  }
124
289
 
125
- function refreshAllAgentFiles(opts = {}) {
126
- const home = os.homedir();
127
- return AGENT_TARGETS.map(t => {
128
- const filePath = path.join(home, t.dir, t.file);
129
- return { name: t.name, ...refreshAgentFile(filePath, opts) };
130
- });
290
+ // ── orchestration ──────────────────────────────────────────
291
+
292
+ // One call that does the full sync: refresh canonical skill, symlink every
293
+ // detected non-universal agent, strip legacy blocks. Returns a result the
294
+ // setup flow reports and derives setup-state from.
295
+ function syncAgentSkill(opts = {}) {
296
+ const home = opts.home || os.homedir();
297
+ const env = opts.env || process.env;
298
+ const canonicalDir = canonicalSkillDir(home);
299
+
300
+ const result = {
301
+ canonical: refreshCanonicalSkill(home),
302
+ links: [],
303
+ stripped: [],
304
+ errors: [],
305
+ };
306
+
307
+ if (result.canonical.error) {
308
+ result.errors.push(`${result.canonical.path}: ${result.canonical.error}`);
309
+ return result;
310
+ }
311
+
312
+ for (const agent of detectSkillAgents(home, env)) {
313
+ if (agent.universal) continue; // canonical copy already covers them
314
+ const linkDir = path.join(agent.dir, SKILL_NAME);
315
+ const r = ensureSkillLink(canonicalDir, linkDir);
316
+ result.links.push({ name: agent.displayName, path: linkDir, ...r });
317
+ if (r.error) result.errors.push(`${agent.name}: ${r.error}`);
318
+ }
319
+
320
+ // Keep the legacy instructions in place unless every required skill link
321
+ // succeeded. They are the safe fallback if an agent-specific path collides
322
+ // with user content or cannot be written.
323
+ if (result.errors.length) return result;
324
+
325
+ result.stripped = stripLegacyBlocks(home, env);
326
+ for (const s of result.stripped) {
327
+ if (s.error) result.errors.push(`${s.file}: ${s.error}`);
328
+ }
329
+
330
+ return result;
331
+ }
332
+
333
+ // True if a sync changed anything (skill written/upgraded, a link created,
334
+ // or a block stripped). Drives the "nothing to do" message and setup state.
335
+ function syncChanged(result) {
336
+ if (result.canonical && result.canonical.changed) return true;
337
+ if (result.links.some(l => l.method === 'symlink' || l.method === 'copy')) return true;
338
+ if (result.stripped.some(s => s.changed)) return true;
339
+ return false;
340
+ }
341
+
342
+ // Flatten a sync result into the {path, changed, error?} shape that
343
+ // implicitConsentState expects.
344
+ function toImplicitResults(result) {
345
+ const arr = [];
346
+ if (result.canonical) {
347
+ arr.push({ path: result.canonical.path, changed: !!result.canonical.changed, error: result.canonical.error });
348
+ }
349
+ for (const l of result.links) {
350
+ arr.push({ path: l.path, changed: l.method === 'symlink' || l.method === 'copy', error: l.error });
351
+ }
352
+ for (const s of result.stripped) {
353
+ arr.push({ path: s.file, changed: s.changed, error: s.error });
354
+ }
355
+ return arr;
131
356
  }
132
357
 
133
- function printRefreshSummary(results) {
134
- const changed = results.filter(r => r.changed);
135
- if (changed.length > 0) {
136
- const n = changed.length;
137
- console.log(`✓ SDocs agent block updated to v${AGENT_BLOCK_VERSION} in ${n} ${n === 1 ? 'file' : 'files'}`);
138
- console.log(` Changes: ${AGENT_CHANGES_URL}#v${AGENT_BLOCK_VERSION}`);
358
+ function printSyncSummary(result) {
359
+ if (result.canonical && result.canonical.changed) {
360
+ console.log(`\u2713 SmallDocs skill updated to v${SKILL_VERSION} at ${result.canonical.path}`);
361
+ console.log(` Changes: ${AGENT_CHANGES_URL}#v${SKILL_VERSION}`);
139
362
  }
140
- for (const r of results.filter(r => r.error)) {
141
- console.log(`! ${r.path}: ${r.error}`);
363
+ if (result.canonical && result.canonical.error) {
364
+ console.log(`! ${result.canonical.path}: ${result.canonical.error}`);
142
365
  }
143
- for (const r of results.filter(r => r.reason === 'symlink')) {
144
- console.log(`! ${r.path}: symlink, skipped (run \`sdoc setup --follow-symlinks\` to follow)`);
366
+ for (const l of result.links) {
367
+ if (l.method === 'symlink' || l.method === 'copy') {
368
+ console.log(`\u2713 ${l.name}: ${l.path} (${l.method})`);
369
+ }
370
+ if (l.error) console.log(`! ${l.name}: ${l.error}`);
145
371
  }
146
- for (const r of results.filter(r => r.reason === 'hand_edited')) {
147
- console.log(`! ${r.path}: local edits detected, run \`sdoc setup\` to refresh manually`);
372
+ for (const s of result.stripped) {
373
+ if (s.changed) console.log(`\u2713 removed old SmallDocs block from ${s.file}`);
374
+ if (s.error) console.log(`! ${s.file}: ${s.error}`);
375
+ if (s.reason === 'hand_edited') console.log(`! ${s.file}: local edits detected, left untouched`);
148
376
  }
149
377
  }
150
378
 
151
379
  module.exports = {
152
- detectAgents,
153
- fileHasBlock,
380
+ IS_WIN,
154
381
  isSymlink,
155
382
  atomicWrite,
156
383
  backupFile,
157
384
  acquireLock,
158
- writeBookendedBlock,
159
- refreshAgentFile,
160
- refreshAllAgentFiles,
161
- printRefreshSummary,
385
+ copyDirRecursive,
386
+ refreshCanonicalSkill,
387
+ ensureSkillLink,
388
+ looksLikeOurSkillDir,
389
+ detectSkillAgents,
390
+ hasSetupEvidence,
391
+ stripLegacyBlocks,
392
+ syncAgentSkill,
393
+ syncChanged,
394
+ toImplicitResults,
395
+ printSyncSummary,
162
396
  };
@@ -86,6 +86,7 @@ function valuesFor(model, fxGrid) {
86
86
  if (fx.kind === 'number') row.push(String(fx.value));
87
87
  else if (fx.kind === 'error') row.push(fx.code);
88
88
  else if (fx.kind === 'text') row.push(String(fx.value));
89
+ else if (fx.kind === 'boolean') row.push(fx.value ? 'TRUE' : 'FALSE');
89
90
  else row.push('');
90
91
  } else {
91
92
  row.push(cell.raw);
@@ -0,0 +1,85 @@
1
+ const crypto = require('crypto');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const credentials = require('./cloud-credentials');
5
+
6
+ function bindingsFile() { return path.join(credentials.cloudDir(), 'bindings.json'); }
7
+ function pendingFile() { return path.join(credentials.cloudDir(), 'pending.json'); }
8
+ function basesDir() { return path.join(credentials.cloudDir(), 'bases'); }
9
+
10
+ function read(file) {
11
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { return {}; }
12
+ }
13
+
14
+ function canonical(file) {
15
+ const absolute = path.resolve(file);
16
+ try { return fs.realpathSync(absolute); } catch (_) { return absolute; }
17
+ }
18
+
19
+ function key(accountId, file) { return accountId + '\0' + canonical(file); }
20
+ function get(accountId, file) { return read(bindingsFile())[key(accountId, file)] || null; }
21
+
22
+ function set(accountId, file, binding) {
23
+ const values = read(bindingsFile());
24
+ values[key(accountId, file)] = { ...binding, account_id: accountId, path: canonical(file) };
25
+ credentials.atomicWrite(bindingsFile(), values);
26
+ return values[key(accountId, file)];
27
+ }
28
+
29
+ function getPending(accountId, file) { return read(pendingFile())[key(accountId, file)] || null; }
30
+ function setPending(accountId, file, value) {
31
+ const values = read(pendingFile());
32
+ values[key(accountId, file)] = value;
33
+ credentials.atomicWrite(pendingFile(), values);
34
+ }
35
+ function clearPending(accountId, file) {
36
+ const values = read(pendingFile());
37
+ delete values[key(accountId, file)];
38
+ credentials.atomicWrite(pendingFile(), values);
39
+ }
40
+
41
+ function operationKey(accountId, operation, resourceId) {
42
+ return accountId + '\0operation:' + operation + '\0' + resourceId;
43
+ }
44
+ function getOperationPending(accountId, operation, resourceId) {
45
+ return read(pendingFile())[operationKey(accountId, operation, resourceId)] || null;
46
+ }
47
+ function setOperationPending(accountId, operation, resourceId, value) {
48
+ const values = read(pendingFile());
49
+ values[operationKey(accountId, operation, resourceId)] = value;
50
+ credentials.atomicWrite(pendingFile(), values);
51
+ }
52
+ function clearOperationPending(accountId, operation, resourceId) {
53
+ const values = read(pendingFile());
54
+ delete values[operationKey(accountId, operation, resourceId)];
55
+ credentials.atomicWrite(pendingFile(), values);
56
+ }
57
+
58
+ function hash(content) { return crypto.createHash('sha256').update(content).digest('hex'); }
59
+
60
+ function baseFile(accountId, documentId, revisionId) {
61
+ const root = path.resolve(basesDir());
62
+ const file = path.resolve(root, String(accountId), String(documentId), String(revisionId) + '.md');
63
+ if (file !== root && !file.startsWith(root + path.sep)) return null;
64
+ return file;
65
+ }
66
+
67
+ function cacheBase(accountId, documentId, revisionId, content) {
68
+ const file = baseFile(accountId, documentId, revisionId);
69
+ if (!file) throw new Error('Cloud base path is invalid');
70
+ const dir = path.dirname(file);
71
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
72
+ fs.writeFileSync(file, content, { mode: 0o600 });
73
+ fs.chmodSync(file, 0o600);
74
+ return file;
75
+ }
76
+
77
+ function readBase(accountId, documentId, revisionId) {
78
+ const file = baseFile(accountId, documentId, revisionId);
79
+ if (!file) return null;
80
+ try { return fs.readFileSync(file, 'utf8'); } catch (_) { return null; }
81
+ }
82
+
83
+ module.exports = { bindingsFile, pendingFile, canonical, get, set, getPending, setPending,
84
+ clearPending, getOperationPending, setOperationPending, clearOperationPending, hash, cacheBase,
85
+ readBase };