🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

memoir-cli

Package Overview
Dependencies
Maintainers
1
Versions
51
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

memoir-cli - npm Package Compare versions

Comparing version
3.10.2
to
3.11.0
+407
src/commands/validate.js
// `memoir validate` — structural conformance checks for the memoir format.
//
// docs/SPEC.md (v0.1 draft) is the normative text; schema/*.schema.json is
// the machine-readable mirror. The checks here are hand-rolled on purpose:
// no JSON Schema engine dependency for a format this small, and the few
// checks that matter (required fields, date sanity, tombstone invariants)
// stay readable as plain code.
//
// memoir validate validate the live session.json
// memoir validate <file.md> ... validate entry files
// memoir validate <dir> validate every *.md under dir
// (+ its session.json, if present)
// memoir validate --strict warnings count as failures
//
// Severity model:
// error — violates a MUST in SPEC.md; the file fails.
// warning — violates a SHOULD, or a legacy (pre-v0.1) dialect that
// readers must tolerate but writers must not emit.
// Exit code is 1 when any error occurred (or any warning, with --strict).
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import { paths } from '../session/state.js';
import { SCHEMA_VERSION } from '../session/migrations.js';
const ENTRY_TYPES = ['fact', 'preference', 'decision', 'lesson', 'goal', 'next_action'];
// Pre-v0.1 type vocabulary (SPEC.md Appendix A) — accepted with a warning.
const LEGACY_TYPES = {
user: 'preference',
feedback: 'lesson',
reference: 'fact',
project: null, // dossier — no atomic mapping; read as opaque legacy entry
};
// Frontmatter date-ish fields, checked for ISO 8601 shape when present.
const DATE_FIELDS = ['created', 'updated', 'date', 'set_on', 'added', 'done_at', 'hidden_at', 'last_fired'];
// ── Frontmatter parsing (restricted YAML subset per SPEC.md 3.1) ──
//
// Supports: scalar `key: value`, one level of nested mapping, and simple
// `- item` string lists. That is all the format allows in frontmatter, so
// that is all this parses. Not a general YAML parser and not meant to be.
function parseScalar(raw) {
let v = String(raw).trim();
if (
(v.startsWith('"') && v.endsWith('"') && v.length >= 2) ||
(v.startsWith("'") && v.endsWith("'") && v.length >= 2)
) {
v = v.slice(1, -1);
}
if (v === 'true') return true;
if (v === 'false') return false;
if (/^-?\d+$/.test(v)) return parseInt(v, 10);
return v;
}
export function parseFrontmatter(raw) {
const lines = String(raw).split(/\r?\n/);
if ((lines[0] || '').trim() !== '---') {
return { present: false, fields: {}, body: raw, error: null };
}
let end = -1;
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === '---') { end = i; break; }
}
if (end === -1) {
return { present: true, fields: {}, body: raw, error: 'unterminated frontmatter (no closing ---)' };
}
const fields = {};
let openKey = null; // most recent top-level key whose value may nest
for (let i = 1; i < end; i++) {
const line = lines[i];
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
if (/^\s/.test(line) && openKey) {
// One level of nesting under openKey: nested map entry or list item.
if (trimmed.startsWith('- ')) {
if (!Array.isArray(fields[openKey])) fields[openKey] = [];
fields[openKey].push(parseScalar(trimmed.slice(2)));
} else {
const m = trimmed.match(/^([^:]+):\s*(.*)$/);
if (m) {
if (typeof fields[openKey] !== 'object' || fields[openKey] === null || Array.isArray(fields[openKey])) {
fields[openKey] = {};
}
fields[openKey][m[1].trim()] = parseScalar(m[2]);
}
}
continue;
}
const m = line.match(/^([^:\s][^:]*):\s*(.*)$/);
if (!m) continue; // tolerate lines we don't understand — validation reports, not parsing, is the job
const key = m[1].trim();
if (m[2] === '') {
openKey = key;
fields[key] = '';
} else {
openKey = null;
fields[key] = parseScalar(m[2]);
}
}
return { present: true, fields, body: lines.slice(end + 1).join('\n'), error: null };
}
// ── Shared checks ────────────────────────────────────────────────
function isIsoDateString(v) {
return typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v) && !Number.isNaN(new Date(v).getTime());
}
// ── Entry file validation (SPEC.md section 3, entry.schema.json) ──
export function validateEntry(raw) {
const errors = [];
const warnings = [];
const { present, fields, body, error } = parseFrontmatter(raw);
if (!present) {
warnings.push('no frontmatter — legacy bare-markdown entry (SPEC.md Appendix A); readers treat as opaque, writers must not emit this');
return { errors, warnings };
}
if (error) {
errors.push(error);
return { errors, warnings };
}
// Resolve type across dialects: canonical top-level `type`, legacy `metadata.type`.
let type = fields.type;
if (type == null && fields.metadata && typeof fields.metadata === 'object' && fields.metadata.type != null) {
type = fields.metadata.type;
warnings.push('legacy dialect: type nested under metadata (canonical: top-level `type`)');
}
if (!fields.name || typeof fields.name !== 'string' || !String(fields.name).trim()) {
errors.push('missing required field: name');
}
if (!fields.description) {
warnings.push('missing description (SHOULD) — entry is invisible in indexes and pickers');
}
if (type == null) {
errors.push(`missing required field: type (one of: ${ENTRY_TYPES.join(', ')})`);
} else if (ENTRY_TYPES.includes(type)) {
validateCanonicalType(type, fields, body, errors, warnings);
} else if (type in LEGACY_TYPES) {
const mapped = LEGACY_TYPES[type];
warnings.push(`legacy type "${type}"${mapped ? ` (reads as ${mapped})` : ' (dossier — no atomic mapping)'} — canonical types: ${ENTRY_TYPES.join(', ')}`);
if (type === 'feedback' && !/\*\*How to apply:?\*\*/i.test(body)) {
warnings.push('legacy feedback entry without a **How to apply:** body section — lesson has no application rule');
}
} else {
errors.push(`unknown type "${type}" — canonical: ${ENTRY_TYPES.join(', ')}; legacy (read-only): ${Object.keys(LEGACY_TYPES).join(', ')}`);
}
for (const f of DATE_FIELDS) {
if (fields[f] != null && !isIsoDateString(fields[f])) {
errors.push(`field ${f} is not an ISO 8601 date: ${JSON.stringify(fields[f])}`);
}
}
if (fields.schema_version != null && (!Number.isInteger(fields.schema_version) || fields.schema_version < 1)) {
errors.push(`schema_version must be a positive integer, got ${JSON.stringify(fields.schema_version)}`);
}
if (Number.isInteger(fields.schema_version) && fields.schema_version > 1) {
warnings.push(`schema_version ${fields.schema_version} is newer than this build understands (v1) — forward-version rule applies: quarantine + degrade, never guess (SPEC.md section 6)`);
}
return { errors, warnings };
}
function validateCanonicalType(type, fields, body, errors, warnings) {
if (type === 'decision') {
if (!fields.why) warnings.push('decision without a why (required-encouraged) — a decision without a why is half a decision');
if (!fields.rejected) warnings.push('decision without a rejected alternative (required-encouraged)');
if (fields.hidden === true && !fields.hidden_at) {
errors.push('hidden: true without hidden_at — tombstones must carry when they were set (SPEC.md 5.3.1)');
}
} else if (type === 'lesson') {
if (!fields.trigger) {
errors.push('lesson missing required field: trigger — when does this lesson apply?');
}
if (!fields.how_to_apply) {
if (/\*\*How to apply:?\*\*/i.test(body)) {
warnings.push('how_to_apply lives in the body (**How to apply:** section) — accepted for legacy entries; canonical form is the frontmatter key');
} else {
errors.push('lesson missing required field: how_to_apply — without an application rule it is an anecdote, not a lesson');
}
}
if (fields.fired_count != null && (!Number.isInteger(fields.fired_count) || fields.fired_count < 0)) {
errors.push(`fired_count must be a non-negative integer, got ${JSON.stringify(fields.fired_count)}`);
}
} else if (type === 'next_action') {
if (!fields.added) {
errors.push('next_action missing required field: added — completion semantics (done_at vs added) are undefined without it');
}
if (isIsoDateString(fields.added) && isIsoDateString(fields.done_at) && new Date(fields.done_at) < new Date(fields.added)) {
warnings.push('done_at is earlier than added — this action was completed before it existed');
}
} else if (type === 'preference') {
if (fields.scope != null && !['global', 'project'].includes(fields.scope)) {
warnings.push(`preference scope should be "global" or "project", got ${JSON.stringify(fields.scope)}`);
}
}
// fact, goal: no extra required fields.
}
// ── Session file validation (SPEC.md section 4, session.schema.json) ──
function checkListItems(list, listName, dateField, errors, warnings) {
if (!Array.isArray(list)) {
errors.push(`current.${listName} must be an array`);
return;
}
list.forEach((item, i) => {
const at = `current.${listName}[${i}]`;
if (!item || typeof item !== 'object' || Array.isArray(item)) {
errors.push(`${at} must be an object`);
return;
}
if (typeof item.text !== 'string' || !item.text.trim()) {
errors.push(`${at} missing required field: text (the merge identity key)`);
}
if (item[dateField] != null && !isIsoDateString(item[dateField])) {
errors.push(`${at}.${dateField} is not an ISO 8601 date-time: ${JSON.stringify(item[dateField])}`);
}
if (item[dateField] == null) {
warnings.push(`${at} missing ${dateField} — merges treat a missing date as the epoch (always loses newest-wins)`);
}
});
}
export function validateSessionObject(obj) {
const errors = [];
const warnings = [];
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
errors.push('session must be a JSON object');
return { errors, warnings };
}
if (!Number.isInteger(obj.version) || obj.version < 1) {
errors.push(`version must be a positive integer, got ${JSON.stringify(obj.version)}`);
} else if (obj.version > SCHEMA_VERSION) {
warnings.push(`version ${obj.version} is newer than this build understands (v${SCHEMA_VERSION}) — forward-version rule applies: quarantine + degrade, never guess (SPEC.md section 6)`);
}
for (const f of ['created_at', 'updated_at']) {
if (obj[f] == null) errors.push(`missing required field: ${f}`);
else if (!isIsoDateString(obj[f])) errors.push(`${f} is not an ISO 8601 date-time: ${JSON.stringify(obj[f])}`);
}
if (!obj.machines || typeof obj.machines !== 'object' || Array.isArray(obj.machines)) {
errors.push('missing required field: machines (object of machine-uuid -> { label, last_seen })');
} else {
for (const [id, m] of Object.entries(obj.machines)) {
if (!m || typeof m !== 'object') { errors.push(`machines["${id}"] must be an object`); continue; }
if (typeof m.label !== 'string') errors.push(`machines["${id}"] missing required field: label`);
if (!isIsoDateString(m.last_seen)) errors.push(`machines["${id}"].last_seen is not an ISO 8601 date-time`);
}
}
const cur = obj.current;
if (!cur || typeof cur !== 'object' || Array.isArray(cur)) {
errors.push('missing required field: current');
} else {
checkListItems(cur.goals, 'goals', 'set_on', errors, warnings);
checkListItems(cur.next_actions, 'next_actions', 'added', errors, warnings);
checkListItems(cur.open_questions, 'open_questions', 'asked', errors, warnings);
checkListItems(cur.decisions, 'decisions', 'date', errors, warnings);
(Array.isArray(cur.decisions) ? cur.decisions : []).forEach((d, i) => {
if (d && d.hidden === true && !isIsoDateString(d.hidden_at)) {
errors.push(`current.decisions[${i}] hidden: true without a valid hidden_at (SPEC.md 5.3.1)`);
}
});
// completed_actions is optional (absent = empty), but when present its
// tombstones must be well-formed or the temporal merge rule breaks.
if (cur.completed_actions != null) {
if (!Array.isArray(cur.completed_actions)) {
errors.push('current.completed_actions must be an array when present');
} else {
cur.completed_actions.forEach((t, i) => {
const at = `current.completed_actions[${i}]`;
if (!t || typeof t !== 'object') { errors.push(`${at} must be an object`); return; }
if (typeof t.text !== 'string' || !t.text.trim()) errors.push(`${at} missing required field: text`);
if (!isIsoDateString(t.done_at)) errors.push(`${at} missing/invalid required field: done_at (the tombstone is meaningless without it)`);
});
}
}
}
if (!Array.isArray(obj.history)) {
errors.push('missing required field: history (array)');
} else {
obj.history.forEach((h, i) => {
if (!h || typeof h !== 'object') { errors.push(`history[${i}] must be an object`); return; }
if (!isIsoDateString(h.date)) errors.push(`history[${i}] missing/invalid required field: date`);
if (h.files_touched != null && !Array.isArray(h.files_touched)) errors.push(`history[${i}].files_touched must be an array`);
if (h.duration_min != null && typeof h.duration_min !== 'number') errors.push(`history[${i}].duration_min must be a number or null`);
});
}
return { errors, warnings };
}
// ── File collection ──────────────────────────────────────────────
async function collectTargets(args) {
// Returns [{ path, kind: 'entry' | 'session' }]. No args: the live session.json.
if (!args.length) {
return [{ path: paths.session, kind: 'session' }];
}
const targets = [];
for (const arg of args) {
const p = path.resolve(arg);
let stat;
try { stat = await fs.stat(p); }
catch { targets.push({ path: p, kind: 'missing' }); continue; }
if (stat.isDirectory()) {
const sessionPath = path.join(p, 'session.json');
if (await fs.pathExists(sessionPath)) targets.push({ path: sessionPath, kind: 'session' });
const stack = [p];
while (stack.length) {
const dir = stack.pop();
let entries = [];
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { continue; }
for (const e of entries) {
if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
const full = path.join(dir, e.name);
if (e.isDirectory()) stack.push(full);
else if (e.name.endsWith('.md')) targets.push({ path: full, kind: 'entry' });
}
}
} else if (p.endsWith('.json')) {
targets.push({ path: p, kind: 'session' });
} else {
targets.push({ path: p, kind: 'entry' });
}
}
return targets;
}
// ── Command ──────────────────────────────────────────────────────
export async function validateCommand(args = [], options = {}) {
const targets = await collectTargets(args);
console.log('\n' + chalk.bold.white(' memoir validate') + chalk.gray(' — format v0.1 draft (docs/SPEC.md)') + '\n');
let checked = 0;
let failed = 0;
let totalWarnings = 0;
for (const target of targets) {
const rel = target.path.startsWith(process.cwd()) ? path.relative(process.cwd(), target.path) : target.path;
if (target.kind === 'missing') {
failed++;
console.log(chalk.red(' ✖ ') + chalk.white(rel));
console.log(chalk.red(' error ') + 'file not found');
continue;
}
checked++;
let result;
if (target.kind === 'session') {
let parsed;
try {
parsed = JSON.parse(await fs.readFile(target.path, 'utf8'));
} catch (err) {
result = { errors: [`unparseable JSON: ${err.message}`], warnings: [] };
}
if (!result) result = validateSessionObject(parsed);
} else {
let raw;
try {
raw = await fs.readFile(target.path, 'utf8');
} catch (err) {
result = { errors: [`unreadable: ${err.message}`], warnings: [] };
}
if (!result) result = validateEntry(raw);
}
const { errors, warnings } = result;
totalWarnings += warnings.length;
const fails = errors.length > 0 || (options.strict && warnings.length > 0);
if (fails) failed++;
const mark = fails ? chalk.red(' ✖ ') : warnings.length ? chalk.yellow(' ⚠ ') : chalk.green(' ✔ ');
console.log(mark + chalk.white(rel));
for (const e of errors) console.log(chalk.red(' error ') + e);
for (const w of warnings) console.log(chalk.yellow(' warning ') + w);
}
const parts = [`${checked} file${checked !== 1 ? 's' : ''} checked`];
parts.push(failed > 0 ? chalk.red(`${failed} failed`) : chalk.green('all passed'));
if (totalWarnings > 0) parts.push(chalk.yellow(`${totalWarnings} warning${totalWarnings !== 1 ? 's' : ''}`));
console.log('\n ' + parts.join(chalk.gray(' · ')) + '\n');
if (failed > 0) process.exitCode = 1;
return { checked, failed, warnings: totalWarnings };
}
+14
-0

@@ -37,2 +37,3 @@ #!/usr/bin/env node

import { autoRefreshCommand } from '../src/commands/auto-refresh.js';
import { validateCommand } from '../src/commands/validate.js';
import { hooksInstallCommand, hooksUninstallCommand, hooksStatusCommand } from '../src/commands/hooks.js';

@@ -273,2 +274,15 @@ import { capture as track, telemetryCommand } from '../src/telemetry.js';

program
.command('validate [paths...]')
.description('Check session state and memory entry files against the memoir format spec (docs/SPEC.md)')
.option('--strict', 'Treat warnings as failures')
.action(async (paths, options) => {
try {
await validateCommand(paths || [], options);
} catch (err) {
console.error(chalk.red('\n✖ Error:'), err.message);
process.exit(1);
}
});
program
.command('doctor')

@@ -275,0 +289,0 @@ .alias('diagnose')

+1
-1
{
"name": "memoir-cli",
"version": "3.10.2",
"version": "3.11.0",
"mcpName": "io.github.camgitt/memoir",

@@ -5,0 +5,0 @@ "description": "Private, portable AI memory: synced across every coding tool and machine, end-to-end encrypted, free. One memory for Claude Code, Cursor, Copilot, Gemini + more — MCP-native, zero-knowledge, open source.",

@@ -374,2 +374,14 @@ import chalk from 'chalk';

if (shouldEncrypt === undefined) {
if (background || !process.stdin.isTTY) {
// First push with nobody to ask (the detached autopush hook, CI, a
// pipe). Any inquirer prompt here dies or hangs against ignored
// stdio. Encrypt if MEMOIR_PASSPHRASE makes that possible;
// otherwise push unencrypted THIS ONCE without persisting the
// choice — a backup beats no backup, and the next interactive push
// still gets the real question (default Yes).
shouldEncrypt = Boolean(process.env.MEMOIR_PASSPHRASE);
if (!shouldEncrypt) {
config.encrypt = undefined; // do not let the fallthrough persist "off"
}
} else {
// First push since encryption was added — ask once and save preference

@@ -384,2 +396,3 @@ spinner.stop();

shouldEncrypt = wantEncrypt;
}

@@ -406,10 +419,15 @@ // Save to config so we don't ask again

if (shouldEncrypt) {
spinner.stop();
const { passphrase } = await inquirer.prompt([{
type: 'password',
name: 'passphrase',
message: '🔒 Encryption passphrase:',
mask: '*',
validate: (input) => input.length >= 6 ? true : 'Passphrase must be at least 6 characters'
}]);
// Headless pushes can supply the passphrase via env; interactive
// pushes are asked as before.
let passphrase = process.env.MEMOIR_PASSPHRASE || '';
if (!passphrase || passphrase.length < 6) {
spinner.stop();
({ passphrase } = await inquirer.prompt([{
type: 'password',
name: 'passphrase',
message: '🔒 Encryption passphrase:',
mask: '*',
validate: (input) => input.length >= 6 ? true : 'Passphrase must be at least 6 characters'
}]));
}
spinner.start(chalk.gray('Deriving encryption key...'));

@@ -416,0 +434,0 @@