diff --git a/internal/modules/chunk/dnd/scenes/assets.go b/internal/modules/chunk/dnd/scenes/assets.go index ca0d75c..8fd77b0 100644 --- a/internal/modules/chunk/dnd/scenes/assets.go +++ b/internal/modules/chunk/dnd/scenes/assets.go @@ -2,5 +2,5 @@ package scenes import "embed" -//go:embed assets/schemas/*.json assets/scriptorium/prompts/*.yaml assets/scriptorium/prompts/dnd/scenes/*.md +//go:embed assets/schemas/*.json assets/prompts/*.yaml assets/prompts/*.md var embeddedAssets embed.FS diff --git a/internal/modules/chunk/dnd/scenes/assets/scriptorium/prompts/dnd.scenes.yaml b/internal/modules/chunk/dnd/scenes/assets/prompts/dnd.scenes.yaml similarity index 100% rename from internal/modules/chunk/dnd/scenes/assets/scriptorium/prompts/dnd.scenes.yaml rename to internal/modules/chunk/dnd/scenes/assets/prompts/dnd.scenes.yaml diff --git a/internal/modules/chunk/dnd/scenes/assets/scriptorium/prompts/dnd/scenes/instructions.md b/internal/modules/chunk/dnd/scenes/assets/prompts/instructions.md similarity index 100% rename from internal/modules/chunk/dnd/scenes/assets/scriptorium/prompts/dnd/scenes/instructions.md rename to internal/modules/chunk/dnd/scenes/assets/prompts/instructions.md diff --git a/internal/modules/chunk/dnd/scenes/assets/scriptorium/prompts/dnd/scenes/task.md b/internal/modules/chunk/dnd/scenes/assets/prompts/task.md similarity index 100% rename from internal/modules/chunk/dnd/scenes/assets/scriptorium/prompts/dnd/scenes/task.md rename to internal/modules/chunk/dnd/scenes/assets/prompts/task.md diff --git a/internal/modules/chunk/dnd/scenes/prompt_fs.go b/internal/modules/chunk/dnd/scenes/prompt_fs.go new file mode 100644 index 0000000..94a10eb --- /dev/null +++ b/internal/modules/chunk/dnd/scenes/prompt_fs.go @@ -0,0 +1,175 @@ +package scenes + +import ( + "bytes" + "fmt" + "io" + "io/fs" + "path" + "sort" + "strings" + "time" +) + +func modulePromptFS() (fs.FS, error) { + return promptMapFSFromEmbedded(map[string]string{ + "assets/prompts/dnd.scenes.yaml": "assets/prompts/dnd.scenes.yaml", + "assets/prompts/dnd/scenes/task.md": "assets/prompts/task.md", + "assets/prompts/dnd/scenes/instructions.md": "assets/prompts/instructions.md", + }) +} + +func promptMapFSFromEmbedded(files map[string]string) (fs.FS, error) { + assets := make(promptMapFS, len(files)) + for virtualPath, embeddedPath := range files { + data, err := fs.ReadFile(embeddedAssets, embeddedPath) + if err != nil { + return nil, fmt.Errorf("read prompt asset %s: %w", embeddedPath, err) + } + assets[virtualPath] = append([]byte(nil), data...) + } + return assets, nil +} + +type promptMapFS map[string][]byte + +func (m promptMapFS) Open(name string) (fs.File, error) { + cleaned, err := cleanPromptPath(name) + if err != nil { + return nil, &fs.PathError{Op: "open", Path: name, Err: err} + } + if data, ok := m[cleaned]; ok { + return &promptFile{ + reader: bytes.NewReader(data), + info: promptFileInfo{name: path.Base(cleaned), size: int64(len(data))}, + }, nil + } + entries := m.dirEntries(cleaned) + if entries != nil { + return &promptDir{name: path.Base(cleaned), entries: entries}, nil + } + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} +} + +func (m promptMapFS) ReadDir(name string) ([]fs.DirEntry, error) { + cleaned, err := cleanPromptPath(name) + if err != nil { + return nil, &fs.PathError{Op: "readdir", Path: name, Err: err} + } + entries := m.dirEntries(cleaned) + if entries == nil { + return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist} + } + return entries, nil +} + +func (m promptMapFS) dirEntries(dir string) []fs.DirEntry { + children := map[string]promptDirEntry{} + prefix := "" + if dir != "." { + prefix = dir + "/" + } + for name, data := range m { + if !strings.HasPrefix(name, prefix) { + continue + } + rest := strings.TrimPrefix(name, prefix) + if rest == "" { + continue + } + childName, _, hasSlash := strings.Cut(rest, "/") + entry := promptDirEntry{name: childName, dir: hasSlash} + if !hasSlash { + entry.size = int64(len(data)) + } + children[childName] = entry + } + if len(children) == 0 { + return nil + } + names := make([]string, 0, len(children)) + for name := range children { + names = append(names, name) + } + sort.Strings(names) + entries := make([]fs.DirEntry, 0, len(names)) + for _, name := range names { + entries = append(entries, children[name]) + } + return entries +} + +func cleanPromptPath(name string) (string, error) { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return "", fmt.Errorf("path must not be empty") + } + cleaned := path.Clean(strings.TrimPrefix(trimmed, "/")) + if cleaned == "." || !fs.ValidPath(cleaned) { + return "", fmt.Errorf("invalid path %q", name) + } + return cleaned, nil +} + +type promptFile struct { + reader *bytes.Reader + info promptFileInfo +} + +func (f *promptFile) Stat() (fs.FileInfo, error) { return f.info, nil } +func (f *promptFile) Read(p []byte) (int, error) { return f.reader.Read(p) } +func (f *promptFile) Close() error { return nil } + +type promptDir struct { + name string + offset int + entries []fs.DirEntry +} + +func (d *promptDir) Stat() (fs.FileInfo, error) { return promptFileInfo{name: d.name, dir: true}, nil } +func (d *promptDir) Read([]byte) (int, error) { return 0, io.EOF } +func (d *promptDir) Close() error { return nil } +func (d *promptDir) ReadDir(n int) ([]fs.DirEntry, error) { + if d.offset >= len(d.entries) { + return nil, io.EOF + } + end := len(d.entries) + if n > 0 && d.offset+n < end { + end = d.offset + n + } + out := append([]fs.DirEntry(nil), d.entries[d.offset:end]...) + d.offset = end + return out, nil +} + +type promptDirEntry struct { + name string + dir bool + size int64 +} + +func (e promptDirEntry) Name() string { return e.name } +func (e promptDirEntry) IsDir() bool { return e.dir } +func (e promptDirEntry) Type() fs.FileMode { return e.fileInfoMode().Type() } +func (e promptDirEntry) Info() (fs.FileInfo, error) { + return promptFileInfo{name: e.name, dir: e.dir, size: e.size}, nil +} +func (e promptDirEntry) fileInfoMode() fs.FileMode { + if e.dir { + return fs.ModeDir | 0o555 + } + return 0o444 +} + +type promptFileInfo struct { + name string + dir bool + size int64 +} + +func (i promptFileInfo) Name() string { return i.name } +func (i promptFileInfo) Size() int64 { return i.size } +func (i promptFileInfo) Mode() fs.FileMode { return promptDirEntry{dir: i.dir}.fileInfoMode() } +func (i promptFileInfo) ModTime() time.Time { return time.Time{} } +func (i promptFileInfo) IsDir() bool { return i.dir } +func (i promptFileInfo) Sys() any { return nil } diff --git a/internal/modules/chunk/dnd/scenes/scriptorium_assets.go b/internal/modules/chunk/dnd/scenes/scriptorium_assets.go index 827640e..f4cd5e8 100644 --- a/internal/modules/chunk/dnd/scenes/scriptorium_assets.go +++ b/internal/modules/chunk/dnd/scenes/scriptorium_assets.go @@ -11,10 +11,14 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets" ) -const scriptoriumPromptRoot = "assets/scriptorium/prompts" +const scriptoriumPromptRoot = "assets/prompts" func RegisterPromptAssets(registry *llm.AssetRegistry) error { - if err := registry.RegisterPromptFS(embeddedAssets, scriptoriumPromptRoot); err != nil { + promptFS, err := modulePromptFS() + if err != nil { + return fmt.Errorf("prepare scene prompt assets: %w", err) + } + if err := registry.RegisterPromptFS(promptFS, scriptoriumPromptRoot); err != nil { return err } return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas") @@ -48,9 +52,9 @@ func referencePromptMaterial(name string, slot contracts.ResolvedReferenceSlot) func scriptoriumPromptMetadata() (string, error) { scriptoriumPromptHashOnce.Do(func() { parts := append([]llm.AssetHashPart{ - {FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd.scenes.yaml"}, - {FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd/scenes/task.md"}, - {FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd/scenes/instructions.md"}, + {FS: embeddedAssets, Path: "assets/prompts/dnd.scenes.yaml"}, + {FS: embeddedAssets, Path: "assets/prompts/task.md"}, + {FS: embeddedAssets, Path: "assets/prompts/instructions.md"}, }, append(sharedassets.CommonHashParts(), sharedassets.ReferenceHashParts()...)...) scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts) }) diff --git a/internal/modules/extract/dnd/spells/assets.go b/internal/modules/extract/dnd/spells/assets.go index 7dae5af..8d9c3aa 100644 --- a/internal/modules/extract/dnd/spells/assets.go +++ b/internal/modules/extract/dnd/spells/assets.go @@ -2,5 +2,5 @@ package spells import "embed" -//go:embed assets/schemas/*.json assets/scriptorium/prompts/*.yaml assets/scriptorium/prompts/dnd/spells/*.md +//go:embed assets/schemas/*.json assets/prompts/*.yaml assets/prompts/*.md var embeddedAssets embed.FS diff --git a/internal/modules/extract/dnd/spells/assets/scriptorium/prompts/dnd.spells.yaml b/internal/modules/extract/dnd/spells/assets/prompts/dnd.spells.yaml similarity index 100% rename from internal/modules/extract/dnd/spells/assets/scriptorium/prompts/dnd.spells.yaml rename to internal/modules/extract/dnd/spells/assets/prompts/dnd.spells.yaml diff --git a/internal/modules/extract/dnd/spells/assets/scriptorium/prompts/dnd/spells/instructions.md b/internal/modules/extract/dnd/spells/assets/prompts/instructions.md similarity index 100% rename from internal/modules/extract/dnd/spells/assets/scriptorium/prompts/dnd/spells/instructions.md rename to internal/modules/extract/dnd/spells/assets/prompts/instructions.md diff --git a/internal/modules/extract/dnd/spells/assets/scriptorium/prompts/dnd/spells/task.md b/internal/modules/extract/dnd/spells/assets/prompts/task.md similarity index 100% rename from internal/modules/extract/dnd/spells/assets/scriptorium/prompts/dnd/spells/task.md rename to internal/modules/extract/dnd/spells/assets/prompts/task.md diff --git a/internal/modules/extract/dnd/spells/prompt_fs.go b/internal/modules/extract/dnd/spells/prompt_fs.go new file mode 100644 index 0000000..7253a26 --- /dev/null +++ b/internal/modules/extract/dnd/spells/prompt_fs.go @@ -0,0 +1,175 @@ +package spells + +import ( + "bytes" + "fmt" + "io" + "io/fs" + "path" + "sort" + "strings" + "time" +) + +func modulePromptFS() (fs.FS, error) { + return promptMapFSFromEmbedded(map[string]string{ + "assets/prompts/dnd.spells.yaml": "assets/prompts/dnd.spells.yaml", + "assets/prompts/dnd/spells/task.md": "assets/prompts/task.md", + "assets/prompts/dnd/spells/instructions.md": "assets/prompts/instructions.md", + }) +} + +func promptMapFSFromEmbedded(files map[string]string) (fs.FS, error) { + assets := make(promptMapFS, len(files)) + for virtualPath, embeddedPath := range files { + data, err := fs.ReadFile(embeddedAssets, embeddedPath) + if err != nil { + return nil, fmt.Errorf("read prompt asset %s: %w", embeddedPath, err) + } + assets[virtualPath] = append([]byte(nil), data...) + } + return assets, nil +} + +type promptMapFS map[string][]byte + +func (m promptMapFS) Open(name string) (fs.File, error) { + cleaned, err := cleanPromptPath(name) + if err != nil { + return nil, &fs.PathError{Op: "open", Path: name, Err: err} + } + if data, ok := m[cleaned]; ok { + return &promptFile{ + reader: bytes.NewReader(data), + info: promptFileInfo{name: path.Base(cleaned), size: int64(len(data))}, + }, nil + } + entries := m.dirEntries(cleaned) + if entries != nil { + return &promptDir{name: path.Base(cleaned), entries: entries}, nil + } + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} +} + +func (m promptMapFS) ReadDir(name string) ([]fs.DirEntry, error) { + cleaned, err := cleanPromptPath(name) + if err != nil { + return nil, &fs.PathError{Op: "readdir", Path: name, Err: err} + } + entries := m.dirEntries(cleaned) + if entries == nil { + return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist} + } + return entries, nil +} + +func (m promptMapFS) dirEntries(dir string) []fs.DirEntry { + children := map[string]promptDirEntry{} + prefix := "" + if dir != "." { + prefix = dir + "/" + } + for name, data := range m { + if !strings.HasPrefix(name, prefix) { + continue + } + rest := strings.TrimPrefix(name, prefix) + if rest == "" { + continue + } + childName, _, hasSlash := strings.Cut(rest, "/") + entry := promptDirEntry{name: childName, dir: hasSlash} + if !hasSlash { + entry.size = int64(len(data)) + } + children[childName] = entry + } + if len(children) == 0 { + return nil + } + names := make([]string, 0, len(children)) + for name := range children { + names = append(names, name) + } + sort.Strings(names) + entries := make([]fs.DirEntry, 0, len(names)) + for _, name := range names { + entries = append(entries, children[name]) + } + return entries +} + +func cleanPromptPath(name string) (string, error) { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return "", fmt.Errorf("path must not be empty") + } + cleaned := path.Clean(strings.TrimPrefix(trimmed, "/")) + if cleaned == "." || !fs.ValidPath(cleaned) { + return "", fmt.Errorf("invalid path %q", name) + } + return cleaned, nil +} + +type promptFile struct { + reader *bytes.Reader + info promptFileInfo +} + +func (f *promptFile) Stat() (fs.FileInfo, error) { return f.info, nil } +func (f *promptFile) Read(p []byte) (int, error) { return f.reader.Read(p) } +func (f *promptFile) Close() error { return nil } + +type promptDir struct { + name string + offset int + entries []fs.DirEntry +} + +func (d *promptDir) Stat() (fs.FileInfo, error) { return promptFileInfo{name: d.name, dir: true}, nil } +func (d *promptDir) Read([]byte) (int, error) { return 0, io.EOF } +func (d *promptDir) Close() error { return nil } +func (d *promptDir) ReadDir(n int) ([]fs.DirEntry, error) { + if d.offset >= len(d.entries) { + return nil, io.EOF + } + end := len(d.entries) + if n > 0 && d.offset+n < end { + end = d.offset + n + } + out := append([]fs.DirEntry(nil), d.entries[d.offset:end]...) + d.offset = end + return out, nil +} + +type promptDirEntry struct { + name string + dir bool + size int64 +} + +func (e promptDirEntry) Name() string { return e.name } +func (e promptDirEntry) IsDir() bool { return e.dir } +func (e promptDirEntry) Type() fs.FileMode { return e.fileInfoMode().Type() } +func (e promptDirEntry) Info() (fs.FileInfo, error) { + return promptFileInfo{name: e.name, dir: e.dir, size: e.size}, nil +} +func (e promptDirEntry) fileInfoMode() fs.FileMode { + if e.dir { + return fs.ModeDir | 0o555 + } + return 0o444 +} + +type promptFileInfo struct { + name string + dir bool + size int64 +} + +func (i promptFileInfo) Name() string { return i.name } +func (i promptFileInfo) Size() int64 { return i.size } +func (i promptFileInfo) Mode() fs.FileMode { return promptDirEntry{dir: i.dir}.fileInfoMode() } +func (i promptFileInfo) ModTime() time.Time { return time.Time{} } +func (i promptFileInfo) IsDir() bool { return i.dir } +func (i promptFileInfo) Sys() any { return nil } diff --git a/internal/modules/extract/dnd/spells/scriptorium_assets.go b/internal/modules/extract/dnd/spells/scriptorium_assets.go index d483300..9d394a8 100644 --- a/internal/modules/extract/dnd/spells/scriptorium_assets.go +++ b/internal/modules/extract/dnd/spells/scriptorium_assets.go @@ -11,10 +11,14 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets" ) -const scriptoriumPromptRoot = "assets/scriptorium/prompts" +const scriptoriumPromptRoot = "assets/prompts" func RegisterPromptAssets(registry *llm.AssetRegistry) error { - if err := registry.RegisterPromptFS(embeddedAssets, scriptoriumPromptRoot); err != nil { + promptFS, err := modulePromptFS() + if err != nil { + return fmt.Errorf("prepare spell prompt assets: %w", err) + } + if err := registry.RegisterPromptFS(promptFS, scriptoriumPromptRoot); err != nil { return err } return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas") @@ -48,9 +52,9 @@ func referencePromptMaterial(name string, slot contracts.ResolvedReferenceSlot) func scriptoriumPromptMetadata() (string, error) { scriptoriumPromptHashOnce.Do(func() { parts := append([]llm.AssetHashPart{ - {FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd.spells.yaml"}, - {FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd/spells/task.md"}, - {FS: embeddedAssets, Path: "assets/scriptorium/prompts/dnd/spells/instructions.md"}, + {FS: embeddedAssets, Path: "assets/prompts/dnd.spells.yaml"}, + {FS: embeddedAssets, Path: "assets/prompts/task.md"}, + {FS: embeddedAssets, Path: "assets/prompts/instructions.md"}, }, append(sharedassets.CommonHashParts(), sharedassets.ReferenceHashParts()...)...) scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts) })