This commit is contained in:
Kurvaz 2026-01-08 07:46:52 -07:00
parent a89a345d08
commit 3c256cd591
18 changed files with 685 additions and 26 deletions

View file

@ -0,0 +1,3 @@
-- Add time_tracker column to stories for tracking story progression time
-- Stores JSON with years, days, hours, minutes fields
ALTER TABLE stories ADD COLUMN time_tracker TEXT;

View file

@ -0,0 +1,2 @@
-- Add time_tracker_snapshot column to checkpoints for time tracking restoration
ALTER TABLE checkpoints ADD COLUMN time_tracker_snapshot TEXT;

View file

@ -0,0 +1,4 @@
-- Add start_time and end_time columns to chapters for timeline tracking
-- Stores JSON with years, days, hours, minutes fields
ALTER TABLE chapters ADD COLUMN start_time TEXT;
ALTER TABLE chapters ADD COLUMN end_time TEXT;

View file

@ -52,6 +52,24 @@ pub fn run() {
sql: include_str!("../migrations/007_story_style_review_state.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 8,
description: "add_story_time_tracker",
sql: include_str!("../migrations/008_story_time_tracker.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 9,
description: "add_checkpoint_time_tracker",
sql: include_str!("../migrations/009_checkpoint_time_tracker.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 10,
description: "add_chapter_time_fields",
sql: include_str!("../migrations/010_chapter_time_fields.sql"),
kind: MigrationKind::Up,
},
];
tauri::Builder::default()

View file

@ -1,8 +1,9 @@
<script lang="ts">
import { ui, type DebugLogEntry } from '$lib/stores/ui.svelte';
import { X, ArrowUpCircle, ArrowDownCircle, Trash2, Copy, Check } from 'lucide-svelte';
import { X, ArrowUpCircle, ArrowDownCircle, Trash2, Copy, Check, WrapText } from 'lucide-svelte';
let copiedId = $state<string | null>(null);
let renderNewlines = $state(false);
function formatTimestamp(timestamp: number): string {
return new Date(timestamp).toLocaleTimeString('en-US', {
@ -22,7 +23,12 @@
function formatJson(data: Record<string, unknown>): string {
try {
return JSON.stringify(data, null, 2);
let json = JSON.stringify(data, null, 2);
if (renderNewlines) {
// Replace escaped newlines with actual newlines in string values
json = json.replace(/\\n/g, '\n');
}
return json;
} catch {
return String(data);
}
@ -96,6 +102,13 @@
</span>
</div>
<div class="flex items-center gap-2">
<button
class="btn-ghost rounded-lg p-2 {renderNewlines ? 'text-blue-400' : 'text-surface-400 hover:text-surface-200'}"
onclick={() => renderNewlines = !renderNewlines}
title={renderNewlines ? 'Show escaped newlines (\\n)' : 'Render newlines as line breaks'}
>
<WrapText class="h-4 w-4" />
</button>
<button
class="btn-ghost rounded-lg p-2 text-surface-400 hover:text-red-400"
onclick={handleClearLogs}

View file

@ -1,11 +1,12 @@
<script lang="ts">
import { ui } from '$lib/stores/ui.svelte';
import { story } from '$lib/stores/story.svelte';
import { Users, MapPin, Backpack, Scroll } from 'lucide-svelte';
import { Users, MapPin, Backpack, Scroll, Clock } from 'lucide-svelte';
import CharacterPanel from '$lib/components/world/CharacterPanel.svelte';
import LocationPanel from '$lib/components/world/LocationPanel.svelte';
import InventoryPanel from '$lib/components/world/InventoryPanel.svelte';
import QuestPanel from '$lib/components/world/QuestPanel.svelte';
import TimePanel from '$lib/components/world/TimePanel.svelte';
import { swipe } from '$lib/utils/swipe';
const tabs = [
@ -13,6 +14,7 @@
{ id: 'locations' as const, icon: MapPin, label: 'Locations' },
{ id: 'inventory' as const, icon: Backpack, label: 'Inventory' },
{ id: 'quests' as const, icon: Scroll, label: 'Quests' },
{ id: 'time' as const, icon: Clock, label: 'Time' },
];
function handleSwipeLeft() {
@ -67,6 +69,8 @@
<InventoryPanel />
{:else if ui.sidebarTab === 'quests'}
<QuestPanel />
{:else if ui.sidebarTab === 'time'}
<TimePanel />
{/if}
</div>
</aside>

View file

@ -1698,6 +1698,29 @@
</div>
</div>
<!-- Chat History Truncation -->
<div class="mb-3">
<label class="mb-1 block text-xs font-medium text-surface-400">
Chat History Truncation: {settings.systemServicesSettings.classifier.chatHistoryTruncation === 0 ? 'None' : `${settings.systemServicesSettings.classifier.chatHistoryTruncation} words`}
</label>
<input
type="range"
min="0"
max="500"
step="25"
bind:value={settings.systemServicesSettings.classifier.chatHistoryTruncation}
onchange={() => settings.saveSystemServicesSettings()}
class="w-full h-2"
/>
<div class="flex justify-between text-xs text-surface-500">
<span>None</span>
<span>500 words</span>
</div>
<p class="text-xs text-surface-500 mt-1">
Max words per message in chat history sent to classifier. 0 = no truncation.
</p>
</div>
<!-- Provider Only -->
<div class="mb-3" class:opacity-50={settings.advancedRequestSettings.manualMode}>
<ProviderOnlySelector

View file

@ -243,6 +243,13 @@
// Create the chapter - use database method to handle deletions correctly
const chapterNumber = await story.getNextChapterNumber();
// Extract time range from entries' metadata
const firstEntry = chapterEntries[0];
const lastEntry = chapterEntries[chapterEntries.length - 1];
const startTime = firstEntry.metadata?.timeStart ?? null;
const endTime = lastEntry.metadata?.timeEnd ?? null;
const chapter: Chapter = {
id: crypto.randomUUID(),
storyId: story.currentStory.id,
@ -252,6 +259,8 @@
endEntryId: chapterEntries[chapterEntries.length - 1].id,
entryCount: chapterEntries.length,
summary: summary.summary,
startTime,
endTime,
keywords: summary.keywords,
characters: summary.characters,
locations: summary.locations,
@ -714,13 +723,18 @@
emitNarrativeResponse(narrationEntry.id, fullResponse);
// Phase 3: Classify the response to extract world state changes
// Pass visible entries so classifier can see full chat history with time data
// Filter out the current narration entry to avoid sending it twice (once in chatHistory, once as narrativeResponse)
log('Starting classification phase...');
try {
const chatHistoryEntries = story.visibleEntries.filter(e => e.id !== narrationEntry.id);
const classificationResult = await aiService.classifyResponse(
fullResponse,
userActionContent,
worldState,
currentStoryRef
currentStoryRef,
chatHistoryEntries,
currentStoryRef?.timeTracker
);
log('Classification complete', {
@ -746,6 +760,10 @@
console.warn('World state classification failed:', classifyError);
}
// Phase 4.1: Update narration entry with timeEnd after classification phase
// This runs regardless of classification success - timeEnd reflects current story time
await story.updateEntryTimeEnd(narrationEntry.id);
// Phase 5: Check if auto-summarization is needed (background, non-blocking)
if (story.memoryConfig.autoSummarize) {
checkAutoSummarize().catch(err => {
@ -860,7 +878,8 @@
content,
rawInput,
actionType,
wasRawActionChoice
wasRawActionChoice,
story.currentStory.timeTracker
);
}
@ -938,6 +957,7 @@
items: backup.items,
storyBeats: backup.storyBeats,
lorebookEntries: backup.lorebookEntries,
timeTracker: backup.timeTracker,
});
} else {
// Persistent restore - delete entries and entities created after backup
@ -959,6 +979,9 @@
} else {
log('Persistent stop restore: skipping entity cleanup (no ID snapshot)');
}
// Restore time tracker snapshot after persistent cleanup
await story.restoreTimeTrackerSnapshot(backup.timeTracker);
}
await tick();
@ -1072,6 +1095,7 @@
items: backup.items,
storyBeats: backup.storyBeats,
lorebookEntries: backup.lorebookEntries,
timeTracker: backup.timeTracker,
});
} else {
// Persistent restore (backup without full snapshots, but with entity IDs)
@ -1094,6 +1118,9 @@
} else {
log('Persistent restore: skipping entity cleanup (no ID snapshot)');
}
// Restore time tracker snapshot after persistent cleanup
await story.restoreTimeTrackerSnapshot(backup.timeTracker);
}
// Wait for state to sync

View file

@ -0,0 +1,172 @@
<script lang="ts">
import { story } from '$lib/stores/story.svelte';
import { Clock, Pencil, Check, X, RotateCcw } from 'lucide-svelte';
let isEditing = $state(false);
let editYears = $state(0);
let editDays = $state(0);
let editHours = $state(0);
let editMinutes = $state(0);
// Format the time display
function formatTime(years: number, days: number, hours: number, minutes: number): string {
const parts: string[] = [];
if (years > 0) parts.push(`${years} year${years !== 1 ? 's' : ''}`);
if (days > 0) parts.push(`${days} day${days !== 1 ? 's' : ''}`);
if (hours > 0) parts.push(`${hours} hour${hours !== 1 ? 's' : ''}`);
if (minutes > 0) parts.push(`${minutes} min.`);
return parts.length > 0 ? parts.join(', ') : 'No time elapsed';
}
function startEdit() {
const time = story.timeTracker;
editYears = time.years;
editDays = time.days;
editHours = time.hours;
editMinutes = time.minutes;
isEditing = true;
}
function cancelEdit() {
isEditing = false;
}
async function saveEdit() {
await story.setTimeTracker({
years: Math.max(0, Number(editYears) || 0),
days: Math.max(0, Number(editDays) || 0),
hours: Math.max(0, Number(editHours) || 0),
minutes: Math.max(0, Number(editMinutes) || 0),
});
isEditing = false;
}
async function resetTime() {
const confirmed = confirm('Reset time to zero? This cannot be undone.');
if (!confirmed) return;
await story.setTimeTracker({ years: 0, days: 0, hours: 0, minutes: 0 });
}
// Helper to pad numbers for display
function pad(n: number, width: number = 2): string {
return n.toString().padStart(width, '0');
}
</script>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="font-medium text-surface-200">Time</h3>
{#if !isEditing}
<div class="flex items-center gap-1">
<button
class="btn-ghost rounded p-1"
onclick={startEdit}
title="Edit time"
>
<Pencil class="h-4 w-4" />
</button>
<button
class="btn-ghost rounded p-1"
onclick={resetTime}
title="Reset time"
>
<RotateCcw class="h-4 w-4" />
</button>
</div>
{/if}
</div>
{#if isEditing}
<div class="card space-y-3">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="mb-1 block text-xs text-surface-400">Years</label>
<input
type="number"
bind:value={editYears}
min="0"
class="input text-sm"
/>
</div>
<div>
<label class="mb-1 block text-xs text-surface-400">Days</label>
<input
type="number"
bind:value={editDays}
min="0"
max="364"
class="input text-sm"
/>
</div>
<div>
<label class="mb-1 block text-xs text-surface-400">Hours</label>
<input
type="number"
bind:value={editHours}
min="0"
max="23"
class="input text-sm"
/>
</div>
<div>
<label class="mb-1 block text-xs text-surface-400">Minutes</label>
<input
type="number"
bind:value={editMinutes}
min="0"
max="59"
class="input text-sm"
/>
</div>
</div>
<p class="text-xs text-surface-500">
Time will be automatically normalized (60 min = 1 hour, etc.)
</p>
<div class="flex justify-end gap-2">
<button class="btn btn-secondary text-xs" onclick={cancelEdit}>
Cancel
</button>
<button class="btn btn-primary text-xs" onclick={saveEdit}>
Save
</button>
</div>
</div>
{:else}
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-full bg-surface-700 p-2">
<Clock class="h-5 w-5 text-accent-400" />
</div>
<div class="flex-1">
<div class="text-sm text-surface-200">
{formatTime(story.timeTracker.years, story.timeTracker.days, story.timeTracker.hours, story.timeTracker.minutes)}
</div>
</div>
</div>
<!-- Detailed time display -->
<div class="mt-4 grid grid-cols-4 gap-2 text-center">
<div class="rounded bg-surface-700/50 p-2">
<div class="text-lg font-medium text-surface-100">{story.timeTracker.years}</div>
<div class="text-xs text-surface-500">Years</div>
</div>
<div class="rounded bg-surface-700/50 p-2">
<div class="text-lg font-medium text-surface-100">{story.timeTracker.days}</div>
<div class="text-xs text-surface-500">Days</div>
</div>
<div class="rounded bg-surface-700/50 p-2">
<div class="text-lg font-medium text-surface-100">{pad(story.timeTracker.hours)}</div>
<div class="text-xs text-surface-500">Hours</div>
</div>
<div class="rounded bg-surface-700/50 p-2">
<div class="text-lg font-medium text-surface-100">{pad(story.timeTracker.minutes)}</div>
<div class="text-xs text-surface-500">Min.</div>
</div>
</div>
</div>
<p class="text-xs text-surface-500">
Time is tracked automatically as the story progresses. You can also edit it manually.
</p>
{/if}
</div>

View file

@ -1,5 +1,5 @@
import type { OpenAIProvider as OpenAIProvider } from './openrouter';
import type { Character, Location, Item, StoryBeat } from '$lib/types';
import type { Character, Location, Item, StoryBeat, StoryEntry, TimeTracker } from '$lib/types';
import { settings, type ClassifierSettings } from '$lib/stores/settings.svelte';
import { buildExtraBody } from './requestOverrides';
@ -100,6 +100,14 @@ export interface StoryBeatUpdate {
};
}
// Chat history entry with time metadata for classification
export interface ClassificationChatEntry {
role: 'user' | 'assistant';
content: string;
timeStart?: TimeTracker | null;
timeEnd?: TimeTracker | null;
}
// Context for classification
export interface ClassificationContext {
narrativeResponse: string;
@ -110,6 +118,10 @@ export interface ClassificationContext {
existingStoryBeats: StoryBeat[];
genre: string | null;
storyMode: 'adventure' | 'creative-writing';
// Chat history with time metadata for context-aware classification
chatHistory?: ClassificationChatEntry[];
// Current story time for reference
currentStoryTime?: TimeTracker | null;
}
export class ClassifierService {
@ -137,6 +149,10 @@ export class ClassifierService {
return this.settingsOverride?.systemPrompt ?? settings.systemServicesSettings.classifier.systemPrompt;
}
private get chatHistoryTruncation(): number {
return this.settingsOverride?.chatHistoryTruncation ?? settings.systemServicesSettings.classifier.chatHistoryTruncation ?? 100;
}
async classify(context: ClassificationContext): Promise<ClassificationResult> {
log('classify called', {
model: this.model,
@ -146,6 +162,8 @@ export class ClassifierService {
existingCharacters: context.existingCharacters.length,
existingLocations: context.existingLocations.length,
existingItems: context.existingItems.length,
chatHistoryEntries: context.chatHistory?.length ?? 0,
currentStoryTime: context.currentStoryTime,
});
const prompt = this.buildClassificationPrompt(context);
@ -197,6 +215,48 @@ export class ClassifierService {
}
}
private formatTime(time: TimeTracker | null | undefined): string {
if (!time) return 'unknown';
const parts: string[] = [];
if (time.years > 0) parts.push(`Year ${time.years}`);
if (time.days > 0) parts.push(`Day ${time.days}`);
if (time.hours > 0 || time.minutes > 0) {
const hour = time.hours.toString().padStart(2, '0');
const minute = time.minutes.toString().padStart(2, '0');
parts.push(`${hour}:${minute}`);
}
return parts.length > 0 ? parts.join(', ') : 'Day 0, 00:00';
}
private truncateToWords(text: string, maxWords: number): string {
if (maxWords <= 0) return text; // 0 = no truncation
const words = text.split(/\s+/);
if (words.length <= maxWords) return text;
return words.slice(0, maxWords).join(' ') + '...';
}
private buildChatHistoryBlock(chatHistory: ClassificationChatEntry[] | undefined): string {
if (!chatHistory || chatHistory.length === 0) return '';
const truncationLimit = this.chatHistoryTruncation;
const formattedEntries = chatHistory.map((entry, index) => {
const roleLabel = entry.role === 'user' ? 'USER' : 'ASSISTANT';
const timeInfo = entry.timeEnd ? ` [Story Time: ${this.formatTime(entry.timeEnd)}]` : '';
// Truncate by words (0 = no truncation)
const truncatedContent = this.truncateToWords(entry.content, truncationLimit);
return `[${index + 1}] ${roleLabel}${timeInfo}:\n${truncatedContent}`;
}).join('\n\n');
return `
## Chat History (with story time)
The following is the visible chat history. Use this to understand context and track when time was last advanced.
Each ASSISTANT message shows the story time AFTER that message (timeEnd).
${formattedEntries}
`;
}
private buildClassificationPrompt(context: ClassificationContext): string {
// Include traits for characters so the classifier can decide when to prune
const existingCharacterInfo = context.existingCharacters.map(c => {
@ -226,17 +286,26 @@ export class ClassifierService {
? "plot_point|revelation|milestone|event"
: "quest|revelation|milestone|event";
// Build chat history block if available
const chatHistoryBlock = this.buildChatHistoryBlock(context.chatHistory);
// Build current time reference
const currentTimeInfo = context.currentStoryTime
? `Current Story Time: ${this.formatTime(context.currentStoryTime)}`
: '';
return `Analyze this narrative passage and extract world state changes.
## Context
${context.genre ? `Genre: ${context.genre}` : ''}
Mode: ${isCreativeMode ? 'Creative Writing (author directing the story)' : 'Adventure (player as protagonist)'}
Already tracking: ${existingCharacterInfo.length} characters, ${existingLocationNames.length} locations, ${existingItemNames.length} items
${currentTimeInfo}
${chatHistoryBlock}
## ${inputLabel}
"${context.userAction}"
## The Narrative Response
## The Narrative Response (to classify)
"""
${context.narrativeResponse}
"""
@ -302,6 +371,11 @@ newStoryBeats: [{"title": "Short Title", "description": "what happened or was le
scene.currentLocationName: ${sceneLocationDesc}
scene.presentCharacterNames: Names of characters physically present in the scene
scene.timeProgression: How much time passed - "none", "minutes", "hours", or "days"
- Look at the chat history timestamps to see when time was last advanced
- If multiple messages have passed without time advancing, consider whether this narrative should advance time
- Actions like traveling, sleeping, waiting, or scene transitions typically warrant time progression
- Quick dialogue exchanges or immediate actions within the same scene may be "none"
- IMPORTANT: If 3 or more messages have passed without any time advancement, you should advance time by at least "minutes" - even casual dialogue takes time in the story world
Return valid JSON only. Empty arrays are fine - don't invent entities that aren't clearly in the text.`;
}

View file

@ -1,7 +1,7 @@
import { settings } from '$lib/stores/settings.svelte';
import { OpenAIProvider as OpenAIProvider } from './openrouter';
import { BUILTIN_TEMPLATES } from '$lib/services/templates';
import { ClassifierService, type ClassificationResult, type ClassificationContext } from './classifier';
import { ClassifierService, type ClassificationResult, type ClassificationContext, type ClassificationChatEntry } from './classifier';
import { MemoryService, type ChapterAnalysis, type ChapterSummary, type RetrievalDecision, DEFAULT_MEMORY_CONFIG } from './memory';
import { SuggestionsService, type StorySuggestion, type SuggestionsResult } from './suggestions';
import { ActionChoicesService, type ActionChoice, type ActionChoicesResult } from './actionChoices';
@ -13,7 +13,7 @@ import { ContextBuilder, type ContextResult, type ContextConfig, DEFAULT_CONTEXT
import { EntryRetrievalService, getEntryRetrievalConfigFromSettings, type EntryRetrievalResult, type ActivationTracker } from './entryRetrieval';
import { buildExtraBody } from './requestOverrides';
import type { Message, GenerationResponse, StreamChunk } from './types';
import type { Story, StoryEntry, Character, Location, Item, StoryBeat, Chapter, MemoryConfig, Entry, LoreManagementResult } from '$lib/types';
import type { Story, StoryEntry, Character, Location, Item, StoryBeat, Chapter, MemoryConfig, Entry, LoreManagementResult, TimeTracker } from '$lib/types';
const DEBUG = true;
@ -309,17 +309,31 @@ class AIService {
narrativeResponse: string,
userAction: string,
worldState: WorldState,
story?: Story | null
story?: Story | null,
visibleEntries?: StoryEntry[],
currentStoryTime?: TimeTracker | null
): Promise<ClassificationResult> {
log('classifyResponse called', {
responseLength: narrativeResponse.length,
userActionLength: userAction.length,
genre: story?.genre,
visibleEntriesCount: visibleEntries?.length ?? 0,
currentStoryTime,
});
const provider = this.getProviderForProfile(settings.systemServicesSettings.classifier.profileId);
const classifier = new ClassifierService(provider);
// Build chat history from visible entries with time metadata
const chatHistory: ClassificationChatEntry[] = (visibleEntries ?? [])
.filter(e => e.type === 'user_action' || e.type === 'narration')
.map(e => ({
role: e.type === 'user_action' ? 'user' as const : 'assistant' as const,
content: e.content,
timeStart: e.metadata?.timeStart ?? null,
timeEnd: e.metadata?.timeEnd ?? null,
}));
const context: ClassificationContext = {
narrativeResponse,
userAction,
@ -329,6 +343,8 @@ class AIService {
existingStoryBeats: worldState.storyBeats,
genre: story?.genre ?? null,
storyMode: story?.mode ?? 'adventure',
chatHistory,
currentStoryTime,
};
const result = await classifier.classify(context);

View file

@ -16,6 +16,7 @@ import type {
EntryPreview,
PersistentRetryState,
PersistentStyleReviewState,
TimeTracker,
} from '$lib/types';
class DatabaseService {
@ -85,9 +86,10 @@ class DatabaseService {
settings,
memory_config,
retry_state,
style_review_state
style_review_state,
time_tracker
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
story.id,
story.title,
@ -101,6 +103,7 @@ class DatabaseService {
story.memoryConfig ? JSON.stringify(story.memoryConfig) : null,
story.retryState ? JSON.stringify(story.retryState) : null,
story.styleReviewState ? JSON.stringify(story.styleReviewState) : null,
story.timeTracker ? JSON.stringify(story.timeTracker) : null,
]
);
return { ...story, createdAt: now, updatedAt: now };
@ -144,6 +147,10 @@ class DatabaseService {
setClauses.push('style_review_state = ?');
values.push(updates.styleReviewState ? JSON.stringify(updates.styleReviewState) : null);
}
if (updates.timeTracker !== undefined) {
setClauses.push('time_tracker = ?');
values.push(updates.timeTracker ? JSON.stringify(updates.timeTracker) : null);
}
values.push(id);
await db.execute(
@ -196,6 +203,28 @@ class DatabaseService {
);
}
/**
* Save time tracker for a story.
*/
async saveTimeTracker(storyId: string, timeTracker: TimeTracker): Promise<void> {
const db = await this.getDb();
await db.execute(
'UPDATE stories SET time_tracker = ? WHERE id = ?',
[JSON.stringify(timeTracker), storyId]
);
}
/**
* Clear time tracker for a story.
*/
async clearTimeTracker(storyId: string): Promise<void> {
const db = await this.getDb();
await db.execute(
'UPDATE stories SET time_tracker = NULL WHERE id = ?',
[storyId]
);
}
async deleteStory(id: string): Promise<void> {
const db = await this.getDb();
await db.execute('DELETE FROM stories WHERE id = ?', [id]);
@ -537,9 +566,9 @@ class DatabaseService {
await db.execute(
`INSERT INTO chapters (
id, story_id, number, title, start_entry_id, end_entry_id, entry_count,
summary, keywords, characters, locations, plot_threads, emotional_tone,
summary, start_time, end_time, keywords, characters, locations, plot_threads, emotional_tone,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
chapter.id,
chapter.storyId,
@ -549,6 +578,8 @@ class DatabaseService {
chapter.endEntryId,
chapter.entryCount,
chapter.summary,
chapter.startTime ? JSON.stringify(chapter.startTime) : null,
chapter.endTime ? JSON.stringify(chapter.endTime) : null,
JSON.stringify(chapter.keywords),
JSON.stringify(chapter.characters),
JSON.stringify(chapter.locations),
@ -566,6 +597,8 @@ class DatabaseService {
if (updates.title !== undefined) { setClauses.push('title = ?'); values.push(updates.title); }
if (updates.summary !== undefined) { setClauses.push('summary = ?'); values.push(updates.summary); }
if (updates.startTime !== undefined) { setClauses.push('start_time = ?'); values.push(updates.startTime ? JSON.stringify(updates.startTime) : null); }
if (updates.endTime !== undefined) { setClauses.push('end_time = ?'); values.push(updates.endTime ? JSON.stringify(updates.endTime) : null); }
if (updates.keywords !== undefined) { setClauses.push('keywords = ?'); values.push(JSON.stringify(updates.keywords)); }
if (updates.characters !== undefined) { setClauses.push('characters = ?'); values.push(JSON.stringify(updates.characters)); }
if (updates.locations !== undefined) { setClauses.push('locations = ?'); values.push(JSON.stringify(updates.locations)); }
@ -607,8 +640,8 @@ class DatabaseService {
`INSERT INTO checkpoints (
id, story_id, name, last_entry_id, last_entry_preview, entry_count,
entries_snapshot, characters_snapshot, locations_snapshot,
items_snapshot, story_beats_snapshot, chapters_snapshot, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
items_snapshot, story_beats_snapshot, chapters_snapshot, time_tracker_snapshot, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
checkpoint.id,
checkpoint.storyId,
@ -622,6 +655,7 @@ class DatabaseService {
JSON.stringify(checkpoint.itemsSnapshot),
JSON.stringify(checkpoint.storyBeatsSnapshot),
JSON.stringify(checkpoint.chaptersSnapshot),
checkpoint.timeTrackerSnapshot ? JSON.stringify(checkpoint.timeTrackerSnapshot) : null,
checkpoint.createdAt,
]
);
@ -915,6 +949,7 @@ class DatabaseService {
memoryConfig: row.memory_config ? JSON.parse(row.memory_config) : null,
retryState: row.retry_state ? JSON.parse(row.retry_state) : null,
styleReviewState: row.style_review_state ? JSON.parse(row.style_review_state) : null,
timeTracker: row.time_tracker ? JSON.parse(row.time_tracker) : null,
};
}
@ -1007,6 +1042,8 @@ class DatabaseService {
endEntryId: row.end_entry_id,
entryCount: row.entry_count,
summary: row.summary,
startTime: row.start_time ? JSON.parse(row.start_time) : null,
endTime: row.end_time ? JSON.parse(row.end_time) : null,
keywords: row.keywords ? JSON.parse(row.keywords) : [],
characters: row.characters ? JSON.parse(row.characters) : [],
locations: row.locations ? JSON.parse(row.locations) : [],
@ -1030,6 +1067,8 @@ class DatabaseService {
itemsSnapshot: row.items_snapshot ? JSON.parse(row.items_snapshot) : [],
storyBeatsSnapshot: row.story_beats_snapshot ? JSON.parse(row.story_beats_snapshot) : [],
chaptersSnapshot: row.chapters_snapshot ? JSON.parse(row.chapters_snapshot) : [],
// Use null when missing - old checkpoints without time tracking should reset time to null on restore
timeTrackerSnapshot: row.time_tracker_snapshot ? JSON.parse(row.time_tracker_snapshot) : null,
createdAt: row.created_at,
};
}

View file

@ -14,10 +14,49 @@ export interface AventuraExport {
storyBeats: StoryBeat[];
lorebookEntries?: Entry[]; // Added in v1.1.0
styleReviewState?: PersistentStyleReviewState | null; // Added in v1.2.0
// Note: story.timeTracker added in v1.3.0
}
// Version history for import compatibility
// v1.0.0 - Initial release
// v1.1.0 - Added lorebookEntries
// v1.2.0 - Added styleReviewState
// v1.3.0 - Added timeTracker to story, entry metadata (timeStart/timeEnd)
class ExportService {
private readonly VERSION = '1.2.0';
private readonly VERSION = '1.3.0';
/**
* Compare semantic versions. Returns:
* - negative if a < b
* - 0 if a === b
* - positive if a > b
*/
private compareVersions(a: string, b: string): number {
const partsA = a.split('.').map(Number);
const partsB = b.split('.').map(Number);
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
const partA = partsA[i] ?? 0;
const partB = partsB[i] ?? 0;
if (partA !== partB) return partA - partB;
}
return 0;
}
/**
* Log warnings for imports from older versions that may be missing features.
*/
private logVersionCompatibilityWarnings(importVersion: string): void {
if (this.compareVersions(importVersion, '1.1.0') < 0) {
console.warn(`[Import] File from v${importVersion} predates lorebook entries (v1.1.0). Lorebook will be empty.`);
}
if (this.compareVersions(importVersion, '1.2.0') < 0) {
console.warn(`[Import] File from v${importVersion} predates style review state (v1.2.0). Style analysis history will be empty.`);
}
if (this.compareVersions(importVersion, '1.3.0') < 0) {
console.warn(`[Import] File from v${importVersion} predates time tracking (v1.3.0). Time tracker will start at zero.`);
}
}
// Export to Aventura format (.avt - JSON)
async exportToAventura(
@ -210,6 +249,9 @@ class ExportService {
return { success: false, error: 'Invalid story file: The file contains no story entries.' };
}
// Log warnings for older export versions that may be missing newer features
this.logVersionCompatibilityWarnings(data.version);
// Generate new IDs to avoid conflicts
const oldToNewId = new Map<string, string>();
@ -229,6 +271,7 @@ class ExportService {
memoryConfig: data.story.memoryConfig || null,
retryState: null, // Clear retry state on import
styleReviewState: data.styleReviewState ?? null, // Restore style review state from export (v1.2.0+)
timeTracker: data.story.timeTracker ?? null, // Restore time tracker from export
};
await database.createStory(importedStory);

View file

@ -117,7 +117,7 @@ class SyncService {
]);
const exportData: AventuraExport = {
version: '1.2.0',
version: '1.3.0',
exportedAt: Date.now(),
story: storyData,
entries,

View file

@ -367,6 +367,7 @@ export interface ClassifierSettings {
reasoningEffort: ReasoningEffort;
providerOnly: string[];
manualBody: string;
chatHistoryTruncation: number; // Max words per chat history entry (0 = no truncation, up to 500)
}
export function getDefaultClassifierSettings(): ClassifierSettings {
@ -379,6 +380,7 @@ export function getDefaultClassifierSettings(): ClassifierSettings {
reasoningEffort: 'medium',
providerOnly: [],
manualBody: '',
chatHistoryTruncation: 100, // Default: truncate to 100 words per entry
};
}
@ -395,6 +397,7 @@ export function getDefaultClassifierSettingsForProvider(provider: ProviderPreset
reasoningEffort: 'medium',
providerOnly: [],
manualBody: '',
chatHistoryTruncation: 100, // Default: truncate to 100 words per entry
};
}

View file

@ -1,4 +1,4 @@
import type { Story, StoryEntry, Character, Location, Item, StoryBeat, Chapter, Checkpoint, MemoryConfig, StoryMode, StorySettings, Entry } from '$lib/types';
import type { Story, StoryEntry, Character, Location, Item, StoryBeat, Chapter, Checkpoint, MemoryConfig, StoryMode, StorySettings, Entry, TimeTracker } from '$lib/types';
import { database } from '$lib/services/database';
import { BUILTIN_TEMPLATES } from '$lib/services/templates';
import { ui } from './ui.svelte';
@ -99,6 +99,10 @@ class StoryStore {
return this.currentStory?.mode || 'adventure';
}
get timeTracker(): TimeTracker {
return this.currentStory?.timeTracker || { years: 0, days: 0, hours: 0, minutes: 0 };
}
get lastChapterEndIndex(): number {
if (this.chapters.length === 0) return 0;
@ -350,6 +354,7 @@ class StoryStore {
memoryConfig: DEFAULT_MEMORY_CONFIG,
retryState: null,
styleReviewState: null,
timeTracker: null,
});
this.allStories = [storyData, ...this.allStories];
@ -382,6 +387,7 @@ class StoryStore {
memoryConfig: DEFAULT_MEMORY_CONFIG,
retryState: null,
styleReviewState: null,
timeTracker: null,
});
this.allStories = [storyData, ...this.allStories];
@ -440,6 +446,7 @@ class StoryStore {
// Add opening scene as first narration entry
if (state.openingScene) {
const tokenCount = countTokens(state.openingScene);
const baseTime = storyData.timeTracker ?? { years: 0, days: 0, hours: 0, minutes: 0 };
await database.addStoryEntry({
id: crypto.randomUUID(),
storyId: storyData.id,
@ -447,7 +454,7 @@ class StoryStore {
content: state.openingScene,
parentId: null,
position: 0,
metadata: { source: 'template', tokenCount },
metadata: { source: 'template', tokenCount, timeStart: { ...baseTime }, timeEnd: { ...baseTime } },
});
}
}
@ -467,6 +474,13 @@ class StoryStore {
// Count tokens for accurate auto-summarize threshold detection
const tokenCount = countTokens(content);
// Capture current story time as timeStart for this entry
// timeEnd defaults to timeStart; for narration entries, timeEnd is updated after classification
const timeStart = this.currentStory.timeTracker
? { ...this.currentStory.timeTracker }
: { years: 0, days: 0, hours: 0, minutes: 0 };
const timeEnd = { ...timeStart };
const position = await database.getNextEntryPosition(this.currentStory.id);
const entry = await database.addStoryEntry({
id: crypto.randomUUID(),
@ -475,7 +489,7 @@ class StoryStore {
content,
parentId: null,
position,
metadata: { ...metadata, tokenCount },
metadata: { ...metadata, tokenCount, timeStart, timeEnd },
});
this.entries = [...this.entries, entry];
@ -515,6 +529,34 @@ class StoryStore {
await database.updateStory(this.currentStory.id, {});
}
/**
* Update an entry's timeEnd metadata after classification applies time progression.
* Called after applyClassificationResult to record the story time after the entry's events.
*/
async updateEntryTimeEnd(entryId: string): Promise<void> {
if (!this.currentStory) throw new Error('No story loaded');
const entry = this.entries.find(e => e.id === entryId);
if (!entry) {
log('updateEntryTimeEnd: Entry not found', entryId);
return;
}
// Capture current story time as timeEnd
const timeEnd = this.currentStory.timeTracker
? { ...this.currentStory.timeTracker }
: { years: 0, days: 0, hours: 0, minutes: 0 };
const updatedMetadata = { ...entry.metadata, timeEnd };
await database.updateStoryEntry(entryId, { metadata: updatedMetadata });
this.entries = this.entries.map(e =>
e.id === entryId ? { ...e, metadata: updatedMetadata } : e
);
log('Entry timeEnd updated', { entryId, timeEnd });
}
/**
* Delete all entries from a given position onward.
* Used for entry-only retry restore (persistent retry).
@ -1214,6 +1256,11 @@ class StoryStore {
}
}
// Apply time progression from scene data
if (result.scene.timeProgression && result.scene.timeProgression !== 'none') {
await this.applyTimeProgression(result.scene.timeProgression);
}
log('applyClassificationResult complete', {
characters: this.characters.length,
locations: this.locations.length,
@ -1349,6 +1396,128 @@ class StoryStore {
log('Memory config updated via updateMemoryConfig:', updates);
}
/**
* Normalize time values, converting overflow/underflow between units.
* Handles both positive overflow (60 min 1 hour) and negative underflow (borrowing).
* 60 minutes 1 hour, 24 hours 1 day, 365 days 1 year
*/
private normalizeTime(time: TimeTracker): TimeTracker {
let { years, days, hours, minutes } = time;
// Handle negative minutes by borrowing from hours
while (minutes < 0 && hours > 0) {
hours -= 1;
minutes += 60;
}
// Handle negative hours by borrowing from days
while (hours < 0 && days > 0) {
days -= 1;
hours += 24;
}
// Handle negative days by borrowing from years
while (days < 0 && years > 0) {
years -= 1;
days += 365;
}
// Clamp any remaining negatives to 0 (can't have negative time)
years = Math.max(0, years);
days = Math.max(0, days);
hours = Math.max(0, hours);
minutes = Math.max(0, minutes);
// Normalize overflow: minutes to hours
if (minutes >= 60) {
hours += Math.floor(minutes / 60);
minutes = minutes % 60;
}
// Normalize overflow: hours to days
if (hours >= 24) {
days += Math.floor(hours / 24);
hours = hours % 24;
}
// Normalize overflow: days to years
if (days >= 365) {
years += Math.floor(days / 365);
days = days % 365;
}
return { years, days, hours, minutes };
}
// Set time tracker directly
async setTimeTracker(time: TimeTracker): Promise<void> {
if (!this.currentStory) throw new Error('No story loaded');
const normalized = this.normalizeTime(time);
await database.saveTimeTracker(this.currentStory.id, normalized);
this.currentStory = { ...this.currentStory, timeTracker: normalized };
log('Time tracker set:', normalized);
}
// Update time tracker with partial values (adds to current time)
async addTime(updates: Partial<TimeTracker>): Promise<void> {
if (!this.currentStory) throw new Error('No story loaded');
const current = this.timeTracker;
const newTime: TimeTracker = {
years: current.years + (updates.years ?? 0),
days: current.days + (updates.days ?? 0),
hours: current.hours + (updates.hours ?? 0),
minutes: current.minutes + (updates.minutes ?? 0),
};
const normalized = this.normalizeTime(newTime);
await database.saveTimeTracker(this.currentStory.id, normalized);
this.currentStory = { ...this.currentStory, timeTracker: normalized };
log('Time added:', updates, '→', normalized);
}
/**
* Apply time progression from classifier result.
* Adds a default amount based on the progression type.
*/
async applyTimeProgression(progression: 'none' | 'minutes' | 'hours' | 'days'): Promise<void> {
if (progression === 'none') return;
// Default increments for each progression type
const increments: Record<string, Partial<TimeTracker>> = {
minutes: { minutes: 15 }, // ~15 minutes for minor actions
hours: { hours: 2 }, // ~2 hours for moderate time passage
days: { days: 1 }, // 1 day for significant time jumps
};
const increment = increments[progression];
if (increment) {
await this.addTime(increment);
}
}
/**
* Restore or clear the story time tracker from a snapshot.
* Undefined means "skip", null means "clear".
*/
async restoreTimeTrackerSnapshot(snapshot: TimeTracker | null | undefined): Promise<void> {
if (!this.currentStory) throw new Error('No story loaded');
if (snapshot === undefined) return;
if (snapshot === null) {
await database.clearTimeTracker(this.currentStory.id);
this.currentStory = { ...this.currentStory, timeTracker: null };
log('Time tracker cleared from snapshot');
return;
}
const normalized = this.normalizeTime(snapshot);
await database.saveTimeTracker(this.currentStory.id, normalized);
this.currentStory = { ...this.currentStory, timeTracker: normalized };
log('Time tracker restored from snapshot:', normalized);
}
// Create a manual chapter at a specific entry index
async createManualChapter(endEntryIndex: number): Promise<void> {
if (!this.currentStory) throw new Error('No story loaded');
@ -1379,6 +1548,12 @@ class StoryStore {
// Get the next chapter number
const chapterNumber = await this.getNextChapterNumber();
// Extract time range from entries' metadata
const firstEntry = chapterEntries[0];
const lastEntry = chapterEntries[chapterEntries.length - 1];
const startTime = firstEntry.metadata?.timeStart ?? null;
const endTime = lastEntry.metadata?.timeEnd ?? null;
// Create the chapter
const chapter: Chapter = {
id: crypto.randomUUID(),
@ -1389,6 +1564,8 @@ class StoryStore {
endEntryId: chapterEntries[chapterEntries.length - 1].id,
entryCount: chapterEntries.length,
summary: chapterData.summary,
startTime,
endTime,
keywords: chapterData.keywords,
characters: chapterData.characters,
locations: chapterData.locations,
@ -1421,6 +1598,7 @@ class StoryStore {
itemsSnapshot: [...this.items],
storyBeatsSnapshot: [...this.storyBeats],
chaptersSnapshot: [...this.chapters],
timeTrackerSnapshot: this.currentStory.timeTracker ? { ...this.currentStory.timeTracker } : null,
createdAt: Date.now(),
};
@ -1455,6 +1633,9 @@ class StoryStore {
// Sort chapters by number to ensure correct ordering
this.chapters = [...checkpoint.chaptersSnapshot].sort((a, b) => a.number - b.number);
// Restore time tracker (null clears)
await this.restoreTimeTrackerSnapshot(checkpoint.timeTrackerSnapshot);
log('Checkpoint restored');
// Emit event
@ -1480,6 +1661,7 @@ class StoryStore {
items: Item[];
storyBeats: StoryBeat[];
lorebookEntries: Entry[];
timeTracker?: TimeTracker | null;
}): Promise<void> {
if (!this.currentStory) throw new Error('No story loaded');
@ -1507,6 +1689,9 @@ class StoryStore {
this.storyBeats = [...backup.storyBeats];
this.lorebookEntries = [...backup.lorebookEntries];
// Restore time tracker if provided (null clears)
await this.restoreTimeTrackerSnapshot(backup.timeTracker);
log('Retry backup restored', {
entries: this.entries.length,
characters: this.characters.length,
@ -1565,6 +1750,7 @@ class StoryStore {
memoryConfig: DEFAULT_MEMORY_CONFIG,
retryState: null,
styleReviewState: null,
timeTracker: null,
});
this.allStories = [storyData, ...this.allStories];
@ -1638,6 +1824,7 @@ class StoryStore {
// Add opening scene as first narration entry
if (data.openingScene) {
const tokenCount = countTokens(data.openingScene);
const baseTime = storyData.timeTracker ?? { years: 0, days: 0, hours: 0, minutes: 0 };
await database.addStoryEntry({
id: crypto.randomUUID(),
storyId,
@ -1645,7 +1832,7 @@ class StoryStore {
content: data.openingScene,
parentId: null,
position: 0,
metadata: { source: 'wizard', tokenCount },
metadata: { source: 'wizard', tokenCount, timeStart: { ...baseTime }, timeEnd: { ...baseTime } },
});
log('Added opening scene');
}

View file

@ -1,4 +1,4 @@
import type { ActivePanel, SidebarTab, UIState, EntryType, StoryEntry, Character, Location, Item, StoryBeat, Entry, ActionInputType, PersistentStyleReviewState, PersistentStyleReviewResult } from '$lib/types';
import type { ActivePanel, SidebarTab, UIState, EntryType, StoryEntry, Character, Location, Item, StoryBeat, Entry, ActionInputType, PersistentStyleReviewState, PersistentStyleReviewResult, TimeTracker } from '$lib/types';
import type { ActionChoice } from '$lib/services/ai/actionChoices';
import type { StorySuggestion } from '$lib/services/ai/suggestions';
import type { StyleReviewResult } from '$lib/services/ai/styleReviewer';
@ -51,6 +51,8 @@ export interface RetryBackup {
itemIds: string[];
storyBeatIds: string[];
lorebookEntryIds: string[];
// Time tracker snapshot (undefined means "don't restore", null means "clear it")
timeTracker: TimeTracker | null | undefined;
}
// Error state for retry functionality
@ -275,7 +277,8 @@ class UIStore {
userActionContent: string,
rawInput: string,
actionType: ActionInputType,
wasRawActionChoice: boolean
wasRawActionChoice: boolean,
timeTracker: TimeTracker | null
) {
const timestamp = Date.now();
const nextEntryPosition = entries.reduce((max, entry) => Math.max(max, entry.position ?? -1), -1) + 1;
@ -315,6 +318,8 @@ class UIStore {
itemIds,
storyBeatIds,
lorebookEntryIds,
// Time tracker snapshot
timeTracker: timeTracker ? { ...timeTracker } : null,
};
this.retryBackups.set(storyId, backup);
this.currentRetryStoryId = storyId;
@ -333,6 +338,7 @@ class UIStore {
itemIds,
storyBeatIds,
lorebookEntryIds,
timeTracker: timeTracker ? { ...timeTracker } : null,
}),
'persist'
);
@ -394,6 +400,7 @@ class UIStore {
itemIds?: string[];
storyBeatIds?: string[];
lorebookEntryIds?: string[];
timeTracker?: TimeTracker | null;
}) {
// Skip if we already have an in-memory backup for this story (it's more complete)
if (this.retryBackups.has(storyId)) {
@ -448,6 +455,10 @@ class UIStore {
itemIds: retryState.itemIds ?? [],
storyBeatIds: retryState.storyBeatIds ?? [],
lorebookEntryIds: retryState.lorebookEntryIds ?? [],
// Time tracker snapshot (undefined means "skip restore", null means "clear")
timeTracker: Object.prototype.hasOwnProperty.call(retryState, 'timeTracker')
? retryState.timeTracker ?? null
: undefined,
};
this.retryBackups.set(storyId, backup);
console.log('[UI] Retry backup loaded from persistent state', {

View file

@ -4,6 +4,14 @@ export type StoryMode = 'adventure' | 'creative-writing';
export type POV = 'first' | 'second' | 'third';
export type Tense = 'past' | 'present';
// Time tracking for story progression
export interface TimeTracker {
years: number;
days: number;
hours: number;
minutes: number;
}
export interface Story {
id: string;
title: string;
@ -17,6 +25,7 @@ export interface Story {
memoryConfig: MemoryConfig | null;
retryState: PersistentRetryState | null;
styleReviewState: PersistentStyleReviewState | null;
timeTracker: TimeTracker | null;
}
// Persistent retry state - lightweight version saved to database
@ -38,6 +47,8 @@ export interface PersistentRetryState {
itemIds: string[];
storyBeatIds: string[];
lorebookEntryIds: string[];
// Story time snapshot captured before the user action (optional for backwards compatibility)
timeTracker?: TimeTracker | null;
}
// Persistent style review state - saved per-story for style analysis tracking
@ -94,6 +105,9 @@ export interface EntryMetadata {
model?: string;
generationTime?: number;
source?: string;
// Story time tracking - captures in-story time at entry creation and after classification
timeStart?: TimeTracker; // Story time when this entry began
timeEnd?: TimeTracker; // Story time after classification applied time progression
}
export interface Character {
@ -174,6 +188,10 @@ export interface Chapter {
// Content
summary: string;
// Story time span covered by this chapter
startTime: TimeTracker | null;
endTime: TimeTracker | null;
// Retrieval optimization metadata
keywords: string[];
characters: string[]; // Character names mentioned
@ -202,6 +220,8 @@ export interface Checkpoint {
itemsSnapshot: Item[];
storyBeatsSnapshot: StoryBeat[];
chaptersSnapshot: Chapter[];
// Optional: undefined means "preserve current time" on restore (for backward compatibility)
timeTrackerSnapshot?: TimeTracker | null;
createdAt: number;
}
@ -395,7 +415,7 @@ export interface AgenticSession {
// UI State types
export type ActivePanel = 'story' | 'library' | 'settings' | 'templates' | 'lorebook' | 'memory';
export type SidebarTab = 'characters' | 'locations' | 'inventory' | 'quests';
export type SidebarTab = 'characters' | 'locations' | 'inventory' | 'quests' | 'time';
export interface UIState {
activePanel: ActivePanel;