mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
Merge pull request #71162 from NousResearch/bb/session-link-titles
feat(desktop): resolve @session links to titles you can click
This commit is contained in:
commit
5349c7c280
13 changed files with 843 additions and 34 deletions
|
|
@ -15,6 +15,7 @@ import {
|
|||
type SlashChipKind,
|
||||
slashIconElement
|
||||
} from '@/components/assistant-ui/directive-text'
|
||||
import { sessionRefFallbackLabel } from '@/lib/session-refs'
|
||||
|
||||
export const RICH_INPUT_SLOT = 'composer-rich-input'
|
||||
|
||||
|
|
@ -59,7 +60,9 @@ export function refChipHtml(kind: string, rawValue: string, displayLabel?: strin
|
|||
const id = unquoteRef(rawValue)
|
||||
const text = `@${kind}:${quoteRefValue(id)}`
|
||||
|
||||
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(displayLabel || refLabel(id))}</span></span>`
|
||||
const label = displayLabel || (kind === 'session' ? sessionRefFallbackLabel(id) : refLabel(id))
|
||||
|
||||
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(label)}</span></span>`
|
||||
}
|
||||
|
||||
export function refChipElement(kind: string, rawValue: string, displayLabel?: string) {
|
||||
|
|
@ -74,7 +77,7 @@ export function refChipElement(kind: string, rawValue: string, displayLabel?: st
|
|||
chip.dataset.refKind = kind
|
||||
chip.className = DIRECTIVE_CHIP_CLASS
|
||||
label.className = 'truncate'
|
||||
label.textContent = displayLabel || refLabel(id)
|
||||
label.textContent = displayLabel || (kind === 'session' ? sessionRefFallbackLabel(id) : refLabel(id))
|
||||
chip.append(directiveIconElement(kind), label)
|
||||
|
||||
return chip
|
||||
|
|
|
|||
|
|
@ -36,4 +36,14 @@ describe('hermesDirectiveFormatter.parse', () => {
|
|||
{ kind: 'text', text: ' the entry point' }
|
||||
])
|
||||
})
|
||||
|
||||
it('parses session links with profile/id values', () => {
|
||||
const segments = hermesDirectiveFormatter.parse('see @session:work/20260101_abc123 next')
|
||||
|
||||
expect(segments).toEqual([
|
||||
{ kind: 'text', text: 'see ' },
|
||||
{ kind: 'mention', type: 'session', label: '20260101…', id: 'work/20260101_abc123' },
|
||||
{ kind: 'text', text: ' next' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@ import { Fragment, useEffect, useMemo, useState } from 'react'
|
|||
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { extractEmbeddedImages } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { gatewayMediaDataUrl, isRemoteGateway } from '@/lib/media'
|
||||
import { useSessionLinkTitle } from '@/lib/session-link-title'
|
||||
import { parseSessionRefValue, sessionRefFallbackLabel } from '@/lib/session-refs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal', 'session'] as const
|
||||
type HermesRefType = (typeof HERMES_REF_TYPES)[number]
|
||||
|
|
@ -40,11 +44,7 @@ const ICON_PATHS: Record<HermesRefType, string[]> = {
|
|||
tool: ['M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5'],
|
||||
line: ['M5 9l14 0', 'M5 15l14 0', 'M11 4l-4 16', 'M17 4l-4 16'],
|
||||
terminal: ['M5 7l5 5l-5 5', 'M12 19l7 0'],
|
||||
session: [
|
||||
'M8 9h8',
|
||||
'M8 13h6',
|
||||
'M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3z'
|
||||
]
|
||||
session: ['M4 4h16v2.172a2 2 0 0 1 -.586 1.414l-4.414 4.414v7l-6 2v-8.5l-4.48 -4.928a2 2 0 0 1 -.52 -1.345v-2.227']
|
||||
}
|
||||
|
||||
const ICON_FALLBACK = ['M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', 'M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28']
|
||||
|
|
@ -123,9 +123,12 @@ export function slashIconElement(kind: SlashChipKind) {
|
|||
return iconElementFromPaths(SLASH_ICON_PATHS[kind])
|
||||
}
|
||||
|
||||
const DirectiveIcon: FC<{ type: string }> = ({ type }) => (
|
||||
const DirectiveIcon: FC<{ type: string; className?: string }> = ({
|
||||
type,
|
||||
className = 'size-3 shrink-0 opacity-80'
|
||||
}) => (
|
||||
<svg
|
||||
className="size-3 shrink-0 opacity-80"
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
|
|
@ -314,12 +317,8 @@ function shortLabel(type: HermesRefType, id: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
// `@session:<profile>/<id>` — show a short id; the composer chip carries the
|
||||
// friendly title, but once sent the wire form only has the id.
|
||||
if (type === 'session') {
|
||||
const sid = id.split('/').filter(Boolean).pop() || id
|
||||
|
||||
return sid.length > 10 ? `${sid.slice(0, 8)}…` : sid
|
||||
return sessionRefFallbackLabel(id)
|
||||
}
|
||||
|
||||
const tail = id.split(/[\\/]/).filter(Boolean).pop()
|
||||
|
|
@ -368,7 +367,9 @@ export function DirectiveContent({ text }: { text: string }) {
|
|||
{segments.map((segment, index) =>
|
||||
segment.kind === 'text' ? (
|
||||
<Fragment key={`t-${index}`}>{segment.text}</Fragment>
|
||||
) : segment.type === 'image' ? null : (
|
||||
) : segment.type === 'image' ? null : segment.type === 'session' ? (
|
||||
<SessionRefChip key={`m-${index}-${segment.id}`} label={segment.label} value={segment.id} />
|
||||
) : (
|
||||
<DirectiveChip id={segment.id} key={`m-${index}-${segment.id}`} label={segment.label} type={segment.type} />
|
||||
)
|
||||
)}
|
||||
|
|
@ -451,19 +452,87 @@ const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => {
|
|||
)
|
||||
}
|
||||
|
||||
/** Opens the referenced session as a tab — same as middle-clicking its sidebar
|
||||
* row. The tile store loads on click, not at import: the composer's rich
|
||||
* editor pulls this module in, and a static import would boot the profile
|
||||
* store (and its REST routing) along with it. */
|
||||
function openSessionRef(value: string) {
|
||||
const { sessionId } = parseSessionRefValue(value)
|
||||
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
triggerHaptic('selection')
|
||||
void import('@/store/session-states').then(({ openSessionTile }) => openSessionTile(sessionId, 'center'))
|
||||
}
|
||||
|
||||
/** A `@session:<profile>/<id>` reference in the user transcript (directive
|
||||
* segments), rendered as a chip like the other composer refs. Clicking it
|
||||
* opens the session as a tab. */
|
||||
export const SessionRefChip: FC<{
|
||||
label?: string
|
||||
value: string
|
||||
}> = ({ label, value }) => {
|
||||
const resolved = useSessionLinkTitle(value, label)
|
||||
|
||||
return <DirectiveChip id={value} label={resolved} onClick={() => openSessionRef(value)} type="session" />
|
||||
}
|
||||
|
||||
/** A `@session:` reference in assistant markdown (`#session/` links rewritten
|
||||
* in `preprocessMarkdown`). Reads as an ordinary inline link — the agent wrote
|
||||
* it mid-sentence — with the funnel icon leading the resolved title. */
|
||||
export const SessionRefLink: FC<{
|
||||
label?: string
|
||||
value: string
|
||||
}> = ({ label, value }) => {
|
||||
const resolved = useSessionLinkTitle(value, label)
|
||||
|
||||
return (
|
||||
<a
|
||||
className="font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere"
|
||||
href="#"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openSessionRef(value)
|
||||
}}
|
||||
title={value}
|
||||
>
|
||||
<DirectiveIcon className="mr-1 inline size-[0.82em] align-[-0.08em] opacity-70" type="session" />
|
||||
{resolved}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
/** Inert by default; `onClick` promotes the chip to a real button (session
|
||||
* refs, which open the session they name). */
|
||||
const DirectiveChip: FC<{
|
||||
type: string
|
||||
label: string
|
||||
id: string
|
||||
}> = ({ type, label, id }) => (
|
||||
<span
|
||||
className={DIRECTIVE_CHIP_CLASS}
|
||||
data-directive-id={id}
|
||||
data-directive-type={type}
|
||||
data-slot="aui_directive-chip"
|
||||
title={id}
|
||||
>
|
||||
<DirectiveIcon type={type} />
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
)
|
||||
onClick?: () => void
|
||||
}> = ({ type, label, id, onClick }) => {
|
||||
const body = (
|
||||
<>
|
||||
<DirectiveIcon type={type} />
|
||||
<span className="truncate">{label}</span>
|
||||
</>
|
||||
)
|
||||
|
||||
const props = {
|
||||
className: cn(DIRECTIVE_CHIP_CLASS, onClick && 'cursor-pointer transition-colors hover:text-foreground'),
|
||||
'data-directive-id': id,
|
||||
'data-directive-type': type,
|
||||
'data-slot': 'aui_directive-chip',
|
||||
title: id
|
||||
}
|
||||
|
||||
return onClick ? (
|
||||
<button {...props} onClick={onClick} type="button">
|
||||
{body}
|
||||
</button>
|
||||
) : (
|
||||
<span {...props}>{body}</span>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { __resetSessionLinkTitleCache } from '@/lib/session-link-title'
|
||||
import { $sessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { MarkdownTextContent } from './markdown-text'
|
||||
|
||||
function makeSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
return {
|
||||
ended_at: null,
|
||||
id: '20260101_abc123',
|
||||
input_tokens: 0,
|
||||
is_active: false,
|
||||
last_active: 1_000,
|
||||
message_count: 1,
|
||||
model: null,
|
||||
output_tokens: 0,
|
||||
preview: null,
|
||||
profile: 'work',
|
||||
source: 'cli',
|
||||
started_at: 1_000,
|
||||
title: 'Branch plan',
|
||||
tool_call_count: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$sessions.set([])
|
||||
__resetSessionLinkTitleCache()
|
||||
})
|
||||
|
||||
// End-to-end for the agent-authored path: a bare ref in assistant markdown has
|
||||
// to survive preprocessMarkdown -> Streamdown -> MarkdownLink and come out as
|
||||
// an inline link titled after the session, not as literal text.
|
||||
describe('MarkdownTextContent session refs', () => {
|
||||
it('renders an agent-written @session ref as a link showing the session title', async () => {
|
||||
$sessions.set([makeSession()])
|
||||
|
||||
render(<MarkdownTextContent isRunning={false} text="Context lives in @session:work/20260101_abc123 today." />)
|
||||
|
||||
const link = await screen.findByTitle('work/20260101_abc123')
|
||||
|
||||
expect(link.tagName).toBe('A')
|
||||
expect(link.textContent).toBe('Branch plan')
|
||||
expect(screen.queryByText(/@session:/)).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to a short id when the session is unknown', async () => {
|
||||
render(<MarkdownTextContent isRunning={false} text="See @session:work/20260101_abc123 for context." />)
|
||||
|
||||
const link = await screen.findByTitle('work/20260101_abc123')
|
||||
|
||||
expect(link.textContent).toBe('20260101…')
|
||||
})
|
||||
|
||||
it('leaves a ref inside inline code as literal text', () => {
|
||||
render(<MarkdownTextContent isRunning={false} text="Write `@session:work/20260101_abc123` to link a chat." />)
|
||||
|
||||
expect(screen.getByText('@session:work/20260101_abc123')).toBeTruthy()
|
||||
expect(screen.queryByTitle('work/20260101_abc123')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -30,8 +30,10 @@ import {
|
|||
resolveMediaDisplaySrc
|
||||
} from '@/lib/media'
|
||||
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
|
||||
import { sessionRefFromMarkdownHref } from '@/lib/session-refs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { SessionRefLink } from './directive-text'
|
||||
import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } from './embeds'
|
||||
|
||||
// Math rendering plugin (KaTeX). Configured once at module scope — the
|
||||
|
|
@ -233,6 +235,12 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a
|
|||
return <PreviewAttachment source="explicit-link" target={previewTarget} />
|
||||
}
|
||||
|
||||
const sessionRef = sessionRefFromMarkdownHref(href)
|
||||
|
||||
if (sessionRef) {
|
||||
return <SessionRefLink value={sessionRef} />
|
||||
}
|
||||
|
||||
const target = href ? normalizeExternalUrl(href) : href
|
||||
|
||||
if (!target || !/^https?:\/\//i.test(target)) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { __resetSessionLinkTitleCache } from '@/lib/session-link-title'
|
||||
|
||||
import { DirectiveContent } from './directive-text'
|
||||
import { MarkdownTextContent } from './markdown-text'
|
||||
|
||||
const openSessionTile = vi.fn()
|
||||
|
||||
vi.mock('@/store/session-states', () => ({
|
||||
openSessionTile: (...args: unknown[]) => openSessionTile(...args)
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
openSessionTile.mockClear()
|
||||
__resetSessionLinkTitleCache()
|
||||
})
|
||||
|
||||
// Both surfaces render a session ref differently — an inline link in agent
|
||||
// prose, a chip in the user's own message — but either one opens the session
|
||||
// it names, the way its sidebar row would.
|
||||
describe('session refs open the session', () => {
|
||||
it('opens the session from an agent-written link', async () => {
|
||||
render(<MarkdownTextContent isRunning={false} text="Picked up in @session:work/20260101_abc123 last night." />)
|
||||
|
||||
fireEvent.click(await screen.findByTitle('work/20260101_abc123'))
|
||||
|
||||
await vi.waitFor(() => expect(openSessionTile).toHaveBeenCalledWith('20260101_abc123', 'center'))
|
||||
})
|
||||
|
||||
it('opens the session from a chip in the user transcript', async () => {
|
||||
render(<DirectiveContent text="pick up @session:work/20260101_abc123 please" />)
|
||||
|
||||
const chip = screen.getByTitle('work/20260101_abc123')
|
||||
|
||||
expect(chip.tagName).toBe('BUTTON')
|
||||
fireEvent.click(chip)
|
||||
|
||||
await vi.waitFor(() => expect(openSessionTile).toHaveBeenCalledWith('20260101_abc123', 'center'))
|
||||
})
|
||||
})
|
||||
|
|
@ -2,6 +2,7 @@ import { normalizeMathDelimiters } from '@assistant-ui/react-streamdown'
|
|||
|
||||
import { isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code'
|
||||
import { stripPreviewTargets } from '@/lib/preview-targets'
|
||||
import { linkifySessionRefs } from '@/lib/session-refs'
|
||||
|
||||
const REASONING_BLOCK_RE = /<(think|thinking|reasoning|scratchpad|analysis)>[\s\S]*?<\/\1>\s*/gi
|
||||
const PREVIEW_MARKER_RE = /\[Preview:[^\]]+\]\(#preview[:/][^)]+\)/gi
|
||||
|
|
@ -149,8 +150,10 @@ function normalizeVisibleProse(text: string): string {
|
|||
.map(part =>
|
||||
part.startsWith('`')
|
||||
? part
|
||||
: autoLinkRawUrls(
|
||||
part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, '')
|
||||
: linkifySessionRefs(
|
||||
autoLinkRawUrls(
|
||||
part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, '')
|
||||
)
|
||||
)
|
||||
)
|
||||
.join('')
|
||||
|
|
|
|||
119
apps/desktop/src/lib/session-link-title.test.ts
Normal file
119
apps/desktop/src/lib/session-link-title.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getSession } from '@/hermes'
|
||||
import { $sessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import {
|
||||
__resetSessionLinkTitleCache,
|
||||
fetchSessionLinkTitle,
|
||||
lookupLocalSessionTitle
|
||||
} from './session-link-title'
|
||||
import { sessionRefCacheKey } from './session-refs'
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getSession: vi.fn()
|
||||
}))
|
||||
|
||||
function makeSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
return {
|
||||
ended_at: null,
|
||||
id: '20260101_abc123',
|
||||
input_tokens: 0,
|
||||
is_active: false,
|
||||
last_active: 1_000,
|
||||
message_count: 1,
|
||||
model: null,
|
||||
output_tokens: 0,
|
||||
preview: null,
|
||||
profile: 'default',
|
||||
source: 'cli',
|
||||
started_at: 1_000,
|
||||
title: 'Research notes',
|
||||
tool_call_count: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
__resetSessionLinkTitleCache()
|
||||
$sessions.set([])
|
||||
vi.mocked(getSession).mockReset()
|
||||
})
|
||||
|
||||
describe('lookupLocalSessionTitle', () => {
|
||||
it('reads from the in-memory session list', () => {
|
||||
$sessions.set([makeSession({ profile: 'work', title: 'Branch plan' })])
|
||||
|
||||
expect(lookupLocalSessionTitle('work/20260101_abc123')).toBe('Branch plan')
|
||||
})
|
||||
|
||||
it('matches the lineage root so a compressed chat still resolves', () => {
|
||||
$sessions.set([makeSession({ _lineage_root_id: '20260101_abc123', id: '20260102_tip', title: 'Compressed chat' })])
|
||||
|
||||
expect(lookupLocalSessionTitle('20260101_abc123')).toBe('Compressed chat')
|
||||
})
|
||||
|
||||
it('ignores a same-id row owned by another profile', () => {
|
||||
$sessions.set([makeSession({ profile: 'work', title: 'Work chat' })])
|
||||
|
||||
expect(lookupLocalSessionTitle('personal/20260101_abc123')).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty for an untitled row so the caller can fall back to the id', () => {
|
||||
$sessions.set([makeSession({ preview: null, title: null })])
|
||||
|
||||
expect(lookupLocalSessionTitle('default/20260101_abc123')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchSessionLinkTitle', () => {
|
||||
it('dedupes concurrent lookups', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(makeSession({ title: 'From API' }))
|
||||
|
||||
const value = 'default/20260101_abc123'
|
||||
const [first, second] = await Promise.all([fetchSessionLinkTitle(value), fetchSessionLinkTitle(value)])
|
||||
|
||||
expect(first).toBe('From API')
|
||||
expect(second).toBe('From API')
|
||||
expect(getSession).toHaveBeenCalledTimes(1)
|
||||
expect(getSession).toHaveBeenCalledWith('20260101_abc123', 'default')
|
||||
})
|
||||
|
||||
it('uses the local sidebar row before calling the API', async () => {
|
||||
$sessions.set([makeSession({ title: 'Cached title' })])
|
||||
|
||||
await expect(fetchSessionLinkTitle('default/20260101_abc123')).resolves.toBe('Cached title')
|
||||
expect(getSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps separate cache entries per profile', async () => {
|
||||
vi.mocked(getSession).mockImplementation(async (id, profile) =>
|
||||
makeSession({ id, profile: profile ?? 'default', title: profile === 'work' ? 'Work chat' : 'Home chat' })
|
||||
)
|
||||
|
||||
await expect(fetchSessionLinkTitle('default/20260101_abc123')).resolves.toBe('Home chat')
|
||||
await expect(fetchSessionLinkTitle('work/20260101_abc123')).resolves.toBe('Work chat')
|
||||
expect(sessionRefCacheKey('default/20260101_abc123')).not.toBe(sessionRefCacheKey('work/20260101_abc123'))
|
||||
})
|
||||
|
||||
it('falls back to the preview when the session has no title', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(makeSession({ preview: 'Summarize this repo', title: null }))
|
||||
|
||||
await expect(fetchSessionLinkTitle('20260101_abc123')).resolves.toBe('Summarize this repo')
|
||||
})
|
||||
|
||||
it('resolves empty when the id is not on this backend', async () => {
|
||||
vi.mocked(getSession).mockRejectedValue(new Error('Session not found'))
|
||||
|
||||
await expect(fetchSessionLinkTitle('default/missing')).resolves.toBe('')
|
||||
})
|
||||
|
||||
it('resolves empty when the desktop bridge is unavailable', async () => {
|
||||
vi.mocked(getSession).mockImplementation(() => {
|
||||
throw new TypeError("Cannot read properties of undefined (reading 'api')")
|
||||
})
|
||||
|
||||
await expect(fetchSessionLinkTitle('default/20260101_abc123')).resolves.toBe('')
|
||||
})
|
||||
})
|
||||
143
apps/desktop/src/lib/session-link-title.ts
Normal file
143
apps/desktop/src/lib/session-link-title.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
/**
|
||||
* Resolves `@session:<profile>/<id>` reference values to the session's title.
|
||||
*
|
||||
* Same shape as the external-link title resolver (`external-link.tsx`): a
|
||||
* process-lifetime cache, in-flight dedupe, and subscribers so every chip for
|
||||
* the same session repaints off one lookup. The sidebar list answers most
|
||||
* lookups for free; only an unknown id costs a REST round-trip.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { getSession } from '@/hermes'
|
||||
import { parseSessionRefValue, sessionRefCacheKey, sessionRefFallbackLabel } from '@/lib/session-refs'
|
||||
import { $sessions, sessionMatchesStoredId } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
const titleCache = new Map<string, string>()
|
||||
const titleInflight = new Map<string, Promise<string>>()
|
||||
const titleSubs = new Map<string, Set<(value: string) => void>>()
|
||||
|
||||
/** Deliberately not `sessionTitle()` from chat-runtime: its "Untitled session"
|
||||
* fallback is a worse chip label than the short id, so an untitled row
|
||||
* resolves to empty and the caller's fallback wins. */
|
||||
function sessionRowTitle(row: SessionInfo): string {
|
||||
return row.title?.trim() || row.preview?.trim() || ''
|
||||
}
|
||||
|
||||
function profileMatches(sessionProfile: null | string | undefined, target?: string): boolean {
|
||||
if (!target) {
|
||||
return true
|
||||
}
|
||||
|
||||
return ((sessionProfile ?? '').trim() || 'default') === (target.trim() || 'default')
|
||||
}
|
||||
|
||||
export function lookupLocalSessionTitle(value: string): string {
|
||||
const { profile, sessionId } = parseSessionRefValue(value)
|
||||
|
||||
if (!sessionId) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const row = $sessions
|
||||
.get()
|
||||
.find(session => sessionMatchesStoredId(session, sessionId) && profileMatches(session.profile, profile))
|
||||
|
||||
return row ? sessionRowTitle(row) : ''
|
||||
}
|
||||
|
||||
/** REST lookup that can't throw: the bridge is absent outside Electron, and a
|
||||
* session id that isn't on this backend 404s. Both mean "no title". */
|
||||
function requestSessionRow(sessionId: string, profile?: string): Promise<null | SessionInfo> {
|
||||
try {
|
||||
return Promise.resolve(getSession(sessionId, profile ?? null)).catch(() => null)
|
||||
} catch {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchSessionLinkTitle(value: string): Promise<string> {
|
||||
const key = sessionRefCacheKey(value)
|
||||
|
||||
if (!key) {
|
||||
return Promise.resolve('')
|
||||
}
|
||||
|
||||
const cached = titleCache.get(key)
|
||||
|
||||
if (cached !== undefined) {
|
||||
return Promise.resolve(cached)
|
||||
}
|
||||
|
||||
const inflight = titleInflight.get(key)
|
||||
|
||||
if (inflight) {
|
||||
return inflight
|
||||
}
|
||||
|
||||
const local = lookupLocalSessionTitle(value)
|
||||
|
||||
if (local) {
|
||||
titleCache.set(key, local)
|
||||
|
||||
return Promise.resolve(local)
|
||||
}
|
||||
|
||||
const { profile, sessionId } = parseSessionRefValue(value)
|
||||
|
||||
const promise = requestSessionRow(sessionId, profile)
|
||||
.then(row => (row ? sessionRowTitle(row) : ''))
|
||||
.then(title => {
|
||||
titleCache.set(key, title)
|
||||
titleInflight.delete(key)
|
||||
titleSubs.get(key)?.forEach(notify => notify(title))
|
||||
|
||||
return title
|
||||
})
|
||||
|
||||
titleInflight.set(key, promise)
|
||||
|
||||
return promise
|
||||
}
|
||||
|
||||
export function useSessionLinkTitle(value: string, fallbackLabel?: string): string {
|
||||
const key = useMemo(() => sessionRefCacheKey(value), [value])
|
||||
const fallback = fallbackLabel?.trim() || sessionRefFallbackLabel(value)
|
||||
const [title, setTitle] = useState(() => (key ? titleCache.get(key) || lookupLocalSessionTitle(value) : ''))
|
||||
|
||||
useEffect(() => {
|
||||
if (!key) {
|
||||
return
|
||||
}
|
||||
|
||||
const known = titleCache.get(key) || lookupLocalSessionTitle(value)
|
||||
|
||||
setTitle(known)
|
||||
|
||||
if (known) {
|
||||
return
|
||||
}
|
||||
|
||||
const subs = titleSubs.get(key) ?? new Set<(resolved: string) => void>()
|
||||
|
||||
subs.add(setTitle)
|
||||
titleSubs.set(key, subs)
|
||||
void fetchSessionLinkTitle(value)
|
||||
|
||||
return () => {
|
||||
subs.delete(setTitle)
|
||||
|
||||
if (!subs.size) {
|
||||
titleSubs.delete(key)
|
||||
}
|
||||
}
|
||||
}, [key, value])
|
||||
|
||||
return title || fallback
|
||||
}
|
||||
|
||||
export function __resetSessionLinkTitleCache(): void {
|
||||
titleCache.clear()
|
||||
titleInflight.clear()
|
||||
titleSubs.clear()
|
||||
}
|
||||
124
apps/desktop/src/lib/session-refs.test.ts
Normal file
124
apps/desktop/src/lib/session-refs.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { preprocessMarkdown } from './markdown-preprocess'
|
||||
import {
|
||||
linkifySessionRefs,
|
||||
parseSessionRefValue,
|
||||
sessionMarkdownHref,
|
||||
sessionRefCacheKey,
|
||||
sessionRefFallbackLabel,
|
||||
sessionRefFromMarkdownHref,
|
||||
splitSessionRefValue
|
||||
} from './session-refs'
|
||||
|
||||
describe('parseSessionRefValue', () => {
|
||||
it('splits profile and session id', () => {
|
||||
expect(parseSessionRefValue('work/20260101_abc123')).toEqual({
|
||||
profile: 'work',
|
||||
sessionId: '20260101_abc123'
|
||||
})
|
||||
})
|
||||
|
||||
it('treats bare values as session ids', () => {
|
||||
expect(parseSessionRefValue('20260101_abc123')).toEqual({ sessionId: '20260101_abc123' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessionRefFallbackLabel', () => {
|
||||
it('truncates long ids', () => {
|
||||
expect(sessionRefFallbackLabel('default/20260610_120000_abcdef')).toBe('20260610…')
|
||||
})
|
||||
|
||||
it('leaves short ids alone', () => {
|
||||
expect(sessionRefFallbackLabel('work/abc123')).toBe('abc123')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessionRefCacheKey', () => {
|
||||
it('separates the same id across profiles', () => {
|
||||
expect(sessionRefCacheKey('work/abc')).not.toBe(sessionRefCacheKey('home/abc'))
|
||||
})
|
||||
|
||||
it('is empty for a valueless ref', () => {
|
||||
expect(sessionRefCacheKey(' ')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitSessionRefValue', () => {
|
||||
it('peels prose punctuation off a bare value', () => {
|
||||
expect(splitSessionRefValue('default/abc123.')).toEqual({ trailing: '.', value: 'default/abc123' })
|
||||
})
|
||||
|
||||
it('leaves a quoted value fenced', () => {
|
||||
expect(splitSessionRefValue('`my session`')).toEqual({ trailing: '', value: 'my session' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('session markdown hrefs', () => {
|
||||
it('round-trips a value through the fragment href', () => {
|
||||
const href = sessionMarkdownHref('work/20260101_abc123')
|
||||
|
||||
expect(href).toBe('#session/work%2F20260101_abc123')
|
||||
expect(sessionRefFromMarkdownHref(href)).toBe('work/20260101_abc123')
|
||||
})
|
||||
|
||||
it('ignores hrefs that are not session fragments', () => {
|
||||
expect(sessionRefFromMarkdownHref('#preview/foo')).toBeNull()
|
||||
expect(sessionRefFromMarkdownHref('https://example.com')).toBeNull()
|
||||
expect(sessionRefFromMarkdownHref(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('linkifySessionRefs', () => {
|
||||
it('rewrites a bare ref into a session link', () => {
|
||||
expect(linkifySessionRefs('see @session:work/20260101_abc123 next')).toBe(
|
||||
'see [20260101…](#session/work%2F20260101_abc123) next'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps trailing prose punctuation outside the link', () => {
|
||||
expect(linkifySessionRefs('see @session:default/abc123.')).toBe('see [abc123](#session/default%2Fabc123).')
|
||||
})
|
||||
|
||||
it('leaves text without a ref untouched', () => {
|
||||
const text = 'no references here'
|
||||
|
||||
expect(linkifySessionRefs(text)).toBe(text)
|
||||
})
|
||||
|
||||
it('does not match an email-like or path-embedded @session', () => {
|
||||
expect(linkifySessionRefs('me@session:abc')).toBe('me@session:abc')
|
||||
expect(linkifySessionRefs('a/@session:abc')).toBe('a/@session:abc')
|
||||
})
|
||||
|
||||
it('leaves a ref a model already wrapped in a markdown link alone', () => {
|
||||
const text = '[that chat](@session:work/abc123)'
|
||||
|
||||
expect(linkifySessionRefs(text)).toBe(text)
|
||||
})
|
||||
})
|
||||
|
||||
// The agent-authored path: assistant markdown runs through preprocessMarkdown
|
||||
// before Streamdown, so a bare ref must survive as a link — and must NOT be
|
||||
// rewritten inside code, where it is being discussed rather than referenced.
|
||||
describe('preprocessMarkdown session refs', () => {
|
||||
it('linkifies a ref in prose', () => {
|
||||
expect(preprocessMarkdown('Context is in @session:work/20260101_abc123 there.')).toContain(
|
||||
'[20260101…](#session/work%2F20260101_abc123)'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves refs inside inline code alone', () => {
|
||||
const out = preprocessMarkdown('Type `@session:work/20260101_abc123` to link a chat.')
|
||||
|
||||
expect(out).toContain('`@session:work/20260101_abc123`')
|
||||
expect(out).not.toContain('#session/')
|
||||
})
|
||||
|
||||
it('leaves refs inside a fenced block alone', () => {
|
||||
const out = preprocessMarkdown(['```text', '@session:work/20260101_abc123', '```'].join('\n'))
|
||||
|
||||
expect(out).toContain('@session:work/20260101_abc123')
|
||||
expect(out).not.toContain('#session/')
|
||||
})
|
||||
})
|
||||
118
apps/desktop/src/lib/session-refs.ts
Normal file
118
apps/desktop/src/lib/session-refs.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* Pure helpers for `@session:<profile>/<id>` references.
|
||||
*
|
||||
* Kept free of React/store/API imports so the markdown preprocessor (a hot
|
||||
* per-flush path) can use them without pulling in the resolver; the stateful
|
||||
* title lookup lives in `session-link-title.ts`.
|
||||
*/
|
||||
|
||||
/** Mirrors the composer/transcript form in `directive-text.tsx`: a bare value,
|
||||
* or one fenced in backticks/quotes so a value with spaces survives. The
|
||||
* lookbehinds keep `foo@session:` and URL paths from matching, and skip a ref
|
||||
* a model already wrapped in a markdown link — rewriting that would nest. */
|
||||
export const SESSION_REF_RE = /(?<![\w/])(?<!]\()@session:(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
|
||||
|
||||
const TRAILING_PUNCTUATION_RE = /[,.;:!?)\]}]+$/
|
||||
|
||||
function unwrapQuotes(raw: string): null | string {
|
||||
if (raw.length < 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
const head = raw[0]
|
||||
const tail = raw[raw.length - 1]
|
||||
|
||||
if ((head === '`' && tail === '`') || (head === '"' && tail === '"') || (head === "'" && tail === "'")) {
|
||||
return raw.slice(1, -1)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Splits a matched value into the reference itself and any prose punctuation
|
||||
* that the greedy `\S+` branch swallowed. Quoted values are already fenced. */
|
||||
export function splitSessionRefValue(raw: string): { trailing: string; value: string } {
|
||||
const quoted = unwrapQuotes(raw)
|
||||
|
||||
if (quoted !== null) {
|
||||
return { trailing: '', value: quoted }
|
||||
}
|
||||
|
||||
const value = raw.replace(TRAILING_PUNCTUATION_RE, '')
|
||||
|
||||
return { trailing: raw.slice(value.length), value }
|
||||
}
|
||||
|
||||
/** Session ids never contain a slash, so a slash unambiguously means
|
||||
* `<profile>/<id>` — same split as `tools/session_search_tool.py`. */
|
||||
export function parseSessionRefValue(value: string): { profile?: string; sessionId: string } {
|
||||
const trimmed = value.trim()
|
||||
const slash = trimmed.indexOf('/')
|
||||
|
||||
if (slash === -1) {
|
||||
return { sessionId: trimmed }
|
||||
}
|
||||
|
||||
const profile = trimmed.slice(0, slash).trim()
|
||||
const sessionId = trimmed.slice(slash + 1).trim()
|
||||
|
||||
return sessionId ? { profile: profile || undefined, sessionId } : { sessionId: trimmed }
|
||||
}
|
||||
|
||||
export function sessionRefCacheKey(value: string): string {
|
||||
const { profile, sessionId } = parseSessionRefValue(value)
|
||||
|
||||
return sessionId ? `${profile ?? ''}/${sessionId}` : ''
|
||||
}
|
||||
|
||||
/** Chip label before (or without) a resolved title — a short, still-identifying id. */
|
||||
export function sessionRefFallbackLabel(value: string): string {
|
||||
const { sessionId } = parseSessionRefValue(value)
|
||||
|
||||
if (!sessionId) {
|
||||
return value
|
||||
}
|
||||
|
||||
return sessionId.length > 10 ? `${sessionId.slice(0, 8)}…` : sessionId
|
||||
}
|
||||
|
||||
/** A fragment href, matching the `#preview/` convention in `preview-targets.ts`
|
||||
* — no custom URL scheme to survive markdown sanitization. */
|
||||
export function sessionMarkdownHref(value: string): string {
|
||||
return `#session/${encodeURIComponent(value)}`
|
||||
}
|
||||
|
||||
export function sessionRefFromMarkdownHref(href?: string): null | string {
|
||||
if (!href?.startsWith('#session/')) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(href.slice('#session/'.length)) || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites bare `@session:<profile>/<id>` tokens into markdown links so an
|
||||
* agent-authored reference reaches `MarkdownLink` and renders as a chip.
|
||||
* Callers must exclude code spans/fences — `preprocessMarkdown` already does.
|
||||
*/
|
||||
export function linkifySessionRefs(text: string): string {
|
||||
if (!text.includes('@session:')) {
|
||||
return text
|
||||
}
|
||||
|
||||
return text.replace(SESSION_REF_RE, (match, raw: string) => {
|
||||
const { trailing, value } = splitSessionRefValue(raw)
|
||||
|
||||
if (!parseSessionRefValue(value).sessionId) {
|
||||
return match
|
||||
}
|
||||
|
||||
const label = sessionRefFallbackLabel(value).replace(/[[\]\\]/g, '\\$&')
|
||||
|
||||
return `[${label}](${sessionMarkdownHref(value)})${trailing}`
|
||||
})
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ from tools.session_search_tool import (
|
|||
_is_compacted_message,
|
||||
_is_compression_ended,
|
||||
_resolve_to_parent,
|
||||
_session_link,
|
||||
session_search,
|
||||
)
|
||||
|
||||
|
|
@ -492,6 +493,69 @@ class TestReadShape:
|
|||
assert len(result["messages"]) == 30 # head 20 + tail 10
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Session links — the value the agent writes to point the user at a session
|
||||
# =========================================================================
|
||||
|
||||
def _linked_session_id(link: str) -> str:
|
||||
"""Recover the session id from an `@session:[<profile>/]<id>` value."""
|
||||
assert link.startswith("@session:"), link
|
||||
value = link[len("@session:"):]
|
||||
|
||||
return value.rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
class TestSessionLink:
|
||||
def test_link_carries_the_named_profile(self):
|
||||
assert _session_link("s_oldest", "work") == "@session:work/s_oldest"
|
||||
|
||||
def test_link_falls_back_to_a_bare_id_when_the_profile_is_unknown(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name",
|
||||
lambda: "custom",
|
||||
)
|
||||
|
||||
assert _session_link("s_oldest") == "@session:s_oldest"
|
||||
|
||||
def test_link_survives_a_failure_to_resolve_the_profile(self, monkeypatch):
|
||||
def boom():
|
||||
raise RuntimeError("no profile")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.profiles.get_active_profile_name", boom)
|
||||
|
||||
assert _session_link("s_oldest") == "@session:s_oldest"
|
||||
|
||||
def test_read_result_links_to_the_session_it_read(self, db):
|
||||
_seed_modpack_sessions(db)
|
||||
result = json.loads(session_search(session_id="s_oldest", db=db))
|
||||
|
||||
assert _linked_session_id(result["link"]) == result["session_id"]
|
||||
|
||||
def test_every_discovery_result_links_to_its_own_session(self, db):
|
||||
_seed_modpack_sessions(db)
|
||||
result = json.loads(session_search(query="modpack", limit=5, db=db))
|
||||
|
||||
assert result["results"]
|
||||
for entry in result["results"]:
|
||||
assert _linked_session_id(entry["link"]) == entry["session_id"]
|
||||
|
||||
def test_every_browse_result_links_to_its_own_session(self, db):
|
||||
_seed_modpack_sessions(db)
|
||||
result = json.loads(session_search(db=db))
|
||||
|
||||
assert result["results"]
|
||||
for entry in result["results"]:
|
||||
assert _linked_session_id(entry["link"]) == entry["session_id"]
|
||||
|
||||
def test_link_splits_the_way_the_tool_parses_it_back(self):
|
||||
"""The agent hands its own link back as session_id; the value has to
|
||||
survive the profile/id partition in session_search."""
|
||||
value = _session_link("s_middle", "work")[len("@session:"):]
|
||||
profile, _, session_id = value.partition("/")
|
||||
|
||||
assert (profile, session_id) == ("work", "s_middle")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Cross-profile read — `profile` swaps in another profile's DB (read-only)
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -291,6 +291,28 @@ def _resolve_profile_db(profile: str):
|
|||
return SessionDB(db_path=profiles_mod.get_profile_dir(canon) / "state.db", read_only=True)
|
||||
|
||||
|
||||
def _session_link(session_id: str, profile: str = None) -> str:
|
||||
"""The reference the agent writes to point the user at a session.
|
||||
|
||||
Same value the desktop composer emits when a session is dragged into a
|
||||
message, so the desktop renders it as a link carrying the session's title.
|
||||
The profile segment is omitted when we can't name it confidently — a bare
|
||||
id still resolves, it just can't disambiguate across profiles.
|
||||
"""
|
||||
name = (profile or "").strip()
|
||||
if not name:
|
||||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
|
||||
resolved = get_active_profile_name()
|
||||
name = "" if resolved == "custom" else resolved
|
||||
except Exception:
|
||||
logging.debug("get_active_profile_name failed for session link", exc_info=True)
|
||||
name = ""
|
||||
|
||||
return f"@session:{name}/{session_id}" if name else f"@session:{session_id}"
|
||||
|
||||
|
||||
def _locate_session_db(session_id: str):
|
||||
"""Scan every profile's ``state.db`` (read-only) for a session id.
|
||||
|
||||
|
|
@ -335,7 +357,7 @@ def _locate_session_db(session_id: str):
|
|||
return None, None
|
||||
|
||||
|
||||
def _read_session(db, session_id: str, head: int = 20, tail: int = 10) -> str:
|
||||
def _read_session(db, session_id: str, head: int = 20, tail: int = 10, link_profile: str = None) -> str:
|
||||
"""Read shape: dump a whole session by id (head + tail when large).
|
||||
|
||||
Serves the linked-session case — the user dropped an @session reference and
|
||||
|
|
@ -366,6 +388,7 @@ def _read_session(db, session_id: str, head: int = 20, tail: int = 10) -> str:
|
|||
"success": True,
|
||||
"mode": "read",
|
||||
"session_id": session_id,
|
||||
"link": _session_link(session_id, link_profile),
|
||||
"session_meta": {
|
||||
"when": _format_timestamp(meta.get("started_at")),
|
||||
"source": meta.get("source"),
|
||||
|
|
@ -384,7 +407,7 @@ def _read_session(db, session_id: str, head: int = 20, tail: int = 10) -> str:
|
|||
return json.dumps(response, ensure_ascii=False)
|
||||
|
||||
|
||||
def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str:
|
||||
def _list_recent_sessions(db, limit: int, current_session_id: str = None, link_profile: str = None) -> str:
|
||||
"""Return metadata for the most recent sessions (no LLM calls, no FTS5)."""
|
||||
try:
|
||||
sessions = db.list_sessions_rich(
|
||||
|
|
@ -405,6 +428,7 @@ def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str
|
|||
continue
|
||||
results.append({
|
||||
"session_id": sid,
|
||||
"link": _session_link(sid, link_profile),
|
||||
"title": s.get("title") or None,
|
||||
"source": s.get("source", ""),
|
||||
"started_at": s.get("started_at", ""),
|
||||
|
|
@ -630,6 +654,7 @@ def _discover(
|
|||
limit: int,
|
||||
sort: Optional[str],
|
||||
current_session_id: str = None,
|
||||
link_profile: str = None,
|
||||
) -> str:
|
||||
"""Discovery shape: FTS5 + anchored window + bookends per hit. Single call."""
|
||||
role_list = role_filter if role_filter else ["user", "assistant"]
|
||||
|
|
@ -764,6 +789,9 @@ def _discover(
|
|||
entry["parent_session_id"] = lineage_root
|
||||
results.append(entry)
|
||||
|
||||
for entry in results:
|
||||
entry["link"] = _session_link(entry["session_id"], link_profile)
|
||||
|
||||
_final_payload = {
|
||||
"success": True,
|
||||
"mode": "discover",
|
||||
|
|
@ -848,7 +876,7 @@ def session_search(
|
|||
# Read shape: a session_id with no anchor → dump the whole session.
|
||||
if isinstance(session_id, str) and session_id.strip():
|
||||
sid = session_id.strip()
|
||||
result = _read_session(db, sid)
|
||||
result = _read_session(db, sid, link_profile=profile)
|
||||
if json.loads(result).get("success"):
|
||||
return result
|
||||
|
||||
|
|
@ -858,7 +886,7 @@ def session_search(
|
|||
located, owner = _locate_session_db(sid)
|
||||
if located is not None:
|
||||
try:
|
||||
found = json.loads(_read_session(located, sid))
|
||||
found = json.loads(_read_session(located, sid, link_profile=owner))
|
||||
finally:
|
||||
located.close()
|
||||
if found.get("success"):
|
||||
|
|
@ -876,7 +904,7 @@ def session_search(
|
|||
|
||||
# Browse shape: no query → recent sessions.
|
||||
if not query or not isinstance(query, str) or not query.strip():
|
||||
return _list_recent_sessions(db, limit, current_session_id)
|
||||
return _list_recent_sessions(db, limit, current_session_id, link_profile=profile)
|
||||
|
||||
# Parse role_filter
|
||||
role_list: Optional[List[str]] = None
|
||||
|
|
@ -897,6 +925,7 @@ def session_search(
|
|||
limit=limit,
|
||||
sort=sort_norm,
|
||||
current_session_id=current_session_id,
|
||||
link_profile=profile,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -962,6 +991,16 @@ SESSION_SEARCH_SCHEMA = {
|
|||
" session_search()\n"
|
||||
" Returns recent sessions chronologically: titles, previews, timestamps. "
|
||||
"Use when the user asks \"what was I working on\" without naming a topic.\n\n"
|
||||
"LINKING THE USER TO A SESSION\n\n"
|
||||
" When you refer the user to a session, write its `link` value inline in "
|
||||
"your reply — every result carries one, e.g. "
|
||||
"`@session:default/20260722_204335_d62c16`. Copy it verbatim; do not "
|
||||
"reformat it as a markdown link or wrap it in backticks. Hermes renders "
|
||||
"it as a link showing the session's title, so the link IS the title: "
|
||||
"use it as a noun mid-sentence (\"that's @session:default/... — want me "
|
||||
"to pick it up?\"), never alone on its own line, and never alongside the "
|
||||
"title, id, or date spelled out — that shows the user the same session "
|
||||
"twice.\n\n"
|
||||
"FTS5 SYNTAX\n\n"
|
||||
" AND is the default — multi-word queries require all terms. Use OR explicitly "
|
||||
"for broader recall (`alpha OR beta OR gamma`), quoted phrases for exact match "
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue