Skip to content

Commit 486b10c

Browse files
feat: Add handoff versioning, decision history, and accurate tokenization
- Handoff versioning: Keep last 10 handoffs in .stackmemory/handoffs/ - Decision history: Archive decisions on clear, view with --history flag - Accurate tokens: Use @anthropic-ai/tokenizer instead of char estimation - Decision clear now archives to ~/.stackmemory/decision-history/
1 parent d7ffccb commit 486b10c

5 files changed

Lines changed: 252 additions & 6 deletions

File tree

package-lock.json

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
},
8383
"dependencies": {
8484
"@anthropic-ai/sdk": "^0.71.2",
85+
"@anthropic-ai/tokenizer": "^0.0.4",
8586
"@aws-sdk/client-s3": "^3.958.0",
8687
"@browsermcp/mcp": "^0.1.3",
8788
"@google-cloud/storage": "^7.18.0",

src/cli/commands/decision.ts

Lines changed: 139 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,16 @@
88
*/
99

1010
import { Command } from 'commander';
11-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
12-
import { join } from 'path';
11+
import {
12+
existsSync,
13+
readFileSync,
14+
writeFileSync,
15+
mkdirSync,
16+
readdirSync,
17+
} from 'fs';
18+
import { join, basename } from 'path';
19+
import { homedir } from 'os';
20+
import { createHash } from 'crypto';
1321

1422
interface Decision {
1523
id: string;
@@ -29,6 +37,94 @@ function getDecisionStorePath(projectRoot: string): string {
2937
return join(projectRoot, '.stackmemory', 'session-decisions.json');
3038
}
3139

40+
function getProjectId(projectRoot: string): string {
41+
const hash = createHash('sha256').update(projectRoot).digest('hex');
42+
return hash.slice(0, 12);
43+
}
44+
45+
function getHistoryDir(): string {
46+
return join(homedir(), '.stackmemory', 'decision-history');
47+
}
48+
49+
function archiveDecisions(projectRoot: string, decisions: Decision[]): void {
50+
if (decisions.length === 0) return;
51+
52+
const historyDir = getHistoryDir();
53+
const projectId = getProjectId(projectRoot);
54+
const projectDir = join(historyDir, projectId);
55+
56+
if (!existsSync(projectDir)) {
57+
mkdirSync(projectDir, { recursive: true });
58+
}
59+
60+
// Save with timestamp
61+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
62+
const archivePath = join(projectDir, `${timestamp}.json`);
63+
64+
const archive = {
65+
projectRoot,
66+
projectName: basename(projectRoot),
67+
archivedAt: new Date().toISOString(),
68+
decisions,
69+
};
70+
71+
writeFileSync(archivePath, JSON.stringify(archive, null, 2));
72+
}
73+
74+
interface HistoricalDecision extends Decision {
75+
projectName: string;
76+
archivedAt: string;
77+
}
78+
79+
function loadDecisionHistory(projectRoot?: string): HistoricalDecision[] {
80+
const historyDir = getHistoryDir();
81+
if (!existsSync(historyDir)) return [];
82+
83+
const allDecisions: HistoricalDecision[] = [];
84+
85+
try {
86+
const projectDirs = projectRoot
87+
? [getProjectId(projectRoot)]
88+
: readdirSync(historyDir);
89+
90+
for (const projectId of projectDirs) {
91+
const projectDir = join(historyDir, projectId);
92+
if (!existsSync(projectDir)) continue;
93+
94+
try {
95+
const files = readdirSync(projectDir).filter((f) =>
96+
f.endsWith('.json')
97+
);
98+
for (const file of files) {
99+
try {
100+
const content = JSON.parse(
101+
readFileSync(join(projectDir, file), 'utf-8')
102+
);
103+
for (const d of content.decisions || []) {
104+
allDecisions.push({
105+
...d,
106+
projectName: content.projectName || 'unknown',
107+
archivedAt: content.archivedAt,
108+
});
109+
}
110+
} catch {
111+
// Skip invalid files
112+
}
113+
}
114+
} catch {
115+
// Skip unreadable directories
116+
}
117+
}
118+
} catch {
119+
// History dir unreadable
120+
}
121+
122+
// Sort by timestamp descending
123+
return allDecisions.sort(
124+
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
125+
);
126+
}
127+
32128
function loadDecisions(projectRoot: string): DecisionStore {
33129
const storePath = getDecisionStorePath(projectRoot);
34130
if (existsSync(storePath)) {
@@ -104,10 +200,42 @@ export function createDecisionCommand(): Command {
104200
.command('list')
105201
.description('List all decisions from this session')
106202
.option('--json', 'Output as JSON')
203+
.option('--history', 'Include historical decisions')
204+
.option('--all', 'Show all projects (with --history)')
107205
.action((options) => {
108206
const projectRoot = process.cwd();
109207
const store = loadDecisions(projectRoot);
110208

209+
if (options.history) {
210+
const history = loadDecisionHistory(
211+
options.all ? undefined : projectRoot
212+
);
213+
214+
if (options.json) {
215+
console.log(JSON.stringify(history, null, 2));
216+
return;
217+
}
218+
219+
if (history.length === 0) {
220+
console.log('No decision history found.');
221+
return;
222+
}
223+
224+
console.log(`Decision History (${history.length}):\n`);
225+
for (const d of history.slice(0, 50)) {
226+
const category = d.category ? `[${d.category}] ` : '';
227+
const project = options.all ? `(${d.projectName}) ` : '';
228+
console.log(`${project}${category}${d.what}`);
229+
if (d.why) {
230+
console.log(` Rationale: ${d.why}`);
231+
}
232+
const date = new Date(d.timestamp).toLocaleDateString();
233+
console.log(` Date: ${date}`);
234+
console.log('');
235+
}
236+
return;
237+
}
238+
111239
if (options.json) {
112240
console.log(JSON.stringify(store.decisions, null, 2));
113241
return;
@@ -137,8 +265,9 @@ export function createDecisionCommand(): Command {
137265
// Clear decisions (for new session)
138266
cmd
139267
.command('clear')
140-
.description('Clear all decisions (start fresh session)')
268+
.description('Clear all decisions (archives to history first)')
141269
.option('--force', 'Skip confirmation')
270+
.option('--no-archive', 'Do not archive decisions')
142271
.action((options) => {
143272
const projectRoot = process.cwd();
144273
const store = loadDecisions(projectRoot);
@@ -150,10 +279,17 @@ export function createDecisionCommand(): Command {
150279

151280
if (!options.force) {
152281
console.log(`This will clear ${store.decisions.length} decisions.`);
282+
console.log('Decisions will be archived to history.');
153283
console.log('Use --force to confirm.');
154284
return;
155285
}
156286

287+
// Archive before clearing (unless --no-archive)
288+
if (options.archive !== false) {
289+
archiveDecisions(projectRoot, store.decisions);
290+
console.log(`Archived ${store.decisions.length} decisions to history.`);
291+
}
292+
157293
const newStore: DecisionStore = {
158294
decisions: [],
159295
sessionStart: new Date().toISOString(),

src/cli/commands/handoff.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@
44

55
import { Command } from 'commander';
66
import { execSync, execFileSync } from 'child_process';
7-
import { existsSync, readFileSync, writeFileSync } from 'fs';
7+
import {
8+
existsSync,
9+
readFileSync,
10+
writeFileSync,
11+
mkdirSync,
12+
readdirSync,
13+
unlinkSync,
14+
} from 'fs';
815
import { join } from 'path';
916
import Database from 'better-sqlite3';
1017
import { z } from 'zod';
@@ -13,6 +20,46 @@ import { LinearTaskManager } from '../../features/tasks/linear-task-manager.js';
1320
import { logger } from '../../core/monitoring/logger.js';
1421
import { EnhancedHandoffGenerator } from '../../core/session/enhanced-handoff.js';
1522

23+
// Handoff versioning - keep last N handoffs
24+
const MAX_HANDOFF_VERSIONS = 10;
25+
26+
function saveVersionedHandoff(
27+
projectRoot: string,
28+
branch: string,
29+
content: string
30+
): string {
31+
const handoffsDir = join(projectRoot, '.stackmemory', 'handoffs');
32+
if (!existsSync(handoffsDir)) {
33+
mkdirSync(handoffsDir, { recursive: true });
34+
}
35+
36+
// Generate versioned filename: YYYY-MM-DD-HH-mm-branch.md
37+
const now = new Date();
38+
const timestamp = now.toISOString().slice(0, 16).replace(/[T:]/g, '-');
39+
const safeBranch = branch.replace(/[^a-zA-Z0-9-]/g, '-').slice(0, 30);
40+
const filename = `${timestamp}-${safeBranch}.md`;
41+
const versionedPath = join(handoffsDir, filename);
42+
43+
// Save versioned handoff
44+
writeFileSync(versionedPath, content);
45+
46+
// Clean up old handoffs (keep last N)
47+
try {
48+
const files = readdirSync(handoffsDir)
49+
.filter((f) => f.endsWith('.md'))
50+
.sort()
51+
.reverse();
52+
53+
for (const oldFile of files.slice(MAX_HANDOFF_VERSIONS)) {
54+
unlinkSync(join(handoffsDir, oldFile));
55+
}
56+
} catch {
57+
// Cleanup failed, not critical
58+
}
59+
60+
return versionedPath;
61+
}
62+
1663
// Input validation schemas
1764
const CommitMessageSchema = z
1865
.string()
@@ -268,14 +315,33 @@ Generated by stackmemory handoff at ${timestamp}
268315
console.log(`Estimated tokens: ~${enhancedHandoff.estimatedTokens}`);
269316
}
270317

271-
// 7. Save handoff prompt
318+
// 7. Save handoff prompt (both latest and versioned)
272319
const handoffPath = join(
273320
projectRoot,
274321
'.stackmemory',
275322
'last-handoff.md'
276323
);
277324
writeFileSync(handoffPath, handoffPrompt);
278325

326+
// Save versioned copy
327+
let branch = 'unknown';
328+
try {
329+
branch = execSync('git rev-parse --abbrev-ref HEAD', {
330+
encoding: 'utf-8',
331+
cwd: projectRoot,
332+
}).trim();
333+
} catch {
334+
// Not a git repo
335+
}
336+
const versionedPath = saveVersionedHandoff(
337+
projectRoot,
338+
branch,
339+
handoffPrompt
340+
);
341+
console.log(
342+
`Versioned: ${versionedPath.split('/').slice(-2).join('/')}`
343+
);
344+
279345
// 8. Display the prompt
280346
console.log('\n' + '='.repeat(60));
281347
console.log(handoffPrompt);

src/core/session/enhanced-handoff.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ import { join, basename } from 'path';
1717
import { homedir, tmpdir } from 'os';
1818
import { globSync } from 'glob';
1919

20+
// Token counting - use Anthropic's tokenizer for accurate counts
21+
let countTokens: (text: string) => number;
22+
try {
23+
// Dynamic import for CommonJS compatibility
24+
const tokenizer = await import('@anthropic-ai/tokenizer');
25+
countTokens = tokenizer.countTokens;
26+
} catch {
27+
// Fallback to estimation if tokenizer not available
28+
countTokens = (text: string) => Math.ceil(text.length / 3.5);
29+
}
30+
2031
// Load session decisions if available
2132
interface SessionDecision {
2233
id: string;
@@ -256,7 +267,7 @@ export class EnhancedHandoffGenerator {
256267

257268
// Calculate estimated tokens
258269
const markdown = this.toMarkdown(handoff);
259-
handoff.estimatedTokens = Math.ceil(markdown.length / 3.5);
270+
handoff.estimatedTokens = countTokens(markdown);
260271

261272
return handoff;
262273
}

0 commit comments

Comments
 (0)