Add structured sections and hierarchical navigation

Add --section flag to add/update commands for structured node content,
render sections and inline children in show, and add memory children command.
This commit is contained in:
2026-02-02 22:29:23 +01:00
parent f1b59a2d1a
commit 5f7692f5f6
6 changed files with 125 additions and 1 deletions

View File

@@ -23,9 +23,11 @@ Add a node to the knowledge graph.
| `-c, --content <content>` | no | Node content/description |
| `--tags <tags>` | no | Comma-separated tags |
| `--status <status>` | no | Status (e.g. `todo`, `doing`, `done`, `active`, `deprecated`) |
| `--section <section>` | no | Structured section as `"Label: body"` (repeatable) |
```bash
memory add memory -t "Auth flow design" -c "OAuth2 PKCE flow for SPA" --tags auth,security --status active
memory add memory -t "add command" --section "Arguments: <kind> — memory, component, task, decision" --section "Options: -t title, -c content, --tags, --status"
memory add task -t "Implement login page" --status todo
memory add decision -t "Use PostgreSQL" -c "Chose Postgres over MySQL for JSON support"
memory add component -t "UserService" -c "Handles user CRUD operations" --tags backend
@@ -97,6 +99,7 @@ Update an existing node's fields.
| `--status <status>` | no | New status |
| `--tags <tags>` | no | Replace tags (comma-separated) |
| `--stale` | no | Mark as stale |
| `--section <section>` | no | Structured section as `"Label: body"` (repeatable, replaces by label) |
```bash
memory update abc123 --status done
@@ -146,6 +149,22 @@ memory graph
memory graph abc123
```
### `memory children <id>`
List child nodes connected via outgoing `contains` edges from the given node.
| Argument / Option | Required | Description |
|---|---|---|
| `<id>` | yes | Parent node ID or prefix |
| `--kind <kind>` | no | Filter children by kind |
| `--format <fmt>` | no | Output format: `text` or `json` (default: `text`) |
```bash
memory children abc123
memory children abc123 --kind task
memory children abc123 --format json
```
### `memory decay`
Run auto-decay to mark old untouched nodes as stale. Nodes whose `lastAccessedAt` exceeds the threshold are marked stale and hidden from default listings and search.
@@ -197,6 +216,22 @@ memory serve --port 8080
| `supersedes` | Source supersedes/replaces target |
| `about` | Source is about target |
## Structured Sections
Nodes can have structured content via `metadata.sections`. Each section has a `label` and `body`. Use the `--section "Label: body"` flag on `add` or `update` to create sections.
When displayed with `memory show`, sections render as:
```
── Arguments ──
<kind> — memory, component, task, decision
── Options ──
-t title, -c content, --tags, --status
```
The `show` command also displays inline children (nodes linked via `contains` edges). Use `memory children <id>` to list only children.
## Search Scoring
Search combines three signals:

View File

@@ -11,6 +11,7 @@ export const addCommand = new Command('add')
.option('-c, --content <content>', 'Node content/description', '')
.option('--tags <tags>', 'Comma-separated tags')
.option('--status <status>', 'Status (e.g. todo, doing, done, active, deprecated)')
.option('--section <section...>', 'Structured section as "Label: body" (repeatable)')
.description('Add a node to the knowledge graph')
.action(async (kind: string, opts) => {
if (!VALID_KINDS.includes(kind as NodeKind)) {
@@ -19,12 +20,21 @@ export const addCommand = new Command('add')
}
const tags = opts.tags ? opts.tags.split(',').map((t: string) => t.trim()) : [];
const metadata: Record<string, any> = {};
if (opts.section) {
metadata.sections = (opts.section as string[]).map((s: string) => {
const idx = s.indexOf(':');
if (idx === -1) return { label: s.trim(), body: '' };
return { label: s.slice(0, idx).trim(), body: s.slice(idx + 1).trim() };
});
}
const node = await addNode({
kind: kind as NodeKind,
title: opts.title,
content: opts.content,
status: opts.status,
tags,
metadata,
});
console.log(chalk.green('✓ Added node'));

View File

@@ -0,0 +1,45 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { findNodeByPrefix } from '../../core/store';
import { getConnections } from '../../core/graph';
import { NodeKind } from '../../types';
export const childrenCommand = new Command('children')
.argument('<id>', 'Parent node ID (or prefix)')
.option('--kind <kind>', 'Filter children by kind')
.option('--format <fmt>', 'Output format: text or json', 'text')
.description('List child nodes (outgoing "contains" edges)')
.action(async (idRaw: string, opts) => {
const node = findNodeByPrefix(idRaw);
if (!node) {
console.error(chalk.red(`Node not found: ${idRaw}`));
process.exit(1);
}
const conns = getConnections(node.id);
let children = conns.outgoing.filter(c => c.type === 'contains');
if (opts.kind) {
children = children.filter(c => c.node.kind === opts.kind);
}
if (opts.format === 'json') {
console.log(JSON.stringify(children.map(c => ({
id: c.node.id,
kind: c.node.kind,
title: c.node.title,
status: c.node.status,
})), null, 2));
return;
}
if (children.length === 0) {
console.log(chalk.dim('No children.'));
return;
}
console.log(chalk.bold(`Children of [${node.kind}] ${node.title}:`));
for (const c of children) {
console.log(` ${c.node.id.slice(0, 8)} [${c.node.kind}] ${c.node.title}`);
}
});

View File

@@ -30,6 +30,23 @@ export const showCommand = new Command('show')
if (node.isStale) console.log(chalk.red('STALE'));
if (node.content) console.log(`\n${node.content}`);
// Render structured sections
if (node.metadata?.sections && Array.isArray(node.metadata.sections)) {
for (const sec of node.metadata.sections) {
console.log(`\n${chalk.bold(`── ${sec.label} ──`)}`);
if (sec.body) console.log(sec.body);
}
}
// Inline children (outgoing 'contains' edges)
const children = conns.outgoing.filter(c => c.type === 'contains');
if (children.length) {
console.log(chalk.bold('\nChildren:'));
for (const c of children) {
console.log(` ${c.node.id.slice(0, 8)} [${c.node.kind}] ${c.node.title}`);
}
}
if (conns.outgoing.length) {
console.log(chalk.bold('\nOutgoing:'));
for (const c of conns.outgoing) {

View File

@@ -9,6 +9,7 @@ export const updateCommand = new Command('update')
.option('--status <status>', 'New status')
.option('--tags <tags>', 'Replace tags (comma-separated)')
.option('--stale', 'Mark as stale')
.option('--section <section...>', 'Structured section as "Label: body" (repeatable)')
.description('Update a node')
.action(async (idRaw: string, opts) => {
const node = findNodeByPrefix(idRaw);
@@ -23,9 +24,23 @@ export const updateCommand = new Command('update')
if (opts.status !== undefined) input.status = opts.status;
if (opts.tags !== undefined) input.tags = opts.tags.split(',').map((t: string) => t.trim());
if (opts.stale) input.isStale = true;
if (opts.section) {
const newSections = (opts.section as string[]).map((s: string) => {
const idx = s.indexOf(':');
if (idx === -1) return { label: s.trim(), body: '' };
return { label: s.slice(0, idx).trim(), body: s.slice(idx + 1).trim() };
});
const existing: { label: string; body: string }[] = node.metadata?.sections ?? [];
for (const ns of newSections) {
const idx = existing.findIndex((e) => e.label === ns.label);
if (idx >= 0) existing[idx] = ns;
else existing.push(ns);
}
input.metadata = { ...node.metadata, sections: existing };
}
if (Object.keys(input).length === 0) {
console.log(chalk.yellow('Nothing to update. Use --title, --content, --status, --tags, or --stale.'));
console.log(chalk.yellow('Nothing to update. Use --title, --content, --status, --tags, --stale, or --section.'));
return;
}

View File

@@ -10,6 +10,7 @@ import { removeCommand } from './commands/remove';
import { graphCommand } from './commands/graph';
import { serveCommand } from './commands/serve';
import { decayCommand } from './commands/decay';
import { childrenCommand } from './commands/children';
import { closeDb } from '../core/db';
const program = new Command();
@@ -29,6 +30,7 @@ program.addCommand(removeCommand);
program.addCommand(graphCommand);
program.addCommand(serveCommand);
program.addCommand(decayCommand);
program.addCommand(childrenCommand);
program.hook('postAction', () => {
closeDb();