quilltap 4.11.0-dev.11 → 4.11.0-dev.110

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/README.md CHANGED
@@ -333,7 +333,7 @@ A character vault's **keystone files** — `properties.json`, the five required
333
333
 
334
334
  ## Memories
335
335
 
336
- `quilltap memories` exposes the same Commonplace Book that each character carries — searchable, sortable, graphable. All verbs open the main encrypted DB read-only; the two that change anything (`maintain`, `validate --repair --write`) ask the running server to do the work.
336
+ `quilltap memories` exposes the same Commonplace Book that each character carries — searchable, sortable, graphable. All verbs open the main encrypted DB read-only; the three that change anything (`maintain`, `validate --repair --write`, `keystone --write`) ask the running server to do the work.
337
337
 
338
338
  ```bash
339
339
  quilltap memories ls # All holders, default sort: reinforcedImportance DESC
@@ -346,15 +346,16 @@ quilltap memories tree <id|prefix> [--depth N] [--max-nodes N] # ASCII w
346
346
  quilltap memories status [--character <name|id>] [--port <n>] # Per-holder rollup, maintenance ledger + dangling-edge check
347
347
  quilltap memories validate [--character <name|id>] [--list] # Graph health check; exit 1 if any dangling link remains
348
348
  quilltap memories validate --repair [--write] # Rehearse (or, with --write, apply) the server's graph repair
349
+ quilltap memories keystone <id|prefix> [--off] [--write] # Rehearse (or, with --write, apply) marking a keystone — a standing fact
349
350
  quilltap memories grep --semantic --character Ariadne "the argument" # Embedding search (server required, one holder)
350
351
  quilltap memories maintain --character Ariadne --dry-run # Rehearse every maintenance phase (server required)
351
352
  quilltap memories maintain --character Ariadne --phases consolidate # Queue a consolidation-only maintenance job
352
353
  quilltap memories maintain --character all [--phases demote,retire] # Queue maintenance for every character
353
354
  ```
354
355
 
355
- Shared filter flags apply to `ls`, `find`, `grep`, and `status` where they make sense: `--character`, `--about` (with `self` / `none` shortcuts), `--source` (AUTO / MANUAL / CONSOLIDATED), `--tier` (hot / cold; both by default, cold rows are starred in the `src` column), `--chat` (with `none` for manual entries), `--project`, `--since`, `--until`, `--min-importance`, `--min-reinforced`, `--has-embedding` / `--no-embedding`. Sort flags (`--sort reinforced|importance|created|accessed|reinforcement-count|links`, plus `-r` / `--reverse` to flip it, `--limit N` (default 50) and `--full-titles` to stop chat titles being truncated to 32 characters) apply to `ls`, `find`, and `grep`. `grep -l` / `--paths-only` prints only the IDs of matching memories, one per line. Names accept fuzzy substrings; ambiguous names print candidates and exit 2. `--json` is supported by every verb. `validate` is `status`'s terse twin — read-only; it counts dangling `relatedMemoryIds` edges, dangling `consolidatedFrom` members (digests naming notes that are gone), dangling `supersededById` pointers, and rows with no embedding. It exits 1 if any dangling link remains (a missing embedding only warns), and `--list` prints the offending IDs. `grep --semantic` defaults to `--top 20`, `--threshold 0.5`, and scopes to **one holder at a time**: `--character all` is rejected. The legacy `quilltap db memories --character <name>` verb remains, now with `--tier hot|cold` (rows with no tier count as hot).
356
+ Shared filter flags apply to `ls`, `find`, `grep`, and `status` where they make sense: `--character`, `--about` (with `self` / `none` shortcuts), `--source` (AUTO / MANUAL / CONSOLIDATED), `--tier` (hot / cold; both by default, cold rows are starred in the `src` column), `--keystone` (keystones only; a keystone's summary is prefixed `◆` in tables, and `show` prints a `Keystone:` line), `--chat` (with `none` for manual entries), `--project`, `--since`, `--until`, `--min-importance`, `--min-reinforced`, `--has-embedding` / `--no-embedding`. Sort flags (`--sort reinforced|importance|created|accessed|reinforcement-count|links`, plus `-r` / `--reverse` to flip it, `--limit N` (default 50) and `--full-titles` to stop chat titles being truncated to 32 characters) apply to `ls`, `find`, and `grep`. `grep -l` / `--paths-only` prints only the IDs of matching memories, one per line. Names accept fuzzy substrings; ambiguous names print candidates and exit 2. `--json` is supported by every verb. `validate` is `status`'s terse twin — read-only; it counts dangling `relatedMemoryIds` edges, dangling `consolidatedFrom` members (digests naming notes that are gone), dangling `supersededById` pointers, and rows with no embedding. It exits 1 if any dangling link remains (a missing embedding only warns), and `--list` prints the offending IDs. `grep --semantic` defaults to `--top 20`, `--threshold 0.5`, and scopes to **one holder at a time**: `--character all` is rejected. The legacy `quilltap db memories --character <name>` verb remains, now with `--tier hot|cold` (rows with no tier count as hot).
356
357
 
357
- Verbs that call the server (`grep --semantic`, `status`'s ledger, `maintain`, `validate --repair`) take `--port`; without it they use the port recorded in the `--instance` / `--data-dir` instance's lock, else 3000.
358
+ Verbs that call the server (`grep --semantic`, `status`'s ledger, `maintain`, `validate --repair`, `keystone --write`) take `--port`; without it they use the port recorded in the `--instance` / `--data-dir` instance's lock, else 3000.
358
359
 
359
360
  `maintain` runs the memory maintenance job by hand: `fold-near-duplicates`, `consolidate`, `demote`, `retire`, `mirror`, always in that order. `--character <name|id|all>` is required (`all` is every character you own); `--phases a,b,…` picks a subset (checked by the server, which answers an unknown name with a 400; naming `consolidate` brings `mirror` along, since the Commonplace mirror follows consolidation). The CLI resolves the character locally and asks the running server — the model, the embeddings and the only database writer live there. With `--dry-run` the server rehearses the run and writes nothing: the CLI prints each phase's status (ran / skipped, and why), then the fold groups, the consolidation clusters (member notes, proposed digests, standalones, contradictions, run stats), the demotion arithmetic (cap, hot total, demotable, protected, excess, demoted), the retire count and the mirror subjects. A dry run over `all` gives one table row per character and may not include `consolidate` (each would call the model). Without `--dry-run` the server queues one `MEMORY_MAINTENANCE` job per character and the CLI prints the job ids; manual runs ignore the master `enabled` switches. `--max N` caps clusters consolidated (`maxClustersPerRun`, 1–1000; a dry run defaults to 10, at most 40), `--threshold 0..1` overrides the cluster similarity (neural embedding profiles), `--cap N` overrides the hot-row cap for demotion (100–100000). `--json` prints the raw API payload. There is no `consolidate` verb any more: use `maintain --phases consolidate`.
360
361
 
@@ -391,7 +392,7 @@ Flags: `--turn <n>` (default: last), `--char <characterId>` (default: first LLM-
391
392
 
392
393
  Tuning and comparable runs:
393
394
 
394
- - `--tuning '<json>'` / `--tuning-file <path>` — retuning constants for the new path only (the old path stays the reference): `boostGateAbs`, `boostGateMargin`, `boostGateRamp` (relevance gate on boosts), `boostCap`, `freshBypassesGate`, `windowBypassesGate`, `multipliers` (`{ "freshEvent24h": 1.3, … }`), `specificAnchors`, `anchorMinHits`, `anchorOrder`, `backgroundReserve`. Unknown keys are rejected. The header's `Tuning` line shows what is active.
395
+ - `--tuning '<json>'` / `--tuning-file <path>` — retuning constants for the new path only (the old path stays the reference): `boostGateAbs`, `boostGateMargin`, `boostGateRamp` (relevance gate on boosts), `boostCap`, `freshBypassesGate`, `windowBypassesGate`, `multipliers` (`{ "freshEvent24h": 1.3, … }`), `specificAnchors`, `anchorMinHits`, `anchorOrder`, `backgroundReserve`, `innerCircleShare` (share of the frozen archive the group inner circle may take, default `0.333…`), `innerCircleAllowance` (digests per circle member by tier, tightest group first, default `[3, 2, 1]`). Unknown keys are rejected. The header's `Tuning` line shows what is active.
395
396
  - `--signals-from <replay.json>` — reuse the signals saved in an earlier `--json` replay of the same chat and turn. The distillation is a cheap-LLM call and words the same turn differently each time, so runs you mean to compare should share it.
396
397
  - `--as-of` — search only memories created before the turn's opening message, with weights decayed to the turn's own clock: the corpus as it stood then. The header's `Harness` line shows the cutoff and how many later memories were left out.
397
398
 
package/bin/quilltap.js CHANGED
@@ -1045,7 +1045,7 @@ async function dbCommand(args) {
1045
1045
  } else if (countTable) {
1046
1046
  const row = db.prepare(`SELECT count(*) as count FROM "${countTable}"`).get();
1047
1047
  if (asJson) console.log(JSON.stringify({ table: countTable, count: row.count }, null, 2));
1048
- else console.log(row.count);
1048
+ else console.log(String(row.count));
1049
1049
  } else if (sql) {
1050
1050
  const stmt = db.prepare(sql);
1051
1051
  if (stmt.reader) {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Platform default instance root, including the Docker case.
3
+ *
4
+ * The bug this guards: inside the Docker image `quilltap db --lock-status`
5
+ * fell through to `~/.quilltap` (a home directory the image never creates),
6
+ * reported "No instance lock found" while the server held one, and
7
+ * `db --tables` reported a missing database.
8
+ *
9
+ * @jest-environment node
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const os = require('os');
15
+ const path = require('path');
16
+
17
+ const { getDefaultBaseDir, isDockerContainer, DOCKER_BASE_DIR } = require('../platform-paths');
18
+ const { resolveDataDir } = require('../db-helpers');
19
+
20
+ const savedEnv = { ...process.env };
21
+
22
+ afterEach(() => {
23
+ process.env = { ...savedEnv };
24
+ });
25
+
26
+ describe('platform default instance root', () => {
27
+ it('is /app/quilltap inside the Docker image', () => {
28
+ process.env.DOCKER_CONTAINER = 'true';
29
+ expect(isDockerContainer()).toBe(true);
30
+ expect(getDefaultBaseDir()).toBe(DOCKER_BASE_DIR);
31
+ });
32
+
33
+ it('is the per-OS location outside a container', () => {
34
+ delete process.env.DOCKER_CONTAINER;
35
+ if (isDockerContainer()) return; // the suite itself is running in a container
36
+ const expected = process.platform === 'darwin'
37
+ ? path.join(os.homedir(), 'Library', 'Application Support', 'Quilltap')
38
+ : process.platform === 'win32'
39
+ ? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Quilltap')
40
+ : path.join(os.homedir(), '.quilltap');
41
+ expect(getDefaultBaseDir()).toBe(expected);
42
+ });
43
+ });
44
+
45
+ describe('resolveDataDir', () => {
46
+ it('defaults to the Docker data directory inside the image', () => {
47
+ process.env.DOCKER_CONTAINER = 'true';
48
+ delete process.env.QUILLTAP_DATA_DIR;
49
+ expect(resolveDataDir()).toBe('/app/quilltap/data');
50
+ });
51
+
52
+ it('still lets QUILLTAP_DATA_DIR win inside the image', () => {
53
+ process.env.DOCKER_CONTAINER = 'true';
54
+ process.env.QUILLTAP_DATA_DIR = '/elsewhere';
55
+ expect(resolveDataDir()).toBe(path.join('/elsewhere', 'data'));
56
+ });
57
+
58
+ it('still lets an explicit override win', () => {
59
+ process.env.DOCKER_CONTAINER = 'true';
60
+ expect(resolveDataDir('/explicit')).toBe(path.join('/explicit', 'data'));
61
+ });
62
+ });
@@ -321,12 +321,12 @@ _quilltap_complete() {
321
321
  fi
322
322
  ;;
323
323
  memories)
324
- local mem_verbs="ls find grep show tree status validate maintain"
324
+ local mem_verbs="ls find grep show tree status validate keystone maintain"
325
325
  local mem_flags="--character --about --source --tier --chat --project --since --until \
326
326
  --min-importance --min-reinforced --has-embedding --no-embedding \
327
327
  --sort -r --reverse --limit --full-titles --in --no-related --list \
328
328
  --ignore-case -i --paths-only -l --max --context --depth --max-nodes \
329
- --semantic --top --threshold --dry-run --phases --cap --repair --write \
329
+ --semantic --top --threshold --dry-run --phases --cap --repair --write --keystone --off \
330
330
  --instance --data-dir --passphrase -p --port --json --help"
331
331
  if [[ -z "$subverb" ]]; then
332
332
  if [[ "$cur" == -* ]]; then
@@ -273,6 +273,7 @@ complete -c quilltap -n '__quilltap_using_subcommand memories' -f -a 'show' -d '
273
273
  complete -c quilltap -n '__quilltap_using_subcommand memories' -f -a 'tree' -d 'Graph walk'
274
274
  complete -c quilltap -n '__quilltap_using_subcommand memories' -f -a 'status' -d 'Memory status'
275
275
  complete -c quilltap -n '__quilltap_using_subcommand memories' -f -a 'validate' -d 'Health check'
276
+ complete -c quilltap -n '__quilltap_using_subcommand memories' -f -a 'keystone' -d 'Mark or unmark a keystone'
276
277
  complete -c quilltap -n '__quilltap_using_subcommand memories' -f -a 'maintain' -d 'Run memory maintenance'
277
278
 
278
279
  # memories flags
@@ -284,7 +285,9 @@ complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'dry-run' -d '
284
285
  complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'phases' -d 'Maintain: comma-separated phases' -x -a 'fold-near-duplicates consolidate demote retire mirror'
285
286
  complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'cap' -d 'Maintain: hot-row cap for demotion' -x
286
287
  complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'repair' -d 'Validate: repair the graph on the running server'
287
- complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'write' -d 'Validate --repair: apply the repair'
288
+ complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'write' -d 'Validate --repair / keystone: apply the change'
289
+ complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'keystone' -d 'Only keystones'
290
+ complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'off' -d 'Keystone: unmark instead of mark'
288
291
  complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'chat' -d 'Chat id or title' -x
289
292
  complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'project' -d 'Project name or id' -x
290
293
  complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'since' -d 'Since timestamp' -x
@@ -539,6 +539,7 @@ _quilltap_memories() {
539
539
  'tree:ASCII walk of graph'
540
540
  'status:Per-character rollup'
541
541
  'validate:Health check'
542
+ 'keystone:Mark or unmark a keystone (standing fact) via the running server'
542
543
  'maintain:Run memory maintenance (fold, consolidate, demote, retire, mirror)'
543
544
  )
544
545
 
@@ -556,7 +557,9 @@ _quilltap_memories() {
556
557
  '--phases[Maintain: comma-separated phases]:phases:_sequence compadd - fold-near-duplicates consolidate demote retire mirror'
557
558
  '--cap[Maintain: hot-row cap for demotion]:n:'
558
559
  '--repair[Validate: repair the graph on the running server (dry run without --write)]'
559
- '--write[Validate --repair: apply the repair]'
560
+ '--write[Validate --repair / keystone: apply the change]'
561
+ '--keystone[Only keystones]'
562
+ '--off[Keystone: unmark instead of mark]'
560
563
  '--chat[Chat id or title]:chat:'
561
564
  '--project[Project name or id]:project:'
562
565
  '--since[Since timestamp]:date:'
@@ -31,6 +31,7 @@ const DB_DOMAINS = {
31
31
  },
32
32
  logs: {
33
33
  'LLM logs': ['llm_logs'],
34
+ 'Log entries': ['log_entries'],
34
35
  },
35
36
  mount: {
36
37
  'Document mount index': [
package/lib/db-helpers.js CHANGED
@@ -15,14 +15,11 @@ function resolveDataDir(overrideDir) {
15
15
  : overrideDir;
16
16
  return path.join(resolved, 'data');
17
17
  }
18
- const os = require('os');
19
18
  if (process.env.QUILLTAP_DATA_DIR) {
20
19
  return path.join(process.env.QUILLTAP_DATA_DIR, 'data');
21
20
  }
22
- const home = os.homedir();
23
- if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Quilltap', 'data');
24
- if (process.platform === 'win32') return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Quilltap', 'data');
25
- return path.join(home, '.quilltap', 'data');
21
+ const { getDefaultBaseDir } = require('./platform-paths');
22
+ return path.join(getDefaultBaseDir(), 'data');
26
23
  }
27
24
 
28
25
  // Resolve the data dir + database passphrase a subcommand should use, given
@@ -206,10 +206,7 @@ let exitHandlersRegistered = false;
206
206
  function detectEnvironmentType() {
207
207
  if (process.versions && process.versions.electron) return 'electron';
208
208
  if (process.env.ELECTRON_DEV) return 'electron';
209
- if (process.env.DOCKER_CONTAINER === 'true') return 'docker';
210
- try {
211
- if (fs.existsSync('/.dockerenv')) return 'docker';
212
- } catch { /* not Docker */ }
209
+ if (require('./platform-paths').isDockerContainer()) return 'docker';
213
210
  return 'local';
214
211
  }
215
212
 
@@ -18,7 +18,7 @@ const {
18
18
  memoryColumns,
19
19
  } = require('./graph-integrity');
20
20
  const { cmdMaintain } = require('./memories-maintain-command');
21
- const { postMemoriesAction, resolveMemoriesServerPort } = require('./memories-server');
21
+ const { postMemoriesAction, putMemory, resolveMemoriesServerPort } = require('./memories-server');
22
22
 
23
23
  // ---------- colour & marker helpers ----------
24
24
 
@@ -152,6 +152,8 @@ function parseFlags(args) {
152
152
  case '--list': flags.list = true; break;
153
153
  case '--repair': flags.repair = true; break;
154
154
  case '--write': flags.write = true; break;
155
+ case '--keystone': flags.keystone = true; break;
156
+ case '--off': flags.off = true; break;
155
157
 
156
158
  case '--phases': flags.phases = args[++i] || ''; break;
157
159
  case '--cap': {
@@ -257,6 +259,11 @@ function buildWhereClause(db, flags, openMounts = null) {
257
259
  params.push(s);
258
260
  }
259
261
 
262
+ if (flags.keystone) {
263
+ // Instances that haven't run add-memory-keystone-v1 have no keystones.
264
+ clauses.push(hasMemoryColumn(db, 'keystone') ? 'm.keystone = 1' : '0');
265
+ }
266
+
260
267
  if (flags.tier) {
261
268
  const t = String(flags.tier).toLowerCase();
262
269
  if (t !== 'hot' && t !== 'cold') {
@@ -386,17 +393,23 @@ const SELECT_BASE_TEMPLATE = `
386
393
  LEFT JOIN projects ON projects.id = m.projectId
387
394
  `;
388
395
 
389
- // Instances that haven't yet run add-memory-tiers-v1 lack the tier columns;
390
- // substitute constants so the CLI still reads them (rows read as hot).
391
- function selectBase(db) {
392
- let hasTier = false;
396
+ function hasMemoryColumn(db, name) {
393
397
  try {
394
- hasTier = db.prepare("PRAGMA table_info('memories')").all().some((c) => c.name === 'tier');
395
- } catch { /* fall through: treat as legacy */ }
396
- const cols = hasTier
398
+ return db.prepare("PRAGMA table_info('memories')").all().some((c) => c.name === name);
399
+ } catch {
400
+ return false; // treat as legacy
401
+ }
402
+ }
403
+
404
+ // Instances that haven't yet run add-memory-tiers-v1 lack the tier columns (and
405
+ // those before add-memory-keystone-v1 the keystone column); substitute
406
+ // constants so the CLI still reads them (rows read as hot, not keystones).
407
+ function selectBase(db) {
408
+ const tierCols = hasMemoryColumn(db, 'tier')
397
409
  ? 'm.tier, m.supersededById, m.consolidatedFrom, m.consolidatedAt,'
398
410
  : "'hot' AS tier, NULL AS supersededById, '[]' AS consolidatedFrom, NULL AS consolidatedAt,";
399
- return SELECT_BASE_TEMPLATE.replace('__TIER_COLUMNS__', cols);
411
+ const keystoneCol = hasMemoryColumn(db, 'keystone') ? 'm.keystone,' : '0 AS keystone,';
412
+ return SELECT_BASE_TEMPLATE.replace('__TIER_COLUMNS__', `${tierCols} ${keystoneCol}`);
400
413
  }
401
414
 
402
415
 
@@ -499,6 +512,7 @@ function rowToJson(row) {
499
512
  supersededById: row.supersededById || null,
500
513
  consolidatedFrom: (() => { try { return JSON.parse(row.consolidatedFrom || '[]'); } catch { return []; } })(),
501
514
  consolidatedAt: row.consolidatedAt || null,
515
+ keystone: Number(row.keystone) === 1,
502
516
  importance: row.importance,
503
517
  reinforcedImportance: row.reinforcedImportance,
504
518
  reinforcementCount: row.reinforcementCount,
@@ -546,7 +560,8 @@ function renderTable(rows, { showHolder, impField, fullTitles }) {
546
560
  const chat = truncate(chatLabel(row, fullTitles), fullTitles ? 24 : 32).padEnd(fullTitles ? 24 : 32);
547
561
  const links = String(linkCount(row)).padStart(5);
548
562
  const emb = row.hasEmbedding ? 'Y' : '-';
549
- const summary = truncate(row.summary || '', sumW);
563
+ // A keystone's line is marked ◆ (a standing fact; never folded or demoted).
564
+ const summary = (Number(row.keystone) === 1 ? '◆ ' : '') + truncate(row.summary || '', sumW);
550
565
 
551
566
  const cells = [
552
567
  showHolder ? truncate(row.holderName || '(?)', 14).padEnd(14) : null,
@@ -937,6 +952,7 @@ function renderShowText(row, graph, depth) {
937
952
  console.log(` About: ${aboutName}${row.aboutCharacterId && row.aboutCharacterId !== row.characterId ? ` (${shortId(row.aboutCharacterId)})` : ''}`);
938
953
  console.log(` Source: ${row.source || '(?)'}`);
939
954
  console.log(` Tier: ${row.tier || 'hot'}${row.supersededById ? ` (superseded by ${shortId(row.supersededById)})` : ''}`);
955
+ console.log(` Keystone: ${Number(row.keystone) === 1 ? 'yes (standing fact)' : 'no'}`);
940
956
  const reinf = row.reinforcedImportance != null ? Number(row.reinforcedImportance).toFixed(2) : '?';
941
957
  const baseImp = row.importance != null ? Number(row.importance).toFixed(2) : '?';
942
958
  console.log(` Importance: ${reinf} (reinforced from ${baseImp}, count: ${row.reinforcementCount ?? 0})`);
@@ -1507,6 +1523,51 @@ function renderRepairText(report) {
1507
1523
  }
1508
1524
  }
1509
1525
 
1526
+ // ---------- keystone ----------
1527
+
1528
+ /**
1529
+ * Mark or unmark a keystone. The id resolves locally (read-only); the change
1530
+ * itself goes to the running server's memory PUT, through the edit chokepoint
1531
+ * that enforces the per-subject limit and invalidates the frozen archive.
1532
+ */
1533
+ async function cmdKeystone(flags, positional) {
1534
+ const idArg = positional[0];
1535
+ if (!idArg) {
1536
+ throw new Error('memories keystone: missing <id>. Usage: quilltap memories keystone <id|prefix> [--off] [--write]');
1537
+ }
1538
+ const want = !flags.off;
1539
+ const { db } = await openDb(flags);
1540
+ let row;
1541
+ try {
1542
+ const id = resolveMemoryId(db, idArg);
1543
+ row = db.prepare(`${selectBase(db)} WHERE m.id = ?`).get(id);
1544
+ if (!row) throw new Error(`Memory ${id} not found`);
1545
+ } finally {
1546
+ db.close();
1547
+ }
1548
+ const is = Number(row.keystone) === 1;
1549
+ const label = `${shortId(row.id)} (${truncate(row.summary || '', 60)})`;
1550
+ if (is === want) {
1551
+ const msg = `${label} is ${want ? 'already' : 'not'} a keystone; nothing to do.`;
1552
+ if (flags.json) renderJson({ id: row.id, keystone: is, changed: false }); else console.log(msg);
1553
+ return;
1554
+ }
1555
+ if (!flags.write) {
1556
+ const msg = `Would ${want ? 'mark' : 'unmark'} ${label} as a keystone. Re-run with --write to apply.`;
1557
+ if (flags.json) renderJson({ id: row.id, keystone: is, wouldBe: want, changed: false, dryRun: true }); else console.log(msg);
1558
+ return;
1559
+ }
1560
+ const payload = await putMemory(flags, row.id, { keystone: want }, {
1561
+ why: 'Changing a keystone needs the running server (the only database writer).',
1562
+ });
1563
+ const now = Boolean(payload && payload.memory && payload.memory.keystone);
1564
+ if (flags.json) {
1565
+ renderJson({ id: row.id, keystone: now, changed: now === want });
1566
+ } else {
1567
+ console.log(`${want ? 'Marked' : 'Unmarked'} ${label} as a keystone.`);
1568
+ }
1569
+ }
1570
+
1510
1571
  // ---------- help ----------
1511
1572
 
1512
1573
  function printMemoriesHelp() {
@@ -1559,6 +1620,16 @@ Subcommands:
1559
1620
  server (the only database
1560
1621
  writer, holder of the instance
1561
1622
  lock), so the server must be up.
1623
+ keystone <id|prefix> [--off] [--write] [--port N]
1624
+ Mark a memory a keystone (a
1625
+ standing fact, always recalled in
1626
+ full, never folded, consolidated
1627
+ or demoted), or unmark it with
1628
+ --off. Without --write it only
1629
+ says what would change; with
1630
+ --write the running server makes
1631
+ the change (at most 5 keystones
1632
+ per holder and subject).
1562
1633
  maintain --character <name|id|all> [--phases a,b,…] [--dry-run]
1563
1634
  [--max N] [--threshold 0..1] [--cap N] [--port N] [--json]
1564
1635
  Run memory maintenance by hand on
@@ -1586,6 +1657,7 @@ Shared filter flags:
1586
1657
  --about <name|id|self|none> Subject (aboutCharacterId).
1587
1658
  --source AUTO|MANUAL|CONSOLIDATED Restrict by source.
1588
1659
  --tier hot|cold Restrict by recall tier (default: both).
1660
+ --keystone Only keystones (marked ◆ in tables).
1589
1661
  --chat <id|title|none> Source chat ('none' for manual memories).
1590
1662
  --project <id|name> Project context.
1591
1663
  --since <date> ISO date floor on createdAt.
@@ -1617,9 +1689,9 @@ Note: -i is reserved here for 'grep --ignore-case'. Use the long --instance
1617
1689
  form to target a registered instance.
1618
1690
 
1619
1691
  All memories verbs are read-only. They open the main encrypted database
1620
- (quilltap.db) and never write to it. 'maintain' and 'validate --repair' leave
1621
- the writing to the running server; 'validate --repair' changes data only with
1622
- --write.
1692
+ (quilltap.db) and never write to it. 'maintain', 'keystone' and 'validate
1693
+ --repair' leave the writing to the running server; 'keystone' and 'validate
1694
+ --repair' change data only with --write.
1623
1695
 
1624
1696
  Examples:
1625
1697
  quilltap memories ls
@@ -1634,6 +1706,9 @@ Examples:
1634
1706
  quilltap memories validate --list
1635
1707
  quilltap memories validate --repair # rehearse the repair
1636
1708
  quilltap memories validate --repair --write # apply it
1709
+ quilltap memories ls --character Friday --keystone
1710
+ quilltap memories keystone abc12345 --write # mark a standing fact
1711
+ quilltap memories keystone abc12345 --off --write # unmark it
1637
1712
  quilltap memories maintain --instance Friday --character Friday --dry-run
1638
1713
  quilltap memories maintain --instance Friday --character Friday --phases consolidate --max 100
1639
1714
  quilltap memories maintain --character all --phases demote,retire --dry-run
@@ -1671,8 +1746,11 @@ async function memoriesCommand(args) {
1671
1746
  if (flags.repair && verb !== 'validate') {
1672
1747
  throw new Error('--repair applies only to memories validate.');
1673
1748
  }
1674
- if (flags.write && !(verb === 'validate' && flags.repair)) {
1675
- throw new Error('--write applies only to memories validate --repair.');
1749
+ if (flags.write && !(verb === 'validate' && flags.repair) && verb !== 'keystone') {
1750
+ throw new Error('--write applies only to memories validate --repair and memories keystone.');
1751
+ }
1752
+ if (flags.off && verb !== 'keystone') {
1753
+ throw new Error('--off applies only to memories keystone.');
1676
1754
  }
1677
1755
  switch (verb) {
1678
1756
  case 'ls': await cmdLs(flags); break;
@@ -1682,6 +1760,7 @@ async function memoriesCommand(args) {
1682
1760
  case 'tree': await cmdTree(flags, positional); break;
1683
1761
  case 'status': await cmdStatus(flags); break;
1684
1762
  case 'validate': await cmdValidate(flags); break;
1763
+ case 'keystone': await cmdKeystone(flags, positional); break;
1685
1764
  case 'maintain': await cmdMaintain(flags, resolveCharacterIdForServer); break;
1686
1765
  default:
1687
1766
  console.error(`Unknown memories subcommand: ${verb}`);
@@ -3,9 +3,10 @@
3
3
  /**
4
4
  * The one way a `quilltap memories` verb asks the running server to do what
5
5
  * the CLI cannot: anything that calls a model, computes an embedding or
6
- * writes. The CLI's own database access is read-only; `maintain` and
7
- * `validate --repair` resolve what they can locally and POST the rest to
8
- * `/api/v1/memories?action=<action>`.
6
+ * writes. The CLI's own database access is read-only; `maintain`,
7
+ * `validate --repair` and `keystone` resolve what they can locally and send
8
+ * the rest to `/api/v1/memories?action=<action>` (or, for a hand edit, the
9
+ * memory's own PUT).
9
10
  *
10
11
  * The port comes from `resolveServerPort` (lock-helpers): an explicit
11
12
  * `--port`, else the port recorded in the `--instance` / `--data-dir`
@@ -29,28 +30,20 @@ function resolveMemoriesServerPort(flags, { quiet = false } = {}) {
29
30
  }
30
31
 
31
32
  /**
32
- * POST `body` to `/api/v1/memories?action=<action>` and return the parsed
33
- * payload. Throws an Error with a readable message on a connection failure
34
- * or a non-2xx response.
35
- *
36
- * @param {object} flags parsed memories flags (port / instance / dataDir)
37
- * @param {string} action the `?action=` name
38
- * @param {object} body JSON request body
39
- * @param {object} [opts]
40
- * @param {string} [opts.why] one line on why the server is needed
41
- * @param {(msg: string) => void} [opts.announce] called with the URL before the request
42
- * @param {typeof fetch} [opts.fetchImpl] for tests
33
+ * Send `body` to a memories API path on the running server and return the
34
+ * parsed payload. Throws an Error with a readable message on a connection
35
+ * failure or a non-2xx response.
43
36
  */
44
- async function postMemoriesAction(flags, action, body, opts = {}) {
45
- const { why, announce, fetchImpl = fetch } = opts;
37
+ async function requestMemoriesApi(flags, method, path, body, opts = {}) {
38
+ const { why, announce, fetchImpl = fetch, action } = opts;
46
39
  const target = resolveMemoriesServerPort(flags);
47
- const url = `http://localhost:${target.port}/api/v1/memories?action=${encodeURIComponent(action)}`;
40
+ const url = `http://localhost:${target.port}${path}`;
48
41
  if (announce) announce(url);
49
42
 
50
43
  let res;
51
44
  try {
52
45
  res = await fetchImpl(url, {
53
- method: 'POST',
46
+ method,
54
47
  headers: { 'Content-Type': 'application/json' },
55
48
  body: JSON.stringify(body),
56
49
  });
@@ -75,7 +68,7 @@ async function postMemoriesAction(flags, action, body, opts = {}) {
75
68
  .map((d) => `${Array.isArray(d.path) && d.path.length ? d.path.join('.') + ' ' : ''}${d.message || ''}`.trim())
76
69
  .join('; ');
77
70
  }
78
- if (res.status === 400 && /unknown action|invalid action/i.test(msg)) {
71
+ if (action && res.status === 400 && /unknown action|invalid action/i.test(msg)) {
79
72
  msg += ` (is the server older than this CLI? '${action}' needs a matching Quilltap server)`;
80
73
  }
81
74
  throw new Error(msg);
@@ -83,4 +76,30 @@ async function postMemoriesAction(flags, action, body, opts = {}) {
83
76
  return payload;
84
77
  }
85
78
 
86
- module.exports = { postMemoriesAction, resolveMemoriesServerPort };
79
+ /**
80
+ * POST `body` to `/api/v1/memories?action=<action>` and return the parsed
81
+ * payload. Throws an Error with a readable message on a connection failure
82
+ * or a non-2xx response.
83
+ *
84
+ * @param {object} flags parsed memories flags (port / instance / dataDir)
85
+ * @param {string} action the `?action=` name
86
+ * @param {object} body JSON request body
87
+ * @param {object} [opts]
88
+ * @param {string} [opts.why] one line on why the server is needed
89
+ * @param {(msg: string) => void} [opts.announce] called with the URL before the request
90
+ * @param {typeof fetch} [opts.fetchImpl] for tests
91
+ */
92
+ async function postMemoriesAction(flags, action, body, opts = {}) {
93
+ return requestMemoriesApi(flags, 'POST', `/api/v1/memories?action=${encodeURIComponent(action)}`, body, { ...opts, action });
94
+ }
95
+
96
+ /**
97
+ * PUT a hand edit to `/api/v1/memories/<id>` (the edit chokepoint) and return
98
+ * the parsed payload (`{ memory }`). Same options and errors as
99
+ * {@link postMemoriesAction}.
100
+ */
101
+ async function putMemory(flags, id, body, opts = {}) {
102
+ return requestMemoriesApi(flags, 'PUT', `/api/v1/memories/${encodeURIComponent(id)}`, body, opts);
103
+ }
104
+
105
+ module.exports = { postMemoriesAction, putMemory, resolveMemoriesServerPort };
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Where an instance lives when nothing says otherwise.
5
+ *
6
+ * The CLI's mirror of the server's platform default (lib/paths.ts). Inside the
7
+ * Docker image the instance is always the bind mount at /app/quilltap, and the
8
+ * `nextjs` user has no home directory, so falling through to `~/.quilltap`
9
+ * sent `docker exec quilltap quilltap db …` to a database that does not exist
10
+ * and reported "no instance lock" while the server held one.
11
+ *
12
+ * Docker detection matches the server's lock-environment check
13
+ * (DOCKER_CONTAINER or /.dockerenv), deliberately without the server's
14
+ * "/app exists" heuristic, which misfires on hosts that happen to have /app.
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const os = require('os');
19
+ const path = require('path');
20
+
21
+ const DOCKER_BASE_DIR = '/app/quilltap';
22
+
23
+ function isDockerContainer() {
24
+ if (process.env.DOCKER_CONTAINER === 'true') return true;
25
+ try {
26
+ return fs.existsSync('/.dockerenv');
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ /** The instance root used when no flag, env var or registered instance names one. */
33
+ function getDefaultBaseDir() {
34
+ if (isDockerContainer()) return DOCKER_BASE_DIR;
35
+ const home = os.homedir();
36
+ if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Quilltap');
37
+ if (process.platform === 'win32') return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Quilltap');
38
+ return path.join(home, '.quilltap');
39
+ }
40
+
41
+ module.exports = {
42
+ DOCKER_BASE_DIR,
43
+ isDockerContainer,
44
+ getDefaultBaseDir,
45
+ };
@@ -61,7 +61,9 @@ Options:
61
61
  multipliers
62
62
  {RECALL_MULTIPLIERS key:
63
63
  value}, specificAnchors, anchorMinHits,
64
- anchorOrder, backgroundReserve.
64
+ anchorOrder, backgroundReserve,
65
+ innerCircleShare, innerCircleAllowance
66
+ ([3,2,1] = digests per member by tier).
65
67
  Unknown keys are rejected.
66
68
  --tuning-file <path> Read the --tuning JSON from a file
67
69
  --signals-from <path> Reuse the signals saved in a previous --json
@@ -21,6 +21,7 @@ const os = require('os');
21
21
  const crypto = require('crypto');
22
22
  const { execSync } = require('child_process');
23
23
  const { validateThemeBundle, validateManifest } = require('./theme-validation');
24
+ const { getDefaultBaseDir } = require('./platform-paths');
24
25
 
25
26
  // ============================================================================
26
27
  // COLOR HELPERS
@@ -54,10 +55,7 @@ function resolveBaseDir(overrideDir) {
54
55
  if (process.env.QUILLTAP_DATA_DIR) {
55
56
  return process.env.QUILLTAP_DATA_DIR;
56
57
  }
57
- const home = os.homedir();
58
- if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Quilltap');
59
- if (process.platform === 'win32') return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Quilltap');
60
- return path.join(home, '.quilltap');
58
+ return getDefaultBaseDir();
61
59
  }
62
60
 
63
61
  function getThemesDir(overrideDir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quilltap",
3
- "version": "4.11.0-dev.11",
3
+ "version": "4.11.0-dev.110",
4
4
  "description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
5
5
  "author": {
6
6
  "name": "Charles Sebold",
@@ -36,7 +36,7 @@
36
36
  "README.md"
37
37
  ],
38
38
  "dependencies": {
39
- "@napi-rs/canvas": "^0.1.100",
39
+ "@napi-rs/canvas": "^1.0.13",
40
40
  "better-sqlite3-multiple-ciphers": "^12.11.1",
41
41
  "node-pty": "^1.1.0",
42
42
  "sharp": "^0.35.4",