hafez 1.4.0 → 2.0.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.
Files changed (63) hide show
  1. package/README.md +26 -33
  2. package/dist/apply.d.ts +5 -2
  3. package/dist/apply.js +225 -114
  4. package/dist/batch-ops.d.ts +89 -26
  5. package/dist/batch-ops.js +176 -18
  6. package/dist/cli/args.d.ts +43 -5
  7. package/dist/cli/args.js +69 -8
  8. package/dist/cli/batch-errors.js +19 -11
  9. package/dist/cli/block-hook.d.ts +9 -0
  10. package/dist/cli/block-hook.js +90 -0
  11. package/dist/cli/commands-schema.js +2 -0
  12. package/dist/cli/commands.d.ts +4 -3
  13. package/dist/cli/commands.js +300 -99
  14. package/dist/cli/format.d.ts +7 -3
  15. package/dist/cli/format.js +27 -10
  16. package/dist/cli/index.d.ts +18 -4
  17. package/dist/cli/index.js +72 -30
  18. package/dist/cli/install.d.ts +53 -0
  19. package/dist/cli/install.js +297 -0
  20. package/dist/cli/onboard.d.ts +15 -2
  21. package/dist/cli/onboard.js +265 -90
  22. package/dist/cli/resolve-vault.d.ts +11 -4
  23. package/dist/cli/resolve-vault.js +13 -11
  24. package/dist/cli/schema-introspect.js +4 -3
  25. package/dist/cli/upgrade.d.ts +13 -4
  26. package/dist/cli/upgrade.js +90 -44
  27. package/dist/closest.d.ts +8 -0
  28. package/dist/closest.js +65 -0
  29. package/dist/contracts.d.ts +17 -1
  30. package/dist/contracts.js +44 -0
  31. package/dist/db.d.ts +15 -2
  32. package/dist/db.js +69 -48
  33. package/dist/digest.d.ts +6 -4
  34. package/dist/digest.js +28 -17
  35. package/dist/document.d.ts +41 -3
  36. package/dist/document.js +218 -62
  37. package/dist/export-okf.d.ts +13 -0
  38. package/dist/export-okf.js +11 -7
  39. package/dist/harness.d.ts +58 -0
  40. package/dist/harness.js +50 -0
  41. package/dist/index.d.ts +1 -1
  42. package/dist/index.js +94 -104
  43. package/dist/merge.js +7 -5
  44. package/dist/near-match.d.ts +9 -0
  45. package/dist/near-match.js +34 -0
  46. package/dist/stale.d.ts +41 -0
  47. package/dist/stale.js +43 -0
  48. package/dist/stopwords.d.ts +6 -0
  49. package/dist/stopwords.js +25 -0
  50. package/dist/surfaces/items.d.ts +366 -0
  51. package/dist/surfaces/items.js +523 -0
  52. package/dist/surfaces/surfaces.d.ts +32 -0
  53. package/dist/surfaces/surfaces.js +230 -0
  54. package/dist/types.d.ts +45 -16
  55. package/dist/validate.d.ts +7 -0
  56. package/dist/validate.js +109 -0
  57. package/dist/vault.js +1 -1
  58. package/package.json +4 -3
  59. package/skill/hafez/SKILL.md +68 -0
  60. package/skill/hafez-save/SKILL.md +29 -0
  61. package/dist/cli/help-agent.d.ts +0 -1
  62. package/dist/cli/help-agent.js +0 -210
  63. package/skill/SKILL.md +0 -137
@@ -0,0 +1,50 @@
1
+ // src/harness.ts
2
+ // Which Harness is running this command, read from the environment. The one
3
+ // helper the Apply update arm, the CLI's --log and `digest` call to name the
4
+ // agent in a session log, and `onboard` calls to pick the Harness it sets up.
5
+ // Also where each Harness keeps its instructions file.
6
+ /** Recorded when no Harness is detectable. */
7
+ export const NEUTRAL_AGENT = 'agent';
8
+ /** Variables Codex sets on the commands it runs. CODEX_HOME and the Claude
9
+ * Code plugin's CODEX_COMPANION_* are deliberately absent: both can be set in
10
+ * a shell Codex is not running. */
11
+ const CODEX_VARS = ['CODEX_THREAD_ID', 'CODEX_SANDBOX', 'CODEX_SANDBOX_NETWORK_DISABLED'];
12
+ export function detectAgent(env = process.env) {
13
+ const set = (key) => Boolean(env[key]);
14
+ // Codex first: Codex started from inside Claude Code inherits CLAUDECODE,
15
+ // and the innermost Harness is the one running the command.
16
+ if (CODEX_VARS.some(set))
17
+ return 'codex';
18
+ if (set('CLAUDECODE'))
19
+ return 'claude-code';
20
+ return NEUTRAL_AGENT;
21
+ }
22
+ /** Every Harness `onboard` can configure, and where its always-loaded
23
+ * user-level instructions file lives, relative to the home directory (harness
24
+ * research, docs/research/2026-09-29-harness-instruction-surfaces.md): the
25
+ * first candidate that exists, else the last. Cursor keeps its User Rules in a
26
+ * Settings UI, not a file: `null`, and the Block is printed for manual paste.
27
+ * `dir` is the config directory whose presence says the Harness is installed. */
28
+ export const HARNESSES = {
29
+ 'claude-code': { label: 'Claude Code', dir: '.claude', instructions: ['.claude/CLAUDE.md'] },
30
+ codex: { label: 'Codex', dir: '.codex', instructions: ['.codex/AGENTS.override.md', '.codex/AGENTS.md'] },
31
+ cursor: { label: 'Cursor', dir: '.cursor', instructions: null },
32
+ gemini: { label: 'Gemini CLI', dir: '.gemini', instructions: ['.gemini/GEMINI.md'] },
33
+ copilot: { label: 'Copilot CLI', dir: '.copilot', instructions: ['.copilot/copilot-instructions.md'] },
34
+ };
35
+ /** The Harnesses bare `onboard` sets up from inside — the ones detected from
36
+ * the environment — and, relative to the home directory, where each keeps
37
+ * what the agent applies: the SessionStart hook and the permission entries.
38
+ * `keystroke` invokes the `hafez-save` skill there. */
39
+ export const ONBOARD_HARNESSES = {
40
+ 'claude-code': { keystroke: '/hafez-save', hooks: '.claude/settings.json', allowEntry: 'Bash(hafez:*)' },
41
+ codex: {
42
+ keystroke: '$hafez-save',
43
+ hooks: '.codex/hooks.json',
44
+ rules: '.codex/rules/default.rules',
45
+ rule: 'prefix_rule(pattern = ["hafez"], decision = "allow")',
46
+ config: '.codex/config.toml',
47
+ },
48
+ };
49
+ export const isOnboardHarness = (value) => Object.hasOwn(ONBOARD_HARNESSES, value);
50
+ export const isHarness = (value) => Object.hasOwn(HARNESSES, value);
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Hafez, HafezConfig } from './types.js';
2
2
  export declare function createHafez(config: HafezConfig): Hafez;
3
3
  export { HafezError } from './types.js';
4
- export type { Hafez, HafezConfig, HafezErrorCode, EntityType, EntityStatus, EntityQueryOpts, KnowledgeQueryOpts, UnifiedQueryOpts, EntityFrontmatter, KnowledgeFrontmatter, ParsedFile, SessionLogEntry, UpdateFields, UpdateResult, CreateEntityFields, CreateKnowledgeFields, QueryFilter, QueryResult, KnowledgeQueryResult, UnifiedResult, ValidationReport, BatchOperation, BatchResult, ReadDepth, ConfidenceLevel, LinkRelation, GitConfig, Journal, SearchResult, VaultStats, ChangelogEntry, KnowledgeSubtype } from './types.js';
4
+ export type { Hafez, HafezConfig, HafezErrorCode, EntityType, EntityStatus, EntityQueryOpts, KnowledgeQueryOpts, UnifiedQueryOpts, AllQueryOpts, SortField, EntitySortField, EntityFrontmatter, KnowledgeFrontmatter, ParsedFile, SessionLogEntry, UpdateFields, UpdateResult, CreateEntityFields, CreateKnowledgeFields, QueryFilter, QueryResult, KnowledgeQueryResult, UnifiedResult, ValidationReport, BatchOperation, BatchResult, ReadDepth, ConfidenceLevel, LinkRelation, GitConfig, Journal, SearchResult, VaultStats, ChangelogEntry, KnowledgeSubtype } from './types.js';
5
5
  export { getBrief, getNextActions } from './document.js';
6
6
  export { parseSessionLogHeading, parseSessionLog } from './document.js';
package/dist/index.js CHANGED
@@ -1,12 +1,14 @@
1
1
  // src/index.ts
2
- import { parseFilePath, slugify, kindFromPath, kindDir, scanVaultDir, isIndexedRelPath, findVaultFile, vaultFileExists } from './vault.js';
2
+ import { parseFilePath, slugify, kindFromPath, kindDir, isIndexedRelPath, findVaultFile, vaultFileExists, resolveFilePath, scanVaultDir, INDEXED_KINDS } from './vault.js';
3
3
  import { createGitJournal } from './git.js';
4
- import { validateEntityFrontmatter, validateKnowledgeFrontmatter, validateSessionLogEntry } from './schema.js';
4
+ import { validateSessionLogEntry } from './schema.js';
5
+ import { validateVault } from './validate.js';
5
6
  import { documentForFile } from './document.js';
6
7
  import { createIndex } from './db.js';
8
+ import { closestSlugs } from './closest.js';
7
9
  import { generateVaultIndex } from './knowledge-index.js';
8
10
  import { HafezError } from './types.js';
9
- import { BatchOperationSchema, specFor, slugBearingOpNames, notFoundError, normalizeOperations, opErrorLine, batchInvalidHeader } from './batch-ops.js';
11
+ import { BatchOperationSchema, specFor, slugBearingOpNames, sessionAddressingOpNames, notFoundError, normalizeOperations, opErrorLine, batchInvalidHeader } from './batch-ops.js';
10
12
  import { applyOperation, buildEntityFrontmatter, buildKnowledgeFrontmatter, validateKindFields } from './apply.js';
11
13
  import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'fs';
12
14
  import { join, basename } from 'path';
@@ -91,19 +93,22 @@ export function createHafez(config) {
91
93
  }
92
94
  async function read(slug, depth = 'summary') {
93
95
  const filePath = findFile(slug);
94
- if (!filePath)
95
- throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`);
96
+ if (!filePath) {
97
+ // A miss carries the closest slugs as its details: the recovery the
98
+ // `read` miss output renders (#91).
99
+ const closest = withIndex(idx => { idx.syncIfStale(); return closestSlugs(slug, idx.indexEntries()); }, []);
100
+ throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`, closest);
101
+ }
96
102
  const parsed = parseFilePath(filePath);
97
103
  // findFile only resolves the indexed kinds, so the session arm is unreachable
98
- const kind = parsed.kind === 'session' ? 'knowledge' : parsed.kind;
99
- const frontmatter = parsed.frontmatter;
100
- const body = parsed.body;
104
+ if (parsed.kind === 'session')
105
+ throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`);
101
106
  if (depth === 'frontmatter')
102
- return { kind, frontmatter, body: '' };
107
+ return { ...parsed, body: '' };
103
108
  if (depth === 'summary') {
104
- return { kind, frontmatter, body: documentForFile(parsed).bodyBeforeSessionLog(body) };
109
+ return { ...parsed, body: documentForFile(parsed).bodyBeforeSessionLog(parsed.body) };
105
110
  }
106
- return { kind, frontmatter, body };
111
+ return parsed;
107
112
  }
108
113
  function rollbackFiles(originals) {
109
114
  for (const [path, content] of originals) {
@@ -259,7 +264,7 @@ export function createHafez(config) {
259
264
  }
260
265
  async function update(slug, fields) {
261
266
  const { applied } = await runMutations([{ op: 'update', slug, fields }], { action: 'update' });
262
- return { matched_action: applied[0].matchedAction };
267
+ return applied[0].updateResult ?? {};
263
268
  }
264
269
  async function link(slug, target, relation) {
265
270
  await runMutations([{ op: 'link', slug, target, relation }], { action: 'link' });
@@ -267,6 +272,10 @@ export function createHafez(config) {
267
272
  async function unlink(slug, target, relation) {
268
273
  await runMutations([{ op: 'unlink', slug, target, relation }], { action: 'unlink' });
269
274
  }
275
+ async function del(slug) {
276
+ const { applied } = await runMutations([{ op: 'delete', slug }], { action: 'delete' });
277
+ return applied[0].deletedKind;
278
+ }
270
279
  // "Recent" with day-granularity dates: primary sort is the frontmatter day,
271
280
  // ties break on git commit time (the real recency signal), then name for
272
281
  // files with no commit history yet. Without this the top-5 lists leak
@@ -303,10 +312,36 @@ export function createHafez(config) {
303
312
  // slug → what it would be after earlier ops in this batch
304
313
  const simulated = new Map();
305
314
  const simulatedSessions = new Set();
306
- const existsAnywhere = (slug) => simulated.has(slug) || exists(slug);
315
+ // Deletes earlier in the batch: the slug is absent to every later op, so
316
+ // delete-then-create of one slug is a rewrite, not a collision.
317
+ const deleted = new Set();
318
+ const deletedSessions = new Set();
319
+ // slug → its parent/related after the earlier ops; files absent here
320
+ // carry what is on disk.
321
+ const simulatedLinks = new Map();
322
+ const existsAnywhere = (slug) => !deleted.has(slug) && (simulated.has(slug) || exists(slug));
323
+ const linksOf = (slug) => {
324
+ const sim = simulatedLinks.get(slug);
325
+ if (sim)
326
+ return sim;
327
+ const fp = findFile(slug);
328
+ if (!fp)
329
+ return {};
330
+ try {
331
+ const pf = parseFilePath(fp);
332
+ if (pf.kind === 'entity')
333
+ return { parent: pf.frontmatter.parent, related: pf.frontmatter.related };
334
+ return { related: pf.frontmatter.related };
335
+ }
336
+ catch {
337
+ return {};
338
+ }
339
+ };
307
340
  // Vault context for the Op Spec table's semantic checks (src/batch-ops.ts).
308
341
  const ctx = {
309
342
  resolve: (slug) => {
343
+ if (deleted.has(slug))
344
+ return null;
310
345
  const sim = simulated.get(slug);
311
346
  if (sim)
312
347
  return sim;
@@ -335,9 +370,48 @@ export function createHafez(config) {
335
370
  }
336
371
  },
337
372
  existsAnywhere,
338
- sessionExists: (slug) => existsSync(join(vaultPath, kindDir('session'), `${slug}.md`)) || simulatedSessions.has(slug),
339
- simulate: (slug, info) => { simulated.set(slug, info); },
340
- simulateSession: (slug) => { simulatedSessions.add(slug); },
373
+ // resolveFilePath, not a bare join: delete acts on this answer, and a
374
+ // '../' slug must never reach a file outside sessions/.
375
+ sessionExists: (slug) => {
376
+ if (deletedSessions.has(slug))
377
+ return false;
378
+ if (simulatedSessions.has(slug))
379
+ return true;
380
+ try {
381
+ return existsSync(resolveFilePath(vaultPath, slug, 'session'));
382
+ }
383
+ catch {
384
+ return false;
385
+ }
386
+ },
387
+ simulate: (slug, info) => { deleted.delete(slug); simulated.set(slug, info); },
388
+ simulateSession: (slug) => { deletedSessions.delete(slug); simulatedSessions.add(slug); },
389
+ linksOf,
390
+ simulateLinks: (slug, links) => { simulatedLinks.set(slug, links); },
391
+ // Read from disk, not the index: the validate phase must see the vault
392
+ // as `validate` will, and a stale index row would let a linked note go.
393
+ referrers: (slug) => {
394
+ const candidates = new Set(simulated.keys());
395
+ for (const kind of INDEXED_KINDS) {
396
+ for (const fp of scanVaultDir(join(vaultPath, kindDir(kind))))
397
+ candidates.add(basename(fp, '.md'));
398
+ }
399
+ return [...candidates]
400
+ .filter(from => from !== slug && !deleted.has(from))
401
+ .filter(from => { const l = linksOf(from); return l.parent === slug || (l.related ?? []).includes(slug); })
402
+ .sort();
403
+ },
404
+ simulateDelete: (slug, kind) => {
405
+ if (kind === 'session') {
406
+ deletedSessions.add(slug);
407
+ simulatedSessions.delete(slug);
408
+ return;
409
+ }
410
+ deleted.add(slug);
411
+ simulated.delete(slug);
412
+ // A file created under this slug later in the batch starts unlinked.
413
+ simulatedLinks.set(slug, {});
414
+ },
341
415
  validateKindFields,
342
416
  validateSessionLogEntry: (entry) => validateSessionLogEntry(entry),
343
417
  buildEntityFrontmatter: (name, fields) => { buildEntityFrontmatter(name, fields, existsAnywhere); },
@@ -369,7 +443,8 @@ export function createHafez(config) {
369
443
  // first (and scripts branch on its exit code) — keep that precedence.
370
444
  if (raw && typeof raw.slug === 'string' && raw.slug &&
371
445
  slugBearingOpNames().includes(entry.op) &&
372
- !existsAnywhere(raw.slug)) {
446
+ !existsAnywhere(raw.slug) &&
447
+ !(sessionAddressingOpNames().includes(entry.op) && ctx.sessionExists(raw.slug))) {
373
448
  entry.errors.unshift(notFoundError(raw.slug));
374
449
  entry.primaryCode = 'NOT_FOUND';
375
450
  }
@@ -399,93 +474,7 @@ export function createHafez(config) {
399
474
  return results;
400
475
  }
401
476
  async function validate() {
402
- const report = {
403
- broken_slugs: [],
404
- orphaned_knowledge: [],
405
- oversized_related: [],
406
- missing_fields: [],
407
- total_entities: 0,
408
- total_knowledge: 0,
409
- };
410
- const allSlugs = new Set();
411
- const knowledgeSlugs = new Set();
412
- const referencedKnowledge = new Set();
413
- // Scan entities
414
- const entDir = join(vaultPath, kindDir('entity'));
415
- {
416
- const files = scanVaultDir(entDir);
417
- report.total_entities = files.length;
418
- for (const filePath of files) {
419
- const slug = basename(filePath, '.md');
420
- allSlugs.add(slug);
421
- let fm;
422
- try {
423
- fm = parseFilePath(filePath).frontmatter;
424
- }
425
- catch {
426
- report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
427
- continue;
428
- }
429
- const errors = validateEntityFrontmatter(fm);
430
- for (const e of errors)
431
- report.missing_fields.push({ slug, field: '', issue: e });
432
- if (fm.parent) {
433
- if (!exists(fm.parent))
434
- report.broken_slugs.push({ slug, field: 'parent', issue: `parent '${fm.parent}' does not exist` });
435
- }
436
- if (fm.related && Array.isArray(fm.related)) {
437
- for (const r of fm.related) {
438
- if (!exists(r))
439
- report.broken_slugs.push({ slug, field: 'related', issue: `related '${r}' does not exist` });
440
- // Track referenced knowledge
441
- referencedKnowledge.add(r);
442
- }
443
- }
444
- }
445
- }
446
- // Scan knowledge
447
- const knDir = join(vaultPath, kindDir('knowledge'));
448
- {
449
- const files = scanVaultDir(knDir);
450
- report.total_knowledge = files.length;
451
- for (const filePath of files) {
452
- const slug = basename(filePath, '.md');
453
- allSlugs.add(slug);
454
- knowledgeSlugs.add(slug);
455
- let fm;
456
- try {
457
- fm = parseFilePath(filePath).frontmatter;
458
- }
459
- catch {
460
- report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
461
- continue;
462
- }
463
- const errors = validateKnowledgeFrontmatter(fm);
464
- for (const e of errors)
465
- report.missing_fields.push({ slug, field: '', issue: e });
466
- if (fm.related && Array.isArray(fm.related)) {
467
- for (const r of fm.related) {
468
- if (!exists(r))
469
- report.broken_slugs.push({ slug, field: 'related', issue: `related '${r}' does not exist` });
470
- }
471
- }
472
- }
473
- }
474
- // Find orphaned knowledge: no related, and not referenced by any entity
475
- for (const slug of knowledgeSlugs) {
476
- let fm;
477
- try {
478
- fm = parseFilePath(join(knDir, `${slug}.md`)).frontmatter;
479
- }
480
- catch {
481
- continue; // already reported above
482
- }
483
- const hasRelated = fm.related && Array.isArray(fm.related) && fm.related.length > 0;
484
- if (!hasRelated && !referencedKnowledge.has(slug)) {
485
- report.orphaned_knowledge.push(slug);
486
- }
487
- }
488
- return report;
477
+ return validateVault(vaultPath);
489
478
  }
490
479
  async function promote(slug, target) {
491
480
  // Rollback safety rides the Spine: applyOperation's promote arm captures
@@ -512,6 +501,7 @@ export function createHafez(config) {
512
501
  capture,
513
502
  link,
514
503
  unlink,
504
+ delete: del,
515
505
  batch,
516
506
  // Same normalization as the apply path — dry-run must judge the payload
517
507
  // apply would actually run.
package/dist/merge.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/merge.ts
2
2
  import { parseContent, serializeFile, ARRAY_FIELDS } from './vault.js';
3
- import { parseSessionLogHeading, splitLogEntries, documentFor } from './document.js';
3
+ import { parseSessionLogHeading, splitLogEntries, documentFor, stripActionStamp } from './document.js';
4
4
  import { APPEND_ONLY_SECTIONS } from './contracts.js';
5
5
  // Fields that are ISO date strings — latest date wins
6
6
  const DATE_FIELDS = ['last-touched', 'last-reinforced', 'created'];
@@ -168,14 +168,16 @@ function parseLogEntries(content) {
168
168
  function mergeNextActions(remoteContent, localContent) {
169
169
  const remote = parseActionItems(remoteContent);
170
170
  const local = parseActionItems(localContent);
171
- // Start with local items; index by action text for the remote pass
171
+ // Start with local items; index by action text for the remote pass. Identity
172
+ // is the unstamped text: one action stamped on two days is one action, and
173
+ // the local line (its stamp included) wins.
172
174
  const merged = local.items.map(i => ({ ...i, notes: [...i.notes] }));
173
- const byText = new Map(merged.map(i => [i.text, i]));
175
+ const byText = new Map(merged.map(i => [stripActionStamp(i.text), i]));
174
176
  for (const item of remote.items) {
175
- const existing = byText.get(item.text);
177
+ const existing = byText.get(stripActionStamp(item.text));
176
178
  if (!existing) {
177
179
  merged.push({ ...item, notes: [...item.notes] });
178
- byText.set(item.text, merged[merged.length - 1]);
180
+ byText.set(stripActionStamp(item.text), merged[merged.length - 1]);
179
181
  continue;
180
182
  }
181
183
  // Checked on either side means done
@@ -0,0 +1,9 @@
1
+ /** The notes whose names near-match `name` — all but one of the shorter
2
+ * name's tokens shared, at least three — and those that only resemble it (two
3
+ * shared or more). Up to three of each, most tokens shared first. */
4
+ export declare function resemblances<T extends {
5
+ name: string;
6
+ }>(name: string, notes: readonly T[]): {
7
+ near: T[];
8
+ weak: T[];
9
+ };
@@ -0,0 +1,34 @@
1
+ // src/near-match.ts
2
+ // The Near-match rule (decision #60): how closely a new knowledge note's name
3
+ // resembles existing ones. Names only, never bodies, so the verdict is
4
+ // deterministic and explainable. Tokens are the name's words, lowercased,
5
+ // with the search stopwords dropped.
6
+ import { tokenize } from './stopwords.js';
7
+ /** A near-match shares at least this many tokens, whatever the name lengths. */
8
+ const NEAR_MIN_SHARED = 3;
9
+ /** A weaker resemblance shares at least this many. */
10
+ const WEAK_MIN_SHARED = 2;
11
+ /** Each list holds at most this many notes. */
12
+ const MAX_LISTED = 3;
13
+ function tokens(name) {
14
+ return new Set(tokenize(name));
15
+ }
16
+ /** The notes whose names near-match `name` — all but one of the shorter
17
+ * name's tokens shared, at least three — and those that only resemble it (two
18
+ * shared or more). Up to three of each, most tokens shared first. */
19
+ export function resemblances(name, notes) {
20
+ const mine = tokens(name);
21
+ const near = [];
22
+ const weak = [];
23
+ for (const note of notes) {
24
+ const theirs = tokens(note.name);
25
+ const shared = [...mine].filter(t => theirs.has(t)).length;
26
+ const shorter = Math.min(mine.size, theirs.size);
27
+ if (shared >= Math.max(NEAR_MIN_SHARED, shorter - 1))
28
+ near.push({ note, shared });
29
+ else if (shared >= WEAK_MIN_SHARED)
30
+ weak.push({ note, shared });
31
+ }
32
+ const top = (xs) => xs.sort((a, b) => b.shared - a.shared).slice(0, MAX_LISTED).map(x => x.note);
33
+ return { near: top(near), weak: top(weak) };
34
+ }
@@ -0,0 +1,41 @@
1
+ /** The threshold an entity without `staleness-days` gets. */
2
+ export declare const DEFAULT_STALENESS_DAYS = 14;
3
+ /** An open action's added day, its age in days, and whether it is stale;
4
+ * undated gives null for both. */
5
+ export interface ActionAge {
6
+ added: string | null;
7
+ age_days: number | null;
8
+ stale: boolean;
9
+ }
10
+ /** The entity side of the rule, as SQL over an index row `i` with `@today`
11
+ * bound to localDay(): whole days from its last-touched day to today.
12
+ * julianday of two bare YYYY-MM-DD days differs by exactly `dayNumber`'s
13
+ * difference, so entities and their actions count the same days. */
14
+ export declare const ENTITY_AGE_DAYS_SQL = "CAST(julianday(@today) - julianday(i.last_touched) AS INTEGER)";
15
+ /** An entity row is stale when its age is past its threshold, not equal to
16
+ * it — `actionAge`'s rule. */
17
+ export declare const ENTITY_STALE_SQL = "CAST(julianday(@today) - julianday(i.last_touched) AS INTEGER) > COALESCE(i.staleness_days, 14)";
18
+ /** How old an action added on `added` is today, and whether that is past
19
+ * `stalenessDays`: older than the threshold, not equal to it. */
20
+ export declare function actionAge(added: string | null, stalenessDays: number, today?: string): ActionAge;
21
+ /** An entity's threshold: its `staleness-days`, else the default. */
22
+ export declare function stalenessDaysOf(frontmatter: {
23
+ 'staleness-days'?: number;
24
+ }): number;
25
+ /** One line of Next Actions as `read --json` gives it: an open line with its
26
+ * Handle and age, a ticked line as text alone. */
27
+ export type AgedAction = ({
28
+ handle: string;
29
+ text: string;
30
+ } & ActionAge) | {
31
+ text: string;
32
+ };
33
+ /** An entity body's Next Actions in file order, each open line aged against
34
+ * the entity's threshold. */
35
+ export declare function agedActions(body: string, frontmatter: {
36
+ 'staleness-days'?: number;
37
+ }): AgedAction[];
38
+ /** The Handles of an entity body's Stale lines, in file order. */
39
+ export declare function staleHandles(body: string, frontmatter: {
40
+ 'staleness-days'?: number;
41
+ }): string[];
package/dist/stale.js ADDED
@@ -0,0 +1,43 @@
1
+ // src/stale.ts
2
+ // Stale (see CONTEXT.md): past the entity's `staleness-days` without
3
+ // movement. An open next action is stale when unticked that long since its
4
+ // added day; an undated one is stale. Ages count local days (src/dates.ts).
5
+ import { localDay } from './dates.js';
6
+ import { getActionItems } from './document.js';
7
+ /** The threshold an entity without `staleness-days` gets. */
8
+ export const DEFAULT_STALENESS_DAYS = 14;
9
+ /** A YYYY-MM-DD day as a whole day count, so differences ignore DST. */
10
+ const dayNumber = (day) => {
11
+ const [y, m, d] = day.split('-').map(Number);
12
+ return Date.UTC(y, m - 1, d) / 86_400_000;
13
+ };
14
+ /** The entity side of the rule, as SQL over an index row `i` with `@today`
15
+ * bound to localDay(): whole days from its last-touched day to today.
16
+ * julianday of two bare YYYY-MM-DD days differs by exactly `dayNumber`'s
17
+ * difference, so entities and their actions count the same days. */
18
+ export const ENTITY_AGE_DAYS_SQL = 'CAST(julianday(@today) - julianday(i.last_touched) AS INTEGER)';
19
+ /** An entity row is stale when its age is past its threshold, not equal to
20
+ * it — `actionAge`'s rule. */
21
+ export const ENTITY_STALE_SQL = `${ENTITY_AGE_DAYS_SQL} > COALESCE(i.staleness_days, ${DEFAULT_STALENESS_DAYS})`;
22
+ /** How old an action added on `added` is today, and whether that is past
23
+ * `stalenessDays`: older than the threshold, not equal to it. */
24
+ export function actionAge(added, stalenessDays, today = localDay()) {
25
+ if (added === null)
26
+ return { added, age_days: null, stale: true };
27
+ const age_days = dayNumber(today) - dayNumber(added);
28
+ return { added, age_days, stale: age_days > stalenessDays };
29
+ }
30
+ /** An entity's threshold: its `staleness-days`, else the default. */
31
+ export function stalenessDaysOf(frontmatter) {
32
+ return frontmatter['staleness-days'] ?? DEFAULT_STALENESS_DAYS;
33
+ }
34
+ /** An entity body's Next Actions in file order, each open line aged against
35
+ * the entity's threshold. */
36
+ export function agedActions(body, frontmatter) {
37
+ const days = stalenessDaysOf(frontmatter);
38
+ return getActionItems(body).map(item => item.handle === undefined ? { text: item.text } : { handle: item.handle, text: item.text, ...actionAge(item.added, days) });
39
+ }
40
+ /** The Handles of an entity body's Stale lines, in file order. */
41
+ export function staleHandles(body, frontmatter) {
42
+ return agedActions(body, frontmatter).flatMap(a => 'handle' in a && a.stale ? [a.handle] : []);
43
+ }
@@ -0,0 +1,6 @@
1
+ export declare const STOPWORDS: ReadonlySet<string>;
2
+ /** Lowercased words with stopwords dropped. `stem` also strips a plural `s`,
3
+ * so `launchpads` and `launchpad` are one word. */
4
+ export declare function tokenize(text: string, { stem }?: {
5
+ stem?: boolean;
6
+ }): string[];
@@ -0,0 +1,25 @@
1
+ // src/stopwords.ts
2
+ // Words too common to tell two texts apart: dropped from full-text search
3
+ // terms (db.ts) and, through `tokenize`, from the words the Near-match rule
4
+ // (near-match.ts) and the read-miss closest slugs (closest.ts) compare.
5
+ export const STOPWORDS = new Set([
6
+ 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of',
7
+ 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
8
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should',
9
+ 'may', 'might', 'can', 'shall', 'not', 'no', 'nor', 'so', 'if', 'then', 'than',
10
+ 'that', 'this', 'these', 'those', 'it', 'its', 'my', 'your', 'his', 'her',
11
+ 'our', 'their', 'what', 'which', 'who', 'whom', 'how', 'when', 'where', 'why',
12
+ 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'some', 'any', 'such',
13
+ 'about', 'into', 'over', 'after', 'before',
14
+ 'work', 'works', 'working', 'use', 'using', 'used', 'get', 'got', 'make', 'made',
15
+ 'need', 'want', 'like', 'just', 'also', 'still', 'already', 'very', 'much',
16
+ ]);
17
+ /** Lowercased words with stopwords dropped. `stem` also strips a plural `s`,
18
+ * so `launchpads` and `launchpad` are one word. */
19
+ export function tokenize(text, { stem = false } = {}) {
20
+ const words = text
21
+ .toLowerCase()
22
+ .split(/[^\p{L}\p{N}]+/u)
23
+ .filter(w => w && !STOPWORDS.has(w));
24
+ return stem ? words.map(w => (w.length > 3 && w.endsWith('s') && !w.endsWith('ss') ? w.slice(0, -1) : w)) : words;
25
+ }