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
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { openSettings } from '#lib/actions/open-settings.js';
import { shouldUseWorkspacePanelHistory } from '#lib/features/shell/focus-nav.js';
import { isReloadShortcut, matchesShortcut, type ShortcutAction } from '#lib/keyboard-shortcuts.js';
import { inboxStore } from '#lib/stores/inbox.svelte.js';
import { skillUpdatesStore } from '#lib/stores/skill-updates.svelte.js';
import { sessionStore } from '#lib/stores/session.svelte.js';
import { settingsStore } from '#lib/stores/settings.svelte.js';
import { shellStore } from '#lib/stores/shell.svelte.js';
Expand Down Expand Up @@ -218,6 +219,7 @@ export function createAppShellShortcuts(deps: {
case 'openSkillDrafts':
if (shellStore.settingsOpen) shellStore.closeSettings();
inboxStore.closeDrawer();
skillUpdatesStore.markSeen();
void goto(resolve('skills'));
return;
case 'openGallery':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@
class="nav-badge"
class:has-badge={skillDraftsStore.hasDrafts || skillUpdatesStore.hasUnseen}
class:active={page.url.pathname === '/skills' || page.url.pathname === '/skill-drafts'}
onclick={() => goto(resolve('skills'))}
onclick={() => {
skillUpdatesStore.markSeen();
void goto(resolve('skills'));
}}
><Sparkles size={16} stroke-width={1.8} /></button
>
</Tooltip>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import type { SkillsPageController } from '#lib/features/skills/skills-page-controller.svelte.js';
import type { SkillResource } from '#lib/types.js';
import { skillExpiryLabel } from '#lib/features/skills/skill-expiry.js';
import { skillUpdatesStore } from '#lib/stores/skill-updates.svelte.js';
import SkillsListPanel from './SkillsListPanel.svelte';
import SkillsListRow from './SkillsListRow.svelte';
Expand All @@ -14,9 +15,20 @@
: `${controller.filteredSkills.length} / ${controller.skills.length}`
);

function previewNotice(page: SkillsPageController): string {
const skill = page.selectedSkill?.skill;
if (!skill) return '';
if (!page.canEditSkill) return 'This bundled skill is read-only.';
if (skill.origin !== 'self-improvement') return '';
if (skill.pinned) return 'Pinned. This skill does not expire.';
return skillExpiryLabel(skill.expires_at, skill.status, Date.now(), skill.pinned);
}

function badgesFor(skill: SkillResource): string[] {
const badges: string[] = [];
if (skill.origin === 'self-improvement') badges.push('self-improvement');
const expiry = skillExpiryLabel(skill.expires_at, skill.status, Date.now(), skill.pinned);
if (expiry) badges.push(expiry);
if (skill.status === 'stale') badges.push('stale');
if (skill.pinned) badges.push('pinned');
if (skill.is_symlink) badges.push('symlink');
Expand Down Expand Up @@ -81,7 +93,7 @@
emptyLabel="Select a skill to preview it."
item={controller.selectedSkill?.skill ?? null}
path={controller.selectedSkill?.skill.path}
notice={controller.canEditSkill ? '' : 'This bundled skill is read-only.'}
notice={previewNotice(controller)}
readonly={!controller.canEditSkill}
bind:value={controller.skillContent}
>
Expand Down Expand Up @@ -129,8 +141,19 @@
<section class="archived-skills">
<h2>Archived</h2>
{#each controller.archived as skill (skill.name)}
{@const expiry = skillExpiryLabel(
skill.expires_at,
'archived',
Date.now(),
skill.pinned
)}
<div class="archived-row">
<span>{skill.name}</span>
<span>
{skill.name}
{#if expiry}
<span class="expiry">{expiry}</span>
{/if}
</span>
<button
type="button"
class="secondary"
Expand All @@ -149,6 +172,11 @@
margin-top: 12px;
}

.expiry {
margin-left: 8px;
color: var(--text-muted);
}

.archived-row {
display: flex;
align-items: center;
Expand Down
23 changes: 23 additions & 0 deletions cometline/src/lib/features/skills/skill-expiry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { skillExpiryLabel } from './skill-expiry';

const now = Date.parse('2026-10-08T00:00:00Z');
const day = 24 * 60 * 60 * 1000;

describe('skillExpiryLabel', () => {
it('says when an active skill archives', () => {
expect(skillExpiryLabel(now + 20 * day, 'active', now)).toBe('archives in 20 days');
expect(skillExpiryLabel(now + day, 'stale', now)).toBe('archives in 1 day');
});

it('says when an archived skill deletes', () => {
expect(skillExpiryLabel(now + 7 * day, 'archived', now)).toBe('deletes in 7 days');
expect(skillExpiryLabel(now - day, 'archived', now)).toBe('deletes today');
});

it('is empty when the skill does not expire', () => {
expect(skillExpiryLabel(undefined, 'active', now)).toBe('');
expect(skillExpiryLabel(0, 'active', now)).toBe('');
expect(skillExpiryLabel(now + day, 'active', now, true)).toBe('');
});
});
15 changes: 15 additions & 0 deletions cometline/src/lib/features/skills/skill-expiry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const dayMs = 24 * 60 * 60 * 1000;

export function skillExpiryLabel(
expiresAt: number | undefined,
status: string | undefined,
now = Date.now(),
pinned = false
): string {
if (!expiresAt || pinned) return '';
const days = Math.ceil((expiresAt - now) / dayMs);
const verb = status === 'archived' ? 'deletes' : 'archives';
if (days <= 0) return `${verb} today`;
if (days === 1) return `${verb} in 1 day`;
return `${verb} in ${days} days`;
}
4 changes: 4 additions & 0 deletions cometline/src/lib/generated/cometmind-api/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,10 @@ export type Skill = {
*/
status?: 'active' | 'stale' | 'archived';
pinned?: boolean;
/**
* Unix milliseconds when an unpinned self-improvement skill is archived or an archived one is deleted. Omitted when pinned or not self-improvement.
*/
expires_at?: number;
/**
* Optional tags from the skill frontmatter, used by the skills page filter.
*/
Expand Down
5 changes: 5 additions & 0 deletions cometline/src/lib/stores/skill-updates.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,10 @@ describe('skillUpdatesStore', () => {

skillUpdatesStore.note(['ship-checklist']);
expect(skillUpdatesStore.hasUnseen).toBe(false);

skillUpdatesStore.note(['ship-checklist', 'retry-pattern']);
expect(skillUpdatesStore.unseen).toEqual(['retry-pattern']);
skillUpdatesStore.markSeen();
expect(skillUpdatesStore.hasUnseen).toBe(false);
});
});
9 changes: 6 additions & 3 deletions cometmind/internal/apigen/types.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions cometmind/internal/server/skill_curator_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ func (a *App) handleListArchivedSkills(c *gin.Context) {
}
resource := a.skillResourceFromModel(skill)
resource.Status = skillpkg.CuratorStatusArchived
if life := a.curator.Lifecycle(c.Request.Context(), name); !life.ExpiresAt.IsZero() {
resource.ExpiresAt = life.ExpiresAt.UnixMilli()
}
items = append(items, resource)
}
c.JSON(http.StatusOK, gin.H{"skills": items})
Expand Down
8 changes: 7 additions & 1 deletion cometmind/internal/server/skill_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type skillResource struct {
Origin string `json:"origin,omitempty"`
Status string `json:"status,omitempty"`
Pinned bool `json:"pinned,omitempty"`
ExpiresAt int64 `json:"expires_at,omitempty"`
Tags []string `json:"tags,omitempty"`
}

Expand Down Expand Up @@ -199,7 +200,12 @@ func (a *App) skillResourceFromModel(skill skillpkg.Skill) skillResource {
Tags: skill.Tags,
}
if a != nil && a.curator != nil && skillpkg.IsSelfImprovement(skill) {
resource.Status, resource.Pinned = a.curator.Lookup(context.Background(), skill.Name)
life := a.curator.Lifecycle(context.Background(), skill.Name)
resource.Status = life.Status
resource.Pinned = life.Pinned
if !life.ExpiresAt.IsZero() {
resource.ExpiresAt = life.ExpiresAt.UnixMilli()
}
}
return resource
}
29 changes: 27 additions & 2 deletions cometmind/internal/skillcurator/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,13 +250,38 @@ func (s *Service) MarkArchived(ctx context.Context, name string) error {
return s.save(ctx, row)
}

// Lifecycle is the curator clock the skills page can show.
type Lifecycle struct {
Status string
Pinned bool
ExpiresAt time.Time
}

// Lookup returns status and pinned for a skill name.
func (s *Service) Lookup(ctx context.Context, name string) (status string, pinned bool) {
life := s.Lifecycle(ctx, name)
return life.Status, life.Pinned
}

// Lifecycle returns status, pin, and the next archive or delete time.
func (s *Service) Lifecycle(ctx context.Context, name string) Lifecycle {
row, err := s.q.GetSkillCuratorState(ctx, name)
if err != nil {
return "", false
return Lifecycle{}
}
skill := skills.CuratorSkill{
Name: row.SkillName,
Status: row.Status,
Pinned: row.Pinned != 0,
CreatedAt: unixMilli(row.CreatedAt),
LastUsedAt: unixMilli(row.LastUsedAt),
ArchivedAt: unixMilli(row.ArchivedAt),
}
return Lifecycle{
Status: row.Status,
Pinned: skill.Pinned,
ExpiresAt: skills.ExpiresAt(s.now(), skill),
}
return row.Status, row.Pinned != 0
}

// ArchivedNames lists archived curator rows.
Expand Down
26 changes: 26 additions & 0 deletions cometmind/internal/skills/curator.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,32 @@ func PlanCuratorTransitions(now time.Time, skills []CuratorSkill) []CuratorActio
return actions
}

// ExpiresAt is when an unpinned skill is archived, or an archived skill is deleted.
// Pinned skills and rows with no clock do not expire.
func ExpiresAt(now time.Time, skill CuratorSkill) time.Time {
if skill.Pinned {
return time.Time{}
}
status := skill.Status
if status == "" {
status = CuratorStatusActive
}
if status == CuratorStatusArchived {
if skill.ArchivedAt.IsZero() {
return time.Time{}
}
return skill.ArchivedAt.Add(CuratorDeleteAfter)
}
base := skill.LastUsedAt
if base.IsZero() {
base = skill.CreatedAt
}
if base.IsZero() {
return time.Time{}
}
return base.Add(CuratorArchiveAfter)
}

func unusedFor(now time.Time, skill CuratorSkill) time.Duration {
base := skill.LastUsedAt
if base.IsZero() {
Expand Down
24 changes: 24 additions & 0 deletions cometmind/internal/skills/curator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,30 @@ func TestPlanCuratorTransitions(t *testing.T) {
}
}

func TestExpiresAt(t *testing.T) {
now := time.Date(2026, 10, 8, 0, 0, 0, 0, time.UTC)
created := now.Add(-10 * 24 * time.Hour)
used := now.Add(-2 * 24 * time.Hour)
archived := now.Add(-3 * 24 * time.Hour)
tests := []struct {
name string
in CuratorSkill
want time.Time
}{
{name: "archive clock", in: CuratorSkill{Status: CuratorStatusActive, CreatedAt: created}, want: created.Add(CuratorArchiveAfter)},
{name: "use resets archive clock", in: CuratorSkill{Status: CuratorStatusStale, CreatedAt: created, LastUsedAt: used}, want: used.Add(CuratorArchiveAfter)},
{name: "pinned does not expire", in: CuratorSkill{Status: CuratorStatusActive, Pinned: true, CreatedAt: created}},
{name: "archived deletes later", in: CuratorSkill{Status: CuratorStatusArchived, CreatedAt: created, ArchivedAt: archived}, want: archived.Add(CuratorDeleteAfter)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ExpiresAt(now, tt.in); !got.Equal(tt.want) {
t.Fatalf("expires = %s, want %s", got, tt.want)
}
})
}
}

func TestCuratorPassDue(t *testing.T) {
now := time.Date(2026, 10, 8, 0, 0, 0, 0, time.UTC)
run, first, note := CuratorPassDue(now, time.Time{}, time.Time{}, false, true)
Expand Down
2 changes: 1 addition & 1 deletion cometmind/internal/tools/registry_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func (r *Registry) addSkillTools(surface ToolSurface, opt RegistryOptions) {
r.Add(ReadSkillDraft{})
}
if surface.SkillMut {
r.Add(WriteSkill{})
r.Add(WriteSkill{Events: opt.MemoryEvents})
r.Add(PromoteSkillDraft{})
}
}
Expand Down
19 changes: 17 additions & 2 deletions cometmind/internal/tools/skills/write_skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ package skills
import (
"context"
"encoding/json"
"strings"

"github.com/Cometline/cometline/cometmind/internal/event"
"github.com/Cometline/cometline/cometmind/internal/skills"
"github.com/Cometline/cometline/cometmind/internal/tools/toolkit"
)

// WriteSkill creates or updates an Agent Skill under ~/.cometmind/skills.
type WriteSkill struct{}
type WriteSkill struct {
Events *event.Hub
}

func (WriteSkill) Spec() ToolSpec {
return ToolSpec{
Expand All @@ -27,7 +31,7 @@ func (WriteSkill) Spec() ToolSpec {
}
}

func (WriteSkill) Execute(_ context.Context, input json.RawMessage) (Result, error) {
func (t WriteSkill) Execute(ctx context.Context, input json.RawMessage) (Result, error) {
var in struct {
Name *string `json:"name"`
Content *string `json:"content"`
Expand All @@ -47,5 +51,16 @@ func (WriteSkill) Execute(_ context.Context, input json.RawMessage) (Result, err
if err := skills.WriteSkill(name, content, in.Overwrite); err != nil {
return Result{OK: false, Output: err.Error()}, nil
}
if t.Events != nil {
action := "created"
if in.Overwrite {
action = "updated"
}
t.Events.Publish(event.SkillReviewUpdated(toolkit.ToolSessionFrom(ctx), []event.SkillReviewChange{{
Name: name,
Action: action,
Description: strings.TrimSpace(skills.SkillMarkdownDescription(content)),
}}))
}
return Result{OK: true, Output: "skill " + name + " written to ~/.cometmind/skills/" + name + "/SKILL.md"}, nil
}
Loading
Loading