Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,41 @@ All notable changes to MeMesh are documented here.

### Added

- **`memesh why <file>` + `POST /v1/why`: file attribution with typed
abstentions.** Local git resolves which commits touched a file
(`git log --follow`, or `git blame` for `--line N`); the graph answers
what memesh remembers about them — the captured commit entity, the
session it was made in, and the memories associated with the file by
`file:<basename>` tag (labelled "associated, not commit-derived").
Every gap in the chain is a machine-readable abstention rendered as a
sentence — `no_commit_entity`, `no_session_link`, `not_a_git_repo`,
`file_not_tracked`, `line_uncommitted`, `line_out_of_range` — never a
guess. The join is prefix-based in both directions because post-commit
names entities `commit-<abbreviated hash>` while git emits full SHAs.
The HTTP route takes commit hashes from the caller and never shells out
to git: its strict schema has no repo-path field on purpose. To make
the commit→session hop real going forward, the post-commit hook now
records `metadata.session_id` and `metadata.files` (capped at 50) on
commit entities — metadata rather than tags, so pre-edit-recall's
file-tag join cannot start injecting commit noise into edits.
- **Project tab: history, honestly told.** Four additions to the roadmap:
(1) a **capture-density band** — per-category (`knowledge` / `activity`
/ `session` / `reference`) histogram of when memories were captured,
bucketed on `created_at`, the same axis the phase strip segments on,
and named for what it measures: what memesh captured, not everything
that happened; (2) a **lineage overlay** — `supersedes` (solid,
neutral) and `contradicts` (dashed, warning) arcs drawn on the
timeline between rows actually on screen, with a visible text legend
counting only the drawn arcs; the superseded (auto-archived) targets
of active entities are re-admitted into the roadmap so a chain always
has both ends — general archived noise stays out; (3) an **ADR-style
Decisions view** — one card per decision entity with an honest
two-state status (`active` / `superseded`, derived from the graph, no
invented lifecycle) and its supersession chain spelled out with jump
links; (4) **URL deep links** — the dashboard now writes `?tab=` back
to the address bar and the Project tab reads and writes `?project=`,
so a copied URL shows the reader the view being looked at.

- **Lesson guards: a recorded mistake can now warn at the moment it is
about to repeat.** The dreamer gained a guard stage: for each
failure-shaped lesson (the Error / Root cause / Fix structure) it
Expand Down
18 changes: 9 additions & 9 deletions dashboard/dist/index.html

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,17 @@ export function App() {
// storage) are silent — the default kicks in next session.
useEffect(() => {
try { localStorage.setItem(TAB_STORAGE_KEY, tab); } catch { /* no-op */ }
// Write the tab back to the URL. The read side (initialTab) has
// honoured ?tab= deep links since the 5-tab shell, but nothing ever
// wrote the param — so copying the address bar always shared "wherever
// the reader's own storage lands them", never the view being looked
// at. replaceState, not pushState: tab switches are view state, not
// navigation history.
try {
const url = new URL(window.location.href);
url.searchParams.set('tab', tab);
history.replaceState(null, '', url);
} catch { /* no-op — same private-mode tolerance as storage */ }
}, [tab]);
const [health, setHealth] = useState<HealthData | null>(null);
const [error, setError] = useState('');
Expand Down
131 changes: 131 additions & 0 deletions dashboard/src/components/CaptureDensityBand.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { useMemo } from 'preact/hooks';
import type { Entity } from '../lib/api';
import { clusterOf, CLUSTER_DOT, type TypeCluster } from '../lib/entity-display';
import { t } from '../lib/i18n';

/**
* Capture density by category — a per-project histogram of WHEN memories
* were captured, split by type cluster.
*
* The name is the honesty contract: this measures what memesh CAPTURED,
* not what happened. A quiet stretch on the band is a capture gap (hooks
* off, work done elsewhere), not proof the project slept — the label says
* so (`roadmap.densityNote`) and stays visible next to the band.
*
* Buckets are derived from `created_at` — the same field the roadmap's
* phase segmentation uses — so the band and the phase strip describe one
* timeline. (The flat fallback list groups by last_accessed; do not
* "align" the band to that, it is a different axis.)
*
* Rendering follows DESIGN.md's composition-bar precedent: the bar itself
* is aria-hidden ornament over data; the visible text (title, note,
* legend counts) carries everything a screen reader needs. Bucket height
* is linear in count — luminance/height carry data or they do not appear.
*/

const CLUSTERS: TypeCluster[] = ['knowledge', 'activity', 'session', 'reference'];
const BAND_HEIGHT = 34;
/** Upper bound on bucket count; the size ladder below keeps real spans
* well under it, this is the guard for degenerate date data. */
const MAX_BUCKETS = 60;

const DAY_MS = 24 * 60 * 60 * 1000;

interface Bucket {
startMs: number;
counts: Record<TypeCluster, number>;
total: number;
}

function bucketSizeMs(spanMs: number): number {
const spanDays = spanMs / DAY_MS;
if (spanDays <= 31) return DAY_MS;
if (spanDays <= 217) return 7 * DAY_MS;
return 30 * DAY_MS;
}

export function deriveBuckets(entities: Entity[]): Bucket[] {
const times = entities
.map((e) => new Date(e.created_at).getTime())
.filter((ms) => Number.isFinite(ms));
if (times.length === 0) return [];
const first = Math.min(...times);
const last = Math.max(...times);
const size = bucketSizeMs(Math.max(last - first, 1));
const count = Math.min(Math.floor((last - first) / size) + 1, MAX_BUCKETS);
const buckets: Bucket[] = Array.from({ length: count }, (_, i) => ({
startMs: first + i * size,
counts: { knowledge: 0, activity: 0, session: 0, reference: 0 },
total: 0,
}));
for (const e of entities) {
const ms = new Date(e.created_at).getTime();
if (!Number.isFinite(ms)) continue;
const idx = Math.min(Math.floor((ms - first) / size), count - 1);
buckets[idx].counts[clusterOf(e.type)]++;
buckets[idx].total++;
}
return buckets;
}

export function CaptureDensityBand({ entities }: { entities: Entity[] }) {
const buckets = useMemo(() => deriveBuckets(entities), [entities]);
const clusterTotals = useMemo(() => {
const totals: Record<TypeCluster, number> = { knowledge: 0, activity: 0, session: 0, reference: 0 };
for (const b of buckets) for (const c of CLUSTERS) totals[c] += b.counts[c];
return totals;
}, [buckets]);

if (buckets.length === 0) return null;
const max = Math.max(...buckets.map((b) => b.total), 1);

return (
<div style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'baseline', marginBottom: 4 }}>
<span style={{ fontSize: 11, color: 'var(--text-2)', fontFamily: 'var(--font-ui)' }}>
{t('roadmap.densityTitle')}
</span>
<span style={{ fontSize: 10, color: 'var(--text-3)' }}>{t('roadmap.densityNote')}</span>
</div>
<div
aria-hidden="true"
style={{
display: 'flex',
alignItems: 'flex-end',
gap: 1,
height: BAND_HEIGHT,
background: 'var(--bg-0)',
borderRadius: 'var(--radius-hairline)',
padding: '2px 2px 0',
}}
>
{buckets.map((b, i) => (
<div
key={i}
style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', minWidth: 0 }}
>
{CLUSTERS.map((c) => b.counts[c] > 0 && (
<div
key={c}
style={{
height: Math.max((b.counts[c] / max) * (BAND_HEIGHT - 2), 1),
background: CLUSTER_DOT[c],
borderRadius: 'var(--radius-hairline)',
}}
/>
))}
</div>
))}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 4 }}>
{CLUSTERS.map((c) => clusterTotals[c] > 0 && (
<span key={c} style={{ display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 11, color: 'var(--text-2)', fontFamily: 'var(--font-ui)' }}>
<span aria-hidden="true" style={{ width: 8, height: 8, borderRadius: 'var(--radius-hairline)', background: CLUSTER_DOT[c], flexShrink: 0 }} />
{t(`cluster.${c}`)}
<span style={{ fontFamily: 'var(--mono)', fontSize: 10, opacity: 0.7 }}>{clusterTotals[c]}</span>
</span>
))}
</div>
</div>
);
}
13 changes: 1 addition & 12 deletions dashboard/src/components/MemoriesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import { Chip } from './Chip';
import { ExpandedBody, SeverityBadge } from './LessonCards';
import { t, getLocale } from '../lib/i18n';
import { actionFailureMessage, classifyLoadError, failureMessage } from '../lib/failure';
import { clusterOf, timeBucket, extractProject, type TypeCluster, type TimeBucket } from '../lib/entity-display';
import { CATEGORICAL_TYPE_COLORS } from '../lib/type-palette';
import { clusterOf, timeBucket, extractProject, CLUSTER_DOT, type TypeCluster, type TimeBucket } from '../lib/entity-display';
import { useSignalMode } from '../lib/signalMode';
import { layerOf } from '../../../src/core/work-topology.js';

Expand Down Expand Up @@ -41,16 +40,6 @@ type SortKey = 'recent' | 'most-recalled' | 'created';

const CLUSTERS: TypeCluster[] = ['knowledge', 'activity', 'session', 'reference'];

/** Composition-bar swatches: each cluster wears its representative
* species' colour (same palette the graph draws with, so the bar and the
* graph tell one story). */
const CLUSTER_DOT: Record<TypeCluster, string> = {
knowledge: CATEGORICAL_TYPE_COLORS.lesson_learned,
activity: CATEGORICAL_TYPE_COLORS.commit,
session: CATEGORICAL_TYPE_COLORS.session_keypoint,
reference: CATEGORICAL_TYPE_COLORS.note,
};

function isArchivedEntity(e: Entity): boolean {
return Boolean(e.archived) || e.status === 'archived';
}
Expand Down
Loading
Loading